Merge develop into fix/html-embed-hardening (#46)
Test / test (pull_request) Has been cancelled

Resolve the html-embed.spec.ts conflict as a union: both #46 and #49 (already in
develop) added different test cases to the same file. Keep all of them —
stripHtmlEmbedNodes gets #46's root-node case plus develop's deeply-nested,
non-object and empty-content cases; #46's collectHtmlEmbedSources and
stripDisallowedHtmlEmbedNodes suites and develop's hasHtmlEmbedNode suite all
kept; imports unioned. No production code conflicted.

Full suite green: server 651, client (16 files), editor-ext 56, mcp 247.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude_code
2026-06-21 01:59:22 +03:00
127 changed files with 12089 additions and 769 deletions
+10
View File
@@ -2,6 +2,16 @@
APP_URL=http://localhost:3000
PORT=3000
# --- Security / reverse proxy ---
# The app runs with Fastify `trustProxy` ENABLED, so it derives the client IP
# (req.ip) from the `X-Forwarded-For` header. That header is client-forgeable.
# Deploy this app behind a trusted reverse proxy that SETS/OVERWRITES (not
# appends) `X-Forwarded-For` with the real client IP. Without such a proxy, any
# per-IP throttling — including the /mcp Basic brute-force limiter — can be
# bypassed by an attacker who simply spoofs `X-Forwarded-For` to rotate IPs.
# (The /mcp limiter keeps a global per-email key as an IP-independent backstop,
# but the per-IP and per-IP+email keys rely on a trustworthy X-Forwarded-For.)
# minimum of 32 characters. Generate one with: openssl rand -hex 32
APP_SECRET=REPLACE_WITH_LONG_SECRET
+5
View File
@@ -18,7 +18,12 @@ env:
IMAGE: ghcr.io/vvzvlad/gitmost
jobs:
# Run the reusable test suite first so a failing test blocks the image build.
test:
uses: ./.github/workflows/test.yml
build:
needs: test
runs-on: ubuntu-latest
steps:
- name: Checkout
+5
View File
@@ -19,7 +19,12 @@ env:
IMAGE: ghcr.io/vvzvlad/gitmost
jobs:
# Run the reusable test suite first so a failing test blocks the image build.
test:
uses: ./.github/workflows/test.yml
build:
needs: test
strategy:
matrix:
include:
+40
View File
@@ -0,0 +1,40 @@
name: Test
on:
pull_request:
workflow_call:
workflow_dispatch:
concurrency:
group: test-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up pnpm
uses: pnpm/action-setup@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Required for the client suite, which resolves @docmost/editor-ext via its
# dist build (the server suite also rebuilds it through its own pretest).
- name: Build editor-ext
run: pnpm --filter @docmost/editor-ext build
- name: Run tests
run: pnpm -r test
@@ -183,6 +183,7 @@
"Successfully imported": "Successfully imported",
"Successfully restored": "Successfully restored",
"System settings": "System settings",
"Template": "Template",
"Templates": "Templates",
"Theme": "Theme",
"To change your email, you have to enter your password and new email.": "To change your email, you have to enter your password and new email.",
@@ -473,6 +474,7 @@
"Make sub-pages public too": "Make sub-pages public too",
"Allow search engines to index page": "Allow search engines to index page",
"Open page": "Open page",
"Open source page": "Open source page",
"Page": "Page",
"Delete public share link": "Delete public share link",
"Delete share": "Delete share",
@@ -183,6 +183,7 @@
"Successfully imported": "Успешно импортировано",
"Successfully restored": "Успешно восстановлено",
"System settings": "Системные настройки",
"Template": "Шаблон",
"Templates": "Шаблоны",
"Theme": "Тема",
"To change your email, you have to enter your password and new email.": "Чтобы изменить электронную почту, вам нужно ввести пароль и новый адрес.",
@@ -391,6 +392,13 @@
"Toggle block": "Сворачиваемый блок",
"Callout": "Выноска",
"Insert callout notice.": "Вставить выноску с сообщением.",
"Footnote": "Сноска",
"Insert a footnote reference.": "Вставить ссылку на сноску.",
"Footnotes": "Примечания",
"Footnote {{number}}": "Сноска {{number}}",
"Go to footnote": "Перейти к сноске",
"Back to reference": "Вернуться к ссылке",
"Empty footnote": "Пустая сноска",
"Math inline": "Строчная формула",
"Insert inline math equation.": "Вставить математическое выражение в строку.",
"Math block": "Блок формулы",
@@ -471,6 +479,7 @@
"Make sub-pages public too": "Сделать подстраницы тоже общедоступными",
"Allow search engines to index page": "Разрешить поисковым системам индексировать страницу",
"Open page": "Открыть страницу",
"Open source page": "Открыть исходную страницу",
"Page": "Страница",
"Delete public share link": "Удалить публичную ссылку",
"Delete share": "Удалить общий доступ",
@@ -57,6 +57,12 @@
display: none;
}
/* In the collapsed state the header expands the window on click, so hint that
it is clickable (override the drag `grab` cursor). */
.minimized .dragBar {
cursor: pointer;
}
.dragBar {
display: flex;
align-items: center;
@@ -18,7 +18,7 @@ import {
IconX,
} from "@tabler/icons-react";
import { useAtom, useSetAtom } from "jotai";
import { useParams } from "react-router-dom";
import { useMatch } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import {
@@ -38,6 +38,10 @@ import {
import ConversationList from "@/features/ai-chat/components/conversation-list.tsx";
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
import { buildChatMarkdown } from "@/features/ai-chat/utils/chat-markdown.ts";
import {
shouldCollapseOnOutsidePointer,
isHeaderClick,
} from "@/features/ai-chat/utils/collapse-helpers.ts";
import { useClipboard } from "@/hooks/use-clipboard";
import { notifications } from "@mantine/notifications";
import classes from "@/features/ai-chat/components/ai-chat-window.module.css";
@@ -110,6 +114,10 @@ export default function AiChatWindow() {
// History section starts collapsed (matches the former panel's behavior).
const [historyOpen, setHistoryOpen] = useState(false);
const [minimized, setMinimized] = useState(false);
// Mirror of `minimized` for handlers wrapped in useCallback([]) (startDrag),
// which would otherwise close over a stale value. Kept in sync below.
const minimizedRef = useRef(minimized);
minimizedRef.current = minimized;
const winRef = useRef<HTMLDivElement>(null);
// Live window geometry (position + size); initialized lazily on first open so
@@ -140,13 +148,16 @@ export default function AiChatWindow() {
const { data: messageRows, isLoading: messagesLoading } =
useAiChatMessagesQuery(activeChatId ?? undefined);
// The page the user is currently viewing, derived from the route (same
// source the breadcrumb uses). On a non-page route `pageSlug` is undefined,
// so the query is disabled and `openPage` is null. This is passed to the
// chat thread as context so the agent knows what "this page"/"the current
// page" refers to; the agent still reads/writes via its CASL-enforced page
// tools using the id.
const { pageSlug } = useParams();
// The page the user is currently viewing. AiChatWindow lives in a pathless
// parent layout route, so useParams() can't see :pageSlug. Match the full
// pathname against the authenticated page route instead so "the current page"
// resolves regardless of where this component is mounted. On a non-page route
// the match is null, so `pageSlug` is undefined, the query is disabled and
// `openPage` is null. This is passed to the chat thread as context so the
// agent knows what "this page"/"the current page" refers to; the agent still
// reads/writes via its CASL-enforced page tools using the id.
const pageRouteMatch = useMatch("/s/:spaceSlug/p/:pageSlug");
const pageSlug = pageRouteMatch?.params?.pageSlug;
const { data: openPageData } = usePageQuery({
pageId: extractPageSlugId(pageSlug),
});
@@ -254,8 +265,31 @@ export default function AiChatWindow() {
useLayoutEffect(() => {
if (!windowOpen) return;
setGeom((prev) => (prev ? clampGeom(prev) : computeInitialGeom()));
// Always show the window expanded on (re)open: a collapsed state from a
// previous open session must not stick. Runs before paint so the first
// frame is already expanded. The composer's autofocus is a focus INSIDE the
// window (not an outside mousedown), so it cannot self-collapse the window.
setMinimized(false);
}, [windowOpen]);
// Auto-collapse the window into its header as soon as the user interacts with
// anything outside it (clicks the page/editor). Armed ONLY while the window is
// open and expanded, so it never fires repeatedly and never collapses on the
// open→reset transition. Capture phase so a page handler's stopPropagation in
// the bubble phase can't hide the event from us; the in-window/portal guards
// (shouldCollapseOnOutsidePointer) prevent false collapses from clicks inside
// the window or inside Mantine portals (kebab menu, delete-confirm modal).
useEffect(() => {
if (!windowOpen || minimized) return;
const onPointerDown = (e: MouseEvent): void => {
if (shouldCollapseOnOutsidePointer(e.target, winRef.current)) {
setMinimized(true);
}
};
document.addEventListener("mousedown", onPointerDown, true);
return () => document.removeEventListener("mousedown", onPointerDown, true);
}, [windowOpen, minimized]);
// Persist the user's resize into state so it survives close/reopen. Skipped
// while minimized so the collapsed (auto) height is never captured. The
// equality guard avoids an update loop.
@@ -303,10 +337,21 @@ export default function AiChatWindow() {
el.style.top = `${nt}px`;
};
const up = (): void => {
const up = (ev: MouseEvent): void => {
document.removeEventListener("mousemove", move);
document.removeEventListener("mouseup", up);
document.body.style.userSelect = "";
// Treat a near-zero-movement press as a click (not a drag). When the
// window is minimized, a header click expands it; nothing to persist
// because the position did not change. minimizedRef avoids the stale
// `minimized` captured by useCallback([]).
if (
minimizedRef.current &&
isHeaderClick(sx, sy, ev.clientX, ev.clientY)
) {
setMinimized(false);
return;
}
const el2 = winRef.current;
// Persist the final position back into state (preserving the size) so
// re-renders keep it.
@@ -350,14 +395,40 @@ export default function AiChatWindow() {
height: minimized ? undefined : geom.height,
}}
>
{/* drag bar / header */}
{/* drag bar / header. Mouse users expand a minimized window by clicking
anywhere on the bar (the click-vs-drag logic in startDrag, which
excludes the buttons). The keyboard/screen-reader Expand affordance
lives on the title element below — NOT on this container — so we never
nest the Minimize/Close <button>s inside an element with
role="button" (invalid ARIA: nested interactive controls). */}
<div className={classes.dragBar} onMouseDown={startDrag}>
<IconGripVertical
size={14}
color="var(--mantine-color-gray-4)"
style={{ flex: "none" }}
/>
<span className={classes.title}>{t("AI chat")}</span>
{/* When minimized, the title doubles as the keyboard Expand button:
it carries role/tabIndex/aria-label and an Enter/Space handler, and
unlike the dragBar it contains no nested <button>s. When expanded it
is a plain, non-focusable label. */}
<span
className={classes.title}
role={minimized ? "button" : undefined}
tabIndex={minimized ? 0 : undefined}
aria-label={minimized ? t("Expand") : undefined}
onKeyDown={
minimized
? (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setMinimized(false);
}
}
: undefined
}
>
{t("AI chat")}
</span>
{/* Role badge for the active chat (emoji + name). Shown only when the
chat is bound to a role that still exists. */}
@@ -10,6 +10,18 @@ import classes from "@/features/ai-chat/components/ai-chat.module.css";
interface MessageItemProps {
message: UIMessage;
/**
* Forwarded to ToolCallCard: whether tool cards render page citation links.
* Defaults to true (internal chat). The public share passes false.
*/
showCitations?: boolean;
/**
* Neutralize internal/relative markdown links in the rendered answer (drop
* their href so they become inert text). Defaults to false (internal chat,
* links stay clickable). The anonymous public share passes true so internal
* UUIDs/routes in the assistant's markdown don't leak as clickable links.
*/
neutralizeInternalLinks?: boolean;
}
/**
@@ -24,7 +36,11 @@ interface MessageItemProps {
* `message` prop identity (and its `parts`) changes each tick. Re-rendering the
* text parts on each delta is what makes the answer stream in progressively.
*/
export default function MessageItem({ message }: MessageItemProps) {
export default function MessageItem({
message,
showCitations = true,
neutralizeInternalLinks = false,
}: MessageItemProps) {
const { t } = useTranslation();
const isUser = message.role === "user";
@@ -53,7 +69,9 @@ export default function MessageItem({ message }: MessageItemProps) {
// starts with an empty text part before the first token arrives); the
// typing indicator covers that gap until real content streams in.
if (!part.text.trim()) return null;
const html = renderChatMarkdown(part.text);
const html = renderChatMarkdown(part.text, {
neutralizeInternalLinks,
});
if (html) {
return (
<div
@@ -73,7 +91,13 @@ export default function MessageItem({ message }: MessageItemProps) {
}
if (isToolPart(part.type)) {
return <ToolCallCard key={index} part={part as unknown as ToolUiPart} />;
return (
<ToolCallCard
key={index}
part={part as unknown as ToolUiPart}
showCitations={showCitations}
/>
);
}
return null;
@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react";
import { ReactNode, useEffect, useRef } from "react";
import { Center, ScrollArea, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import type { UIMessage } from "@ai-sdk/react";
@@ -10,6 +10,26 @@ import classes from "@/features/ai-chat/components/ai-chat.module.css";
interface MessageListProps {
messages: UIMessage[];
isStreaming: boolean;
/**
* Content shown when the transcript is empty and no turn is in flight.
* Defaults to the internal chat's prompt. The public share passes its own
* documentation-focused copy. This is purely the empty-state text; the
* streaming/typing/markdown/tool-card paths below are shared verbatim.
*/
emptyState?: ReactNode;
/**
* Forwarded to MessageItem -> ToolCallCard: whether tool cards render page
* citation links. Defaults to true (internal chat). The public share passes
* false because an anonymous reader cannot open the linked internal pages.
*/
showCitations?: boolean;
/**
* Forwarded to MessageItem: neutralize internal/relative markdown links in
* the rendered answers (drop their href so they render as inert text).
* Defaults to false (internal chat). The public share passes true so internal
* UUIDs/routes don't leak as clickable links to anonymous readers.
*/
neutralizeInternalLinks?: boolean;
}
// Distance (px) from the bottom within which the viewport still counts as
@@ -24,7 +44,7 @@ const BOTTOM_THRESHOLD = 40;
* - the last (assistant) message has no non-empty text and no tool part.
* Once any text/tool part arrives, MessageItem renders it and this hides.
*/
function showTypingIndicator(messages: UIMessage[], isStreaming: boolean): boolean {
export function showTypingIndicator(messages: UIMessage[], isStreaming: boolean): boolean {
if (!isStreaming) return false;
const last = messages[messages.length - 1];
if (!last) return true; // submitted with nothing rendered yet.
@@ -41,7 +61,13 @@ function showTypingIndicator(messages: UIMessage[], isStreaming: boolean): boole
* but only while the user is pinned to the bottom — if they scrolled up to read
* earlier messages, streamed deltas no longer yank them back down.
*/
export default function MessageList({ messages, isStreaming }: MessageListProps) {
export default function MessageList({
messages,
isStreaming,
emptyState,
showCitations = true,
neutralizeInternalLinks = false,
}: MessageListProps) {
const { t } = useTranslation();
const viewportRef = useRef<HTMLDivElement>(null);
// Whether the viewport is currently pinned to the bottom. Starts true so the
@@ -104,9 +130,11 @@ export default function MessageList({ messages, isStreaming }: MessageListProps)
if (messages.length === 0 && !typing) {
return (
<Center className={classes.messages}>
<Text size="sm" c="dimmed" ta="center">
{t("Ask the AI agent anything about your workspace.")}
</Text>
{emptyState ?? (
<Text size="sm" c="dimmed" ta="center">
{t("Ask the AI agent anything about your workspace.")}
</Text>
)}
</Center>
);
}
@@ -115,7 +143,12 @@ export default function MessageList({ messages, isStreaming }: MessageListProps)
<ScrollArea className={classes.messages} viewportRef={viewportRef} scrollbarSize={6} type="scroll">
<Stack gap={0} pr="xs">
{messages.map((message) => (
<MessageItem key={message.id} message={message} />
<MessageItem
key={message.id}
message={message}
showCitations={showCitations}
neutralizeInternalLinks={neutralizeInternalLinks}
/>
))}
{typing && <TypingIndicator />}
</Stack>
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import type { UIMessage } from "@ai-sdk/react";
import { showTypingIndicator } from "@/features/ai-chat/components/message-list.tsx";
/**
* Pure-helper tests for the typing-indicator bridging logic that the internal
* chat and the public share widget now share. This is the behavior that decides
* whether the animated "AI agent is typing…" placeholder shows in the gap
* between sending and the first streamed token.
*/
const msg = (
role: "user" | "assistant",
parts: UIMessage["parts"],
): UIMessage => ({ id: Math.random().toString(), role, parts }) as UIMessage;
describe("showTypingIndicator", () => {
it("is hidden when not streaming", () => {
expect(showTypingIndicator([], false)).toBe(false);
expect(
showTypingIndicator([msg("assistant", [{ type: "text", text: "hi" }])], false),
).toBe(false);
});
it("shows while streaming with no messages yet (just submitted)", () => {
expect(showTypingIndicator([], true)).toBe(true);
});
it("shows while streaming when the last message is still the user's", () => {
expect(
showTypingIndicator([msg("user", [{ type: "text", text: "q" }])], true),
).toBe(true);
});
it("shows while streaming when the assistant row has no visible content", () => {
expect(
showTypingIndicator([msg("assistant", [{ type: "text", text: "" }])], true),
).toBe(true);
expect(
showTypingIndicator([msg("assistant", [{ type: "text", text: " " }])], true),
).toBe(true);
});
it("hides once the assistant streams non-empty text", () => {
expect(
showTypingIndicator([msg("assistant", [{ type: "text", text: "answer" }])], true),
).toBe(false);
});
it("hides once a tool part appears (even before any text)", () => {
const toolPart = { type: "tool-searchPages" } as unknown as UIMessage["parts"][number];
expect(
showTypingIndicator([msg("assistant", [toolPart])], true),
).toBe(false);
});
});
@@ -13,6 +13,14 @@ import classes from "@/features/ai-chat/components/ai-chat.module.css";
interface ToolCallCardProps {
part: ToolUiPart;
/**
* Whether to render page citation links. Defaults to true (the internal chat,
* where the reader is authenticated and the `/p/{id}` links resolve). The
* public share passes false: an anonymous reader cannot open internal pages,
* so the links would 404/redirect to login. Suppressing them keeps the card
* (the action log itself) while dropping the unusable links.
*/
showCitations?: boolean;
}
/**
@@ -20,12 +28,15 @@ interface ToolCallCardProps {
* agent DID (the agent writes without confirmation — D2), its run state
* (running / done / error), and citation link(s) to any referenced page(s).
*/
export default function ToolCallCard({ part }: ToolCallCardProps) {
export default function ToolCallCard({
part,
showCitations = true,
}: ToolCallCardProps) {
const { t } = useTranslation();
const toolName = getToolName(part);
const state = toolRunState(part.state);
const { key, values } = toolLabelKey(toolName);
const citations = toolCitations(part);
const citations = showCitations ? toolCitations(part) : [];
return (
<div className={classes.toolCard}>
@@ -0,0 +1,79 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
shouldCollapseOnOutsidePointer,
isHeaderClick,
} from "./collapse-helpers";
describe("shouldCollapseOnOutsidePointer", () => {
let windowEl: HTMLDivElement;
let inside: HTMLSpanElement;
let portal: HTMLDivElement;
let portalChild: HTMLButtonElement;
let page: HTMLDivElement;
beforeEach(() => {
// The floating window with a child node.
windowEl = document.createElement("div");
inside = document.createElement("span");
windowEl.appendChild(inside);
// A Mantine-style portal (data-portal="true") with a child (e.g. a menu item).
portal = document.createElement("div");
portal.setAttribute("data-portal", "true");
portalChild = document.createElement("button");
portal.appendChild(portalChild);
// An unrelated page element.
page = document.createElement("div");
document.body.append(windowEl, portal, page);
});
afterEach(() => {
document.body.innerHTML = "";
});
it("returns false for a target inside the window", () => {
expect(shouldCollapseOnOutsidePointer(inside, windowEl)).toBe(false);
expect(shouldCollapseOnOutsidePointer(windowEl, windowEl)).toBe(false);
});
it("returns false for a target inside a Mantine portal", () => {
expect(shouldCollapseOnOutsidePointer(portal, windowEl)).toBe(false);
expect(shouldCollapseOnOutsidePointer(portalChild, windowEl)).toBe(false);
});
it("returns true for a target on the page (outside window and portals)", () => {
expect(shouldCollapseOnOutsidePointer(page, windowEl)).toBe(true);
});
it("returns false when there is no window element", () => {
expect(shouldCollapseOnOutsidePointer(page, null)).toBe(false);
});
it("returns false for a non-Element target", () => {
expect(shouldCollapseOnOutsidePointer(null, windowEl)).toBe(false);
expect(shouldCollapseOnOutsidePointer(document, windowEl)).toBe(false);
});
});
describe("isHeaderClick", () => {
it("treats a zero-movement press as a click", () => {
expect(isHeaderClick(100, 100, 100, 100)).toBe(true);
});
it("treats movement within the threshold as a click", () => {
expect(isHeaderClick(100, 100, 103, 97)).toBe(true);
expect(isHeaderClick(100, 100, 104, 104)).toBe(true);
});
it("treats movement beyond the threshold (either axis) as a drag", () => {
expect(isHeaderClick(100, 100, 105, 100)).toBe(false);
expect(isHeaderClick(100, 100, 100, 105)).toBe(false);
});
it("honors a custom threshold", () => {
expect(isHeaderClick(0, 0, 8, 0, 10)).toBe(true);
expect(isHeaderClick(0, 0, 11, 0, 10)).toBe(false);
});
});
@@ -0,0 +1,41 @@
// Pure helpers for the AI chat window auto-collapse behavior. Kept free of React
// so they can be unit-tested in isolation (see collapse-helpers.test.ts).
/**
* Decide whether an outside pointer (mousedown) should collapse the chat window.
*
* Returns true only when the pointer target is genuinely "on the page": NOT
* inside the window element AND NOT inside a Mantine portal. Mantine renders
* dropdown menus (chat-list kebab), modals (delete-confirm), tooltips and
* notifications into portals tagged with `data-portal="true"`; clicks on those
* are part of operating the chat, so they must not collapse it.
*/
export function shouldCollapseOnOutsidePointer(
target: EventTarget | null,
windowEl: HTMLElement | null,
): boolean {
if (!windowEl) return false;
if (!(target instanceof Element)) return false;
// Inside the window itself -> not an "away" interaction (drag, resize, typing).
if (windowEl.contains(target)) return false;
// Inside a Mantine portal the chat owns (kebab menu, confirm modal, tooltip,
// notifications). data-portal="true" reliably excludes all of them.
if (target.closest("[data-portal]")) return false;
return true;
}
/**
* Click-vs-drag discrimination for the window header: a press whose pointer
* moved less than `threshold` px on both axes between mousedown and mouseup is
* treated as a click (which expands a collapsed window), not a drag (which
* repositions it).
*/
export function isHeaderClick(
downX: number,
downY: number,
upX: number,
upY: number,
threshold = 4,
): boolean {
return Math.abs(upX - downX) <= threshold && Math.abs(upY - downY) <= threshold;
}
@@ -0,0 +1,53 @@
import { describe, it, expect } from "vitest";
import { describeChatError } from "./error-message";
// Identity translator: assert on the raw English key so the tests do not depend
// on the i18n catalog.
const t = (key: string) => key;
describe("describeChatError", () => {
it('surfaces a provider "402: ..." stream error verbatim', () => {
expect(describeChatError("402: Insufficient credits", t)).toBe(
"402: Insufficient credits",
);
});
it('does NOT misclassify a body that merely contains "403" (no "statusCode":403)', () => {
// A provider message mentioning the number 403 must be surfaced verbatim,
// never folded into the "AI chat is disabled" gating message.
const msg = "429: rate limited after 403 attempts";
expect(describeChatError(msg, t)).toBe(msg);
});
it('maps a {"statusCode":403} body to the disabled message', () => {
const body = '{"statusCode":403,"message":"Forbidden"}';
expect(describeChatError(body, t)).toBe(
"AI chat is disabled for this workspace.",
);
});
it('maps a {"statusCode":503} body to the not-configured message', () => {
const body = '{"statusCode":503,"message":"Service Unavailable"}';
expect(describeChatError(body, t)).toBe(
"The AI provider is not configured. Ask an administrator to set it up.",
);
});
it('falls back to the generic message for "An error occurred."', () => {
expect(describeChatError("An error occurred.", t)).toBe(
"The AI agent could not respond. Please try again.",
);
});
it('falls back to the generic message for "Internal server error"', () => {
expect(describeChatError("Internal server error", t)).toBe(
"The AI agent could not respond. Please try again.",
);
});
it("falls back to the generic message for empty input", () => {
expect(describeChatError("", t)).toBe(
"The AI agent could not respond. Please try again.",
);
});
});
@@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
/**
* Tests for the internal-link neutralization used by the anonymous public
* share. Now that the share renders the assistant's MARKDOWN (not plain text),
* internal app links (e.g. `[page](/p/{uuid})`) would otherwise become clickable
* `<a href="/p/...">`, leaking internal UUIDs/structure and linking to auth-gated
* routes. With the flag ON those links are made inert (href removed) while the
* visible text and the rest of the markdown formatting are preserved; genuinely
* EXTERNAL http(s) links (a DIFFERENT host than the app's own origin) are kept
* with a safe rel/target, while absolute links back to our OWN origin are
* neutralized too. With the flag OFF (internal default) links keep their href so
* the authenticated chat is unchanged.
*/
/** Parse the rendered HTML and return the first <a> element (or null). */
function firstAnchor(html: string): HTMLAnchorElement | null {
const doc = new DOMParser().parseFromString(html, "text/html");
return doc.querySelector("a");
}
describe("renderChatMarkdown — internal link neutralization", () => {
it("makes an internal link inert when the flag is ON (no href, text kept)", () => {
const html = renderChatMarkdown("[x](/p/abc)", {
neutralizeInternalLinks: true,
});
const a = firstAnchor(html);
expect(a).not.toBeNull();
expect(a!.hasAttribute("href")).toBe(false);
expect(a!.hasAttribute("target")).toBe(false);
// Visible link text is preserved.
expect(a!.textContent).toBe("x");
});
it("neutralizes bare-fragment links when the flag is ON", () => {
const html = renderChatMarkdown("[here](#section)", {
neutralizeInternalLinks: true,
});
const a = firstAnchor(html);
expect(a).not.toBeNull();
expect(a!.hasAttribute("href")).toBe(false);
});
it("keeps an external http(s) link with a safe rel/target when the flag is ON", () => {
const html = renderChatMarkdown("[y](https://example.com/x)", {
neutralizeInternalLinks: true,
});
const a = firstAnchor(html);
expect(a).not.toBeNull();
expect(a!.getAttribute("href")).toBe("https://example.com/x");
expect(a!.getAttribute("rel")).toBe("noopener noreferrer nofollow");
expect(a!.getAttribute("target")).toBe("_blank");
});
it("neutralizes an absolute link to our OWN origin when the flag is ON", () => {
// An LLM can emit an absolute URL back at our own host (e.g.
// `http://self/p/{uuid}`); it is internal and must be made inert just like a
// relative `/p/...` link, not kept clickable as if it were external.
const ownOrigin = `${window.location.origin}/p/abc`;
const html = renderChatMarkdown(`[x](${ownOrigin})`, {
neutralizeInternalLinks: true,
});
const a = firstAnchor(html);
expect(a).not.toBeNull();
expect(a!.hasAttribute("href")).toBe(false);
expect(a!.hasAttribute("target")).toBe(false);
expect(a!.textContent).toBe("x");
});
it("neutralizes dangerous/unsafe schemes when the flag is ON", () => {
// javascript:, data:, and protocol-relative `//...` must never stay
// clickable on the anonymous share — they are not genuinely external
// http(s) links to a different host, so the href is dropped (or sanitized
// away entirely by DOMPurify).
for (const markdown of [
"[a](javascript:alert(1))",
"[b](data:text/html,<script>alert(1)</script>)",
"[c](//evil.com/x)",
]) {
const html = renderChatMarkdown(markdown, {
neutralizeInternalLinks: true,
});
const a = firstAnchor(html);
// Either the anchor was stripped of its href, or DOMPurify removed the
// unsafe href outright; in both cases nothing dangerous remains.
if (a !== null) {
expect(a.hasAttribute("href")).toBe(false);
expect(a.hasAttribute("target")).toBe(false);
}
}
});
it("keeps internal links clickable when the flag is OFF (internal default)", () => {
const html = renderChatMarkdown("[x](/p/abc)");
const a = firstAnchor(html);
expect(a).not.toBeNull();
expect(a!.getAttribute("href")).toBe("/p/abc");
});
it("keeps an absolute own-origin link clickable when the flag is OFF (internal default)", () => {
const ownOrigin = `${window.location.origin}/p/abc`;
const html = renderChatMarkdown(`[x](${ownOrigin})`);
const a = firstAnchor(html);
expect(a).not.toBeNull();
expect(a!.getAttribute("href")).toBe(ownOrigin);
});
it("does not leave a global DOMPurify hook that affects a later internal render", () => {
// A neutralizing render first, then an internal render: the internal link
// must survive (the hook is removed after the share render).
renderChatMarkdown("[x](/p/abc)", { neutralizeInternalLinks: true });
const html = renderChatMarkdown("[x](/p/abc)");
const a = firstAnchor(html);
expect(a!.getAttribute("href")).toBe("/p/abc");
});
});
@@ -1,6 +1,66 @@
import { markdownToHtml } from "@docmost/editor-ext";
import DOMPurify from "dompurify";
export interface RenderChatMarkdownOptions {
/**
* Neutralize INTERNAL links so they render as inert text (no `href`/`target`).
* Used by the anonymous public share: the assistant's answer can contain
* relative app links (e.g. `[page](/p/{uuid})`, `[settings](/settings/members)`)
* that would otherwise become clickable `<a href="/p/...">`, leaking internal
* UUIDs/structure and pointing at auth-gated routes. An anonymous reader can
* still follow genuinely EXTERNAL `http(s)` links (a DIFFERENT host than the
* app's own origin), so those are kept (with a safe `rel`/`target`); absolute
* links back to our OWN origin (e.g. `https://self/p/{uuid}`) are internal and
* neutralized too. Defaults to false — the internal chat keeps internal links
* clickable for authenticated users.
*/
neutralizeInternalLinks?: boolean;
}
/**
* Whether `href` points at an EXTERNAL absolute URL we are happy for an
* anonymous reader to follow. A link qualifies only if it is absolute
* `http(s)://` AND its host differs from the app's own origin
* (`window.location.host`): absolute links back to our OWN host (e.g.
* `https://self/p/{uuid}`) are internal and must be neutralized, exactly like
* relative `/p/...` links. Everything else (relative `/...`, bare fragments
* `#...`, protocol-relative `//...`, other schemes, or anything that does not
* parse) is treated as internal/unsafe and neutralized — fail closed.
*/
function isExternalHttpUrl(href: string): boolean {
const value = href.trim();
if (!/^https?:\/\//i.test(value)) return false;
try {
// External only if it points at a DIFFERENT host than the app's own origin.
// Absolute links back to our own host (e.g. https://self/p/{uuid}) are
// internal and must be neutralized, same as relative `/p/...` links.
return new URL(value).host !== window.location.host;
} catch {
return false; // unparseable -> treat as internal/unsafe, neutralize
}
}
/**
* DOMPurify `afterSanitizeAttributes` hook that neutralizes internal links.
* Hooks are GLOBAL on the DOMPurify instance, so this is only ever registered
* for the duration of a single sanitize call (added then removed in
* `renderChatMarkdown`) — it must never leak into the internal chat's renders.
*/
function neutralizeInternalLinksHook(node: Element): void {
if (node.nodeName !== "A") return;
const href = node.getAttribute("href");
if (href !== null && isExternalHttpUrl(href)) {
// Genuinely external link: keep it, but force a safe rel/target.
node.setAttribute("rel", "noopener noreferrer nofollow");
node.setAttribute("target", "_blank");
return;
}
// Internal/relative/fragment link (or no href): make it inert text. Drop the
// href and any target so it is no longer clickable; the visible text stays.
node.removeAttribute("href");
node.removeAttribute("target");
}
/**
* Render AI markdown to sanitized HTML for read-only display. We reuse the
* app's `markdownToHtml` (the same `marked` pipeline used for paste/import) so
@@ -12,9 +72,31 @@ import DOMPurify from "dompurify";
* synchronously, but we guard the Promise case by returning a safe empty string
* for that branch (the caller renders the raw text fallback instead).
*/
export function renderChatMarkdown(markdown: string): string {
export function renderChatMarkdown(
markdown: string,
options: RenderChatMarkdownOptions = {},
): string {
if (!markdown) return "";
const html = markdownToHtml(markdown);
if (typeof html !== "string") return "";
return DOMPurify.sanitize(html);
if (!options.neutralizeInternalLinks) {
// Internal chat: unchanged behavior, no hook registered.
return DOMPurify.sanitize(html);
}
// Public share: register the neutralization hook only for THIS sanitize call,
// then remove it immediately so it can never affect the internal chat (hooks
// are global on the shared DOMPurify instance).
DOMPurify.addHook("afterSanitizeAttributes", neutralizeInternalLinksHook);
try {
return DOMPurify.sanitize(html);
} finally {
// Remove by reference (not a bare pop) so we only ever remove OUR hook,
// robust to any other afterSanitizeAttributes hook registered in future.
DOMPurify.removeHook(
"afterSanitizeAttributes",
neutralizeInternalLinksHook,
);
}
}
@@ -0,0 +1,100 @@
import { describe, it, expect } from "vitest";
import {
toolCitations,
toolRunState,
type ToolUiPart,
} from "./tool-parts";
describe("toolCitations", () => {
it("emits one citation per searchPages item with a /p/{id} href", () => {
const part: ToolUiPart = {
type: "tool-searchPages",
state: "output-available",
output: [
{ id: "p1", title: "First" },
{ id: "p2", title: "Second" },
],
};
expect(toolCitations(part)).toEqual([
{ pageId: "p1", title: "First", href: "/p/p1" },
{ pageId: "p2", title: "Second", href: "/p/p2" },
]);
});
it("drops searchPages items missing an id", () => {
const part: ToolUiPart = {
type: "tool-searchPages",
state: "output-available",
output: [{ title: "No id here" }, { id: "p2", title: "Kept" }],
};
expect(toolCitations(part)).toEqual([
{ pageId: "p2", title: "Kept", href: "/p/p2" },
]);
});
it("falls back to input.pageId / input.title for a page-op with only pageId", () => {
// The mutating tools echo `pageId` (no `id`); title is taken from the input.
const part: ToolUiPart = {
type: "tool-updatePageContent",
state: "output-available",
input: { pageId: "host-1", title: "From input" },
output: { pageId: "host-1" },
};
expect(toolCitations(part)).toEqual([
{ pageId: "host-1", title: "From input", href: "/p/host-1" },
]);
});
it("prefers output.id over input.pageId when both exist", () => {
const part: ToolUiPart = {
type: "tool-getPage",
state: "output-available",
input: { pageId: "input-id", title: "Input title" },
output: { id: "output-id", title: "Output title" },
};
expect(toolCitations(part)).toEqual([
{ pageId: "output-id", title: "Output title", href: "/p/output-id" },
]);
});
it("returns [] when the state is not output-available", () => {
const part: ToolUiPart = {
type: "tool-getPage",
state: "input-available",
output: { id: "p1", title: "Pending" },
};
expect(toolCitations(part)).toEqual([]);
});
it("returns [] for a page-op output with no resolvable id", () => {
const part: ToolUiPart = {
type: "tool-getPage",
state: "output-available",
input: {},
output: { title: "Only a title" },
};
expect(toolCitations(part)).toEqual([]);
});
});
describe("toolRunState", () => {
it('maps "output-error" to error', () => {
expect(toolRunState("output-error")).toBe("error");
});
it('maps "output-denied" to error', () => {
expect(toolRunState("output-denied")).toBe("error");
});
it('maps "output-available" to done', () => {
expect(toolRunState("output-available")).toBe("done");
});
it('maps "input-available" to running', () => {
expect(toolRunState("input-available")).toBe("running");
});
it("maps undefined to running", () => {
expect(toolRunState(undefined)).toBe("running");
});
});
@@ -0,0 +1,48 @@
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { useTranslation } from "react-i18next";
import { getFootnoteNumber } from "@docmost/editor-ext";
import classes from "./footnote.module.css";
/**
* NodeView for a single footnote definition: a decorative number marker, the
* editable content (NodeViewContent), and a "↩" back-link to its reference.
* The number is derived from the document (not stored).
*/
export default function FootnoteDefinitionView(props: NodeViewProps) {
const { node, editor } = props;
const { t } = useTranslation();
const id = node.attrs.id as string;
// Read the cached number from the numbering plugin (computed once per doc
// change) rather than recomputing the whole map on every render.
const number = getFootnoteNumber(editor.state, id) ?? "?";
const handleBack = (e: React.MouseEvent) => {
e.preventDefault();
editor.commands.scrollToReference(id);
};
return (
<NodeViewWrapper
data-footnote-def=""
data-id={id}
className={classes.definition}
style={{ ["--footnote-number" as any]: `"${number}"` }}
>
<span className={classes.definitionMarker} contentEditable={false}>
{number}.
</span>
<NodeViewContent className={classes.definitionContent} />
<span
className={classes.backLink}
contentEditable={false}
onClick={handleBack}
role="button"
aria-label={t("Back to reference")}
title={t("Back to reference")}
>
</span>
</NodeViewWrapper>
);
}
@@ -0,0 +1,146 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import {
autoUpdate,
computePosition,
flip,
offset,
shift,
} from "@floating-ui/dom";
import {
FOOTNOTE_DEFINITION_NAME,
getFootnoteNumber,
} from "@docmost/editor-ext";
import { ActionIcon } from "@mantine/core";
import { IconArrowDown } from "@tabler/icons-react";
import classes from "./footnote.module.css";
/**
* Read the plain text of the footnote definition with `id` directly from the
* editor state. No sub-editor: the popover is read-only.
*/
function getDefinitionText(editor: NodeViewProps["editor"], id: string): string {
let text = "";
editor.state.doc.descendants((node) => {
if (
node.type.name === FOOTNOTE_DEFINITION_NAME &&
node.attrs.id === id
) {
text = node.textContent;
return false;
}
return undefined;
});
return text;
}
export default function FootnoteReferenceView(props: NodeViewProps) {
const { node, editor, selected } = props;
const { t } = useTranslation();
const id = node.attrs.id as string;
const anchorRef = useRef<HTMLElement | null>(null);
const popoverRef = useRef<HTMLDivElement | null>(null);
const [open, setOpen] = useState(false);
// Number is derived (not stored). Read it from the numbering plugin's cached
// map (computed once per doc change) instead of walking the whole document on
// every render — recomputing per NodeView per render was O(n^2) per keystroke.
const number = getFootnoteNumber(editor.state, id) ?? "?";
const defText = open ? getDefinitionText(editor, id) : "";
const position = useCallback(() => {
const anchor = anchorRef.current;
const popup = popoverRef.current;
if (!anchor || !popup) return;
computePosition(anchor, popup, {
placement: "top",
middleware: [offset(6), flip(), shift({ padding: 8 })],
}).then(({ x, y }) => {
popup.style.left = `${x}px`;
popup.style.top = `${y}px`;
});
}, []);
useEffect(() => {
if (!open) return;
const anchor = anchorRef.current;
const popup = popoverRef.current;
if (!anchor || !popup) return;
const cleanup = autoUpdate(anchor, popup, position);
const onPointerDown = (e: PointerEvent) => {
if (
popup.contains(e.target as Node) ||
anchor.contains(e.target as Node)
) {
return;
}
setOpen(false);
};
document.addEventListener("pointerdown", onPointerDown, true);
return () => {
cleanup();
document.removeEventListener("pointerdown", onPointerDown, true);
};
}, [open, position]);
const handleGoTo = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setOpen(false);
editor.commands.scrollToFootnote(id);
};
return (
<NodeViewWrapper as="span" style={{ display: "inline" }}>
<sup
ref={(el) => (anchorRef.current = el)}
data-footnote-ref=""
data-id={id}
className={`${classes.reference} ${selected ? classes.selected : ""}`}
onMouseEnter={() => setOpen(true)}
onClick={(e) => {
e.preventDefault();
setOpen((v) => !v);
}}
// The decoration sets --footnote-number; provide a fallback inline.
style={{ ["--footnote-number" as any]: `"${number}"` }}
aria-label={t("Footnote {{number}}", { number })}
role="button"
/>
{open &&
createPortal(
<div
ref={popoverRef}
className={classes.popover}
role="tooltip"
onMouseLeave={() => setOpen(false)}
>
<div className={classes.popoverHeader}>
<span className={classes.popoverNumber}>
{t("Footnote {{number}}", { number })}
</span>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
onClick={handleGoTo}
aria-label={t("Go to footnote")}
>
<IconArrowDown size={16} />
</ActionIcon>
</div>
<div className={classes.popoverBody}>
{defText || t("Empty footnote")}
</div>
</div>,
document.body,
)}
</NodeViewWrapper>
);
}
@@ -0,0 +1,111 @@
/* Superscript reference marker. The visible number comes from the numbering
plugin decoration which sets the --footnote-number CSS variable. */
.reference {
cursor: pointer;
color: var(--mantine-color-blue-6);
font-weight: 500;
vertical-align: super;
font-size: 0.75em;
line-height: 0;
user-select: none;
white-space: nowrap;
}
.reference::after {
content: var(--footnote-number, "");
}
.reference:hover {
text-decoration: underline;
}
.reference.selected {
background-color: var(--mantine-color-blue-1);
border-radius: 2px;
}
/* Read-only popover shown on hover/click of a reference. */
.popover {
position: absolute;
z-index: 1000;
max-width: 360px;
padding: var(--mantine-spacing-sm);
background: var(--mantine-color-body);
color: var(--mantine-color-default-color);
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
box-shadow: var(--mantine-shadow-md);
font-size: var(--mantine-font-size-sm);
line-height: 1.4;
}
.popoverHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--mantine-spacing-xs);
margin-bottom: 4px;
}
.popoverNumber {
font-weight: 600;
color: var(--mantine-color-dimmed);
}
.popoverBody {
white-space: pre-wrap;
word-break: break-word;
}
/* Bottom footnotes container. */
.list {
margin-top: var(--mantine-spacing-lg);
padding-top: var(--mantine-spacing-md);
border-top: 1px solid var(--mantine-color-default-border);
}
.listHeading {
font-weight: 600;
font-size: var(--mantine-font-size-sm);
color: var(--mantine-color-dimmed);
margin-bottom: var(--mantine-spacing-xs);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.definition {
display: flex;
align-items: flex-start;
/* Tight number→text spacing (~one space) so it reads like "1. text"
instead of leaving a wide gap after the period. */
gap: 0.4em;
padding: 2px 0;
}
.definitionMarker {
flex: 0 0 auto;
min-width: 1.5em;
/* Right-align within the narrow column so the period sits next to the text
and multi-digit numbers (10, 11, …) stay aligned on their right edge. */
text-align: right;
font-variant-numeric: tabular-nums;
color: var(--mantine-color-dimmed);
user-select: none;
}
.definitionContent {
flex: 1 1 auto;
min-width: 0;
}
.backLink {
flex: 0 0 auto;
cursor: pointer;
color: var(--mantine-color-blue-6);
user-select: none;
font-size: 0.9em;
}
.backLink:hover {
text-decoration: underline;
}
@@ -0,0 +1,20 @@
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { useTranslation } from "react-i18next";
import classes from "./footnote.module.css";
/**
* NodeView for the bottom footnotes container. Renders a visual separator and a
* localized heading, then the editable list of definitions via NodeViewContent.
*/
export default function FootnotesListView(_props: NodeViewProps) {
const { t } = useTranslation();
return (
<NodeViewWrapper>
<div className={classes.list} contentEditable={false}>
<div className={classes.listHeading}>{t("Footnotes")}</div>
</div>
<NodeViewContent />
</NodeViewWrapper>
);
}
@@ -15,39 +15,11 @@ import { useAtomValue } from "jotai";
import useUserRole from "@/hooks/use-user-role.tsx";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import classes from "./html-embed-view.module.css";
/**
* Inject raw HTML (including <script> tags) into `container`, executing any
* scripts.
*
* Setting `innerHTML` does NOT run inline or external <script> tags the browser
* parses that way: the HTML spec marks scripts inserted via innerHTML as
* "already started" so they never execute. To get the tracker/analytics
* use-case working we walk the freshly-parsed scripts and replace each with a
* brand-new <script> element copying its attributes and inline code. A
* programmatically created+inserted <script> DOES execute, so this restores
* normal script behaviour in the wiki origin (Variant C).
*/
function renderRawHtml(container: HTMLElement, source: string) {
// Clear any previous render (re-render on source change).
container.innerHTML = "";
if (!source) return;
container.innerHTML = source;
const scripts = Array.from(container.querySelectorAll("script"));
for (const oldScript of scripts) {
const newScript = document.createElement("script");
// Copy every attribute (src, type, async, defer, data-*, etc.).
for (const attr of Array.from(oldScript.attributes)) {
newScript.setAttribute(attr.name, attr.value);
}
// Copy inline code.
newScript.text = oldScript.textContent ?? "";
// Replacing the node in place triggers execution.
oldScript.parentNode?.replaceChild(newScript, oldScript);
}
}
import {
canEdit as computeCanEdit,
renderRawHtml,
shouldExecute as computeShouldExecute,
} from "./render-raw-html.ts";
export default function HtmlEmbedView(props: NodeViewProps) {
const { t } = useTranslation();
@@ -70,7 +42,10 @@ export default function HtmlEmbedView(props: NodeViewProps) {
// here — we execute exactly the `source` the server chose to serve.
// - EDITABLE editor (admin authoring): keep gating on the per-workspace
// toggle so an admin sees the inert placeholder when the feature is OFF.
const shouldExecute = !editor.isEditable || htmlEmbedEnabled;
const shouldExecute = computeShouldExecute(
editor.isEditable,
htmlEmbedEnabled,
);
const contentRef = useRef<HTMLDivElement | null>(null);
const [modalOpen, setModalOpen] = useState(false);
@@ -104,7 +79,7 @@ export default function HtmlEmbedView(props: NodeViewProps) {
// The edit affordance is only meaningful in edit mode, is restricted to admins
// (the server strips the node for non-admins anyway), and is offered only when
// the workspace feature toggle is ON.
const canEdit = editor.isEditable && isAdmin && htmlEmbedEnabled;
const canEdit = computeCanEdit(editor.isEditable, isAdmin, htmlEmbedEnabled);
return (
<NodeViewWrapper
@@ -0,0 +1,112 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { JSDOM } from "jsdom";
import { renderRawHtml, shouldExecute, canEdit } from "./render-raw-html";
// jsdom does NOT execute <script> nodes unless its instance was created with
// `runScripts: "dangerously"`. The whole point of renderRawHtml is to make
// re-created scripts run, so the execution tests drive a dedicated script-
// running JSDOM and pass it a container from THAT document (renderRawHtml uses
// `container.ownerDocument`, so it creates the fresh scripts in the running
// instance). The default vitest jsdom (no runScripts) is used for the
// structural and policy assertions.
describe("renderRawHtml (script execution against a runScripts jsdom)", () => {
let dom: JSDOM;
let container: HTMLElement;
beforeEach(() => {
dom = new JSDOM("<!doctype html><html><body></body></html>", {
runScripts: "dangerously",
});
container = dom.window.document.createElement("div");
dom.window.document.body.appendChild(container);
});
afterEach(() => {
dom.window.close();
});
it("re-creates and executes an inline <script> (observable side effect)", () => {
renderRawHtml(
container,
"<div>hello</div><script>window.__htmlEmbedFlag = true;</script>",
);
// The re-created inline script ran inside the jsdom window.
expect((dom.window as unknown as Record<string, unknown>).__htmlEmbedFlag).toBe(
true,
);
// The non-script markup is preserved.
expect(container.querySelector("div")?.textContent).toBe("hello");
});
it("copies src/async/defer onto a re-created external <script src>", () => {
renderRawHtml(
container,
'<script src="https://example.com/t.js" async defer></script>',
);
const script = container.querySelector("script");
expect(script).not.toBeNull();
expect(script?.getAttribute("src")).toBe("https://example.com/t.js");
expect(script?.hasAttribute("async")).toBe(true);
expect(script?.hasAttribute("defer")).toBe(true);
});
it("clears the container when the source is empty", () => {
container.innerHTML = "<p>stale</p>";
renderRawHtml(container, "");
expect(container.innerHTML).toBe("");
});
it("clears prior content first on a re-render with new source", () => {
const win = dom.window as unknown as Record<string, unknown>;
renderRawHtml(
container,
"<span id='first'>one</span><script>window.__htmlEmbedCount = 1;</script>",
);
expect(win.__htmlEmbedCount).toBe(1);
expect(container.querySelector("#first")).not.toBeNull();
renderRawHtml(
container,
"<span id='second'>two</span><script>window.__htmlEmbedCount = 2;</script>",
);
// Prior content is gone; only the new render remains.
expect(container.querySelector("#first")).toBeNull();
expect(container.querySelector("#second")).not.toBeNull();
expect(win.__htmlEmbedCount).toBe(2);
});
});
describe("shouldExecute (execution policy)", () => {
it("read-only executes regardless of the workspace toggle", () => {
// isEditable=false → the server already gated the content.
expect(shouldExecute(false, false)).toBe(true);
expect(shouldExecute(false, true)).toBe(true);
});
it("editable + toggle OFF does NOT execute", () => {
expect(shouldExecute(true, false)).toBe(false);
});
it("editable + toggle ON executes", () => {
expect(shouldExecute(true, true)).toBe(true);
});
});
describe("canEdit (edit policy)", () => {
it("a member (non-admin) can never edit", () => {
expect(canEdit(true, false, true)).toBe(false);
expect(canEdit(false, false, true)).toBe(false);
});
it("an admin with the toggle OFF cannot edit", () => {
expect(canEdit(true, true, false)).toBe(false);
});
it("an admin with the toggle ON in editable mode can edit", () => {
expect(canEdit(true, true, true)).toBe(true);
});
it("an admin in read-only mode cannot edit (no edit affordance)", () => {
expect(canEdit(false, true, true)).toBe(false);
});
});
@@ -0,0 +1,73 @@
/**
* Pure DOM helpers for the HTML embed node view. Kept out of the React
* component so the script re-creation/execution mechanism and the execution/
* edit policy can be unit-tested against a bare jsdom container with no
* Tiptap/Mantine providers.
*/
/**
* Inject raw HTML (including <script> tags) into `container`, executing any
* scripts.
*
* Setting `innerHTML` does NOT run inline or external <script> tags the browser
* parses that way: the HTML spec marks scripts inserted via innerHTML as
* "already started" so they never execute. To get the tracker/analytics
* use-case working we walk the freshly-parsed scripts and replace each with a
* brand-new <script> element copying its attributes and inline code. A
* programmatically created+inserted <script> DOES execute, so this restores
* normal script behaviour in the wiki origin (Variant C).
*/
export function renderRawHtml(container: HTMLElement, source: string): void {
// Clear any previous render (re-render on source change).
container.innerHTML = "";
if (!source) return;
container.innerHTML = source;
// Use the container's own document so the helper works against any document
// (the live page or a standalone jsdom instance in tests), not just the
// ambient global `document`.
const doc = container.ownerDocument;
const scripts = Array.from(container.querySelectorAll("script"));
for (const oldScript of scripts) {
const newScript = doc.createElement("script");
// Copy every attribute (src, type, async, defer, data-*, etc.).
for (const attr of Array.from(oldScript.attributes)) {
newScript.setAttribute(attr.name, attr.value);
}
// Copy inline code.
newScript.text = oldScript.textContent ?? "";
// Replacing the node in place triggers execution.
oldScript.parentNode?.replaceChild(newScript, oldScript);
}
}
/**
* Execution policy split by editor mode:
* - READ-ONLY / public-share view: the SERVER already decided whether to
* include the embed (it strips htmlEmbed from shared content when the
* workspace toggle is OFF). An anonymous viewer has no workspace and thus
* reads `featureEnabled` as false, so we must NOT gate execution on it here
* — we execute exactly the `source` the server chose to serve.
* - EDITABLE editor (admin authoring): keep gating on the per-workspace toggle
* so an admin sees the inert placeholder when the feature is OFF.
*/
export function shouldExecute(
isEditable: boolean,
featureEnabled: boolean,
): boolean {
return !isEditable || featureEnabled;
}
/**
* The edit affordance is only meaningful in edit mode, is restricted to admins
* (the server strips the node for non-admins anyway), and is offered only when
* the workspace feature toggle is ON.
*/
export function canEdit(
isEditable: boolean,
isAdmin: boolean,
featureEnabled: boolean,
): boolean {
return isEditable && isAdmin && featureEnabled;
}
@@ -0,0 +1,141 @@
import { describe, it, expect } from "vitest";
import { decideEmbedState } from "./decide-embed-state";
import { PAGE_EMBED_MAX_DEPTH } from "./page-embed-ancestry-context";
import type { PageTemplateLookup } from "@/features/page-embed/types/page-embed.types";
const okResult: PageTemplateLookup = {
sourcePageId: "p1",
slugId: "slug-p1",
title: "Template",
icon: null,
content: { type: "doc" },
sourceUpdatedAt: "2026-01-01T00:00:00.000Z",
};
describe("decideEmbedState", () => {
it("returns no_source when sourcePageId is null", () => {
expect(
decideEmbedState({
sourcePageId: null,
chain: [],
hostPageId: null,
available: true,
result: null,
}),
).toBe("no_source");
});
it("returns cycle when sourcePageId is already in the ancestor chain", () => {
expect(
decideEmbedState({
sourcePageId: "p1",
chain: ["root", "p1"],
hostPageId: "host",
available: true,
result: okResult,
}),
).toBe("cycle");
});
it("returns cycle when sourcePageId equals the host page id (top-level self-embed)", () => {
expect(
decideEmbedState({
sourcePageId: "host",
chain: [],
hostPageId: "host",
available: true,
result: okResult,
}),
).toBe("cycle");
});
it("returns too_deep when chain length reaches PAGE_EMBED_MAX_DEPTH", () => {
const chain = Array.from({ length: PAGE_EMBED_MAX_DEPTH }, (_, i) => `a${i}`);
expect(
decideEmbedState({
sourcePageId: "p1",
chain,
hostPageId: "host",
available: true,
result: okResult,
}),
).toBe("too_deep");
});
it("cycle wins over too_deep when both apply (cycle checked first)", () => {
const chain = Array.from(
{ length: PAGE_EMBED_MAX_DEPTH },
(_, i) => `a${i}`,
);
chain[0] = "p1"; // also a cycle
expect(
decideEmbedState({
sourcePageId: "p1",
chain,
hostPageId: "host",
available: true,
result: okResult,
}),
).toBe("cycle");
});
it("returns unavailable when no lookup context is mounted", () => {
expect(
decideEmbedState({
sourcePageId: "p1",
chain: [],
hostPageId: "host",
available: false,
result: null,
}),
).toBe("unavailable");
});
it("returns loading when available but the result is not back yet", () => {
expect(
decideEmbedState({
sourcePageId: "p1",
chain: [],
hostPageId: "host",
available: true,
result: null,
}),
).toBe("loading");
});
it("returns no_access when the result status is no_access", () => {
expect(
decideEmbedState({
sourcePageId: "p1",
chain: [],
hostPageId: "host",
available: true,
result: { sourcePageId: "p1", status: "no_access" },
}),
).toBe("no_access");
});
it("returns not_found when the result status is not_found", () => {
expect(
decideEmbedState({
sourcePageId: "p1",
chain: [],
hostPageId: "host",
available: true,
result: { sourcePageId: "p1", status: "not_found" },
}),
).toBe("not_found");
});
it("returns ok for a resolved template (happy path)", () => {
expect(
decideEmbedState({
sourcePageId: "p1",
chain: [],
hostPageId: "host",
available: true,
result: okResult,
}),
).toBe("ok");
});
});
@@ -0,0 +1,58 @@
import { PAGE_EMBED_MAX_DEPTH } from "./page-embed-ancestry-context";
import type { PageTemplateLookup } from "@/features/page-embed/types/page-embed.types";
/**
* The render outcome of a single pageEmbed node, decided BEFORE rendering a
* nested editor. Kept pure (no React) so the cycle / depth / access / not-found
* branch logic is unit-testable in isolation; the node view maps each outcome
* to a placeholder or the embedded content.
*/
export type EmbedState =
| "no_source" // no sourcePageId picked yet
| "cycle" // self-embed or an ancestor already shows this page
| "too_deep" // nesting depth limit reached
| "unavailable" // no lookup context (e.g. public share)
| "loading" // context present, result not back yet
| "ok" // resolved template content to render
| "no_access" // server says the viewer can't see the page
| "not_found"; // server says the page no longer exists
export interface DecideEmbedStateInput {
sourcePageId: string | null;
/** sourcePageIds of every ancestor pageEmbed up the render tree. */
chain: string[];
/** Host page id; a top-level self-embed must be caught against it. */
hostPageId: string | null;
/** Whether a lookup context is mounted (false on public shares in MVP). */
available: boolean;
/** The lookup result, or null while still loading. */
result: PageTemplateLookup | null;
}
/**
* Decide what a pageEmbed should render. The order matters: cycle and depth
* guards run first (before any lookup is even consulted), then availability,
* then the resolved result. Mirrors the branch ladder in PageEmbedBody.
*/
export function decideEmbedState({
sourcePageId,
chain,
hostPageId,
available,
result,
}: DecideEmbedStateInput): EmbedState {
if (!sourcePageId) return "no_source";
// Self-embed or a source already present in the ancestor chain → cycle.
const isCycle = chain.includes(sourcePageId) || hostPageId === sourcePageId;
if (isCycle) return "cycle";
if (chain.length >= PAGE_EMBED_MAX_DEPTH) return "too_deep";
if (!available) return "unavailable";
if (!result) return "loading";
if (!("status" in result)) return "ok";
if (result.status === "no_access") return "no_access";
return "not_found";
}
@@ -0,0 +1,149 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import {
PageEmbedAncestryProvider,
usePageEmbedAncestry,
isPageEmbedCycle,
isPageEmbedTooDeep,
PAGE_EMBED_MAX_DEPTH,
} from "./page-embed-ancestry-context";
/**
* Tiny probe that renders the current ancestry context as serialized data
* attributes so tests can assert the accumulated chain / threaded hostPageId
* without mounting the heavy Tiptap node view.
*/
function AncestryProbe({ testId = "probe" }: { testId?: string }) {
const { chain, hostPageId } = usePageEmbedAncestry();
return (
<span
data-testid={testId}
data-chain={chain.join(",")}
data-chain-length={String(chain.length)}
data-host={hostPageId ?? ""}
/>
);
}
describe("PageEmbedAncestryProvider", () => {
it("defaults to an empty chain and null host with no provider", () => {
render(<AncestryProbe />);
const probe = screen.getByTestId("probe");
expect(probe.getAttribute("data-chain")).toBe("");
expect(probe.getAttribute("data-chain-length")).toBe("0");
expect(probe.getAttribute("data-host")).toBe("");
});
it("accumulates sourcePageId into the chain across nested providers", () => {
render(
<PageEmbedAncestryProvider sourcePageId="a" hostPageId="host">
<PageEmbedAncestryProvider sourcePageId="b">
<PageEmbedAncestryProvider sourcePageId="c">
<AncestryProbe />
</PageEmbedAncestryProvider>
</PageEmbedAncestryProvider>
</PageEmbedAncestryProvider>,
);
const probe = screen.getByTestId("probe");
// Chain is built outermost -> innermost.
expect(probe.getAttribute("data-chain")).toBe("a,b,c");
expect(probe.getAttribute("data-chain-length")).toBe("3");
});
it("threads the host page id from the outermost provider down the tree", () => {
render(
<PageEmbedAncestryProvider sourcePageId="a" hostPageId="host-page">
<PageEmbedAncestryProvider sourcePageId="b" hostPageId="ignored">
<AncestryProbe />
</PageEmbedAncestryProvider>
</PageEmbedAncestryProvider>,
);
const probe = screen.getByTestId("probe");
// The first host wins (parent.hostPageId ?? hostPageId); deeper hosts are
// ignored so the original host is preserved for self-embed detection.
expect(probe.getAttribute("data-host")).toBe("host-page");
});
it("does not add an entry to the chain when sourcePageId is missing", () => {
render(
<PageEmbedAncestryProvider sourcePageId="a" hostPageId="host">
<PageEmbedAncestryProvider sourcePageId={null}>
<PageEmbedAncestryProvider>
<AncestryProbe />
</PageEmbedAncestryProvider>
</PageEmbedAncestryProvider>
</PageEmbedAncestryProvider>,
);
const probe = screen.getByTestId("probe");
// null / undefined sources are pass-through: chain stays ["a"], host kept.
expect(probe.getAttribute("data-chain")).toBe("a");
expect(probe.getAttribute("data-host")).toBe("host");
});
it("adopts a host provided only at a deeper level when the root had none", () => {
render(
<PageEmbedAncestryProvider sourcePageId="a">
<PageEmbedAncestryProvider sourcePageId="b" hostPageId="late-host">
<AncestryProbe />
</PageEmbedAncestryProvider>
</PageEmbedAncestryProvider>,
);
const probe = screen.getByTestId("probe");
expect(probe.getAttribute("data-host")).toBe("late-host");
});
});
describe("isPageEmbedCycle", () => {
it("is false when the source is not in the chain and is not the host", () => {
expect(isPageEmbedCycle(["a", "b"], "host", "c")).toBe(false);
});
it("is true when the source is already present in the ancestor chain", () => {
expect(isPageEmbedCycle(["a", "b", "c"], "host", "b")).toBe(true);
});
it("is true for a top-level self-embed (host === source, empty chain)", () => {
expect(isPageEmbedCycle([], "self", "self")).toBe(true);
});
it("is true when the source equals the host even mid-chain", () => {
expect(isPageEmbedCycle(["x"], "self", "self")).toBe(true);
});
it("is false when there is no source id (nothing to embed yet)", () => {
expect(isPageEmbedCycle(["a"], "host", null)).toBe(false);
expect(isPageEmbedCycle([], "host", "")).toBe(false);
});
it("is false when host is null and source is not in the chain", () => {
expect(isPageEmbedCycle(["a", "b"], null, "c")).toBe(false);
});
});
describe("isPageEmbedTooDeep", () => {
it("is false below the max depth", () => {
expect(isPageEmbedTooDeep([])).toBe(false);
expect(
isPageEmbedTooDeep(new Array(PAGE_EMBED_MAX_DEPTH - 1).fill("x")),
).toBe(false);
});
it("is true once the chain length reaches the max depth", () => {
expect(
isPageEmbedTooDeep(new Array(PAGE_EMBED_MAX_DEPTH).fill("x")),
).toBe(true);
});
it("is true when the chain length exceeds the max depth", () => {
expect(
isPageEmbedTooDeep(new Array(PAGE_EMBED_MAX_DEPTH + 3).fill("x")),
).toBe(true);
});
it("guards at exactly PAGE_EMBED_MAX_DEPTH (=5)", () => {
// Pin the documented constant so an accidental change is caught.
expect(PAGE_EMBED_MAX_DEPTH).toBe(5);
expect(isPageEmbedTooDeep(["1", "2", "3", "4"])).toBe(false);
expect(isPageEmbedTooDeep(["1", "2", "3", "4", "5"])).toBe(true);
});
});
@@ -51,3 +51,26 @@ export function PageEmbedAncestryProvider({
export function usePageEmbedAncestry() {
return useContext(PageEmbedAncestryContext);
}
/**
* Pure cycle predicate used by the page-embed node view. Returns true when the
* source page would recurse into itself: either it is already present in the
* ancestor chain, or it is the host page (top-level self-embed). Extracted so
* the anti-DoS guard can be unit-tested without mounting the Tiptap NodeView.
*/
export function isPageEmbedCycle(
chain: string[],
hostPageId: string | null,
sourcePageId: string | null,
): boolean {
if (!sourcePageId) return false;
return chain.includes(sourcePageId) || hostPageId === sourcePageId;
}
/**
* Pure depth-limit predicate. Returns true once the ancestor chain has reached
* the hard cap, before a deeper nested editor is mounted.
*/
export function isPageEmbedTooDeep(chain: string[]): boolean {
return chain.length >= PAGE_EMBED_MAX_DEPTH;
}
@@ -0,0 +1,162 @@
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
} from "vitest";
import { act, render } from "@testing-library/react";
import type { PageTemplateLookup } from "@/features/page-embed/types/page-embed.types";
// Mock the API module the provider calls. Hoisted by vitest before the import.
const lookupTemplate = vi.fn();
vi.mock("@/features/page-embed/services/page-embed-api", () => ({
lookupTemplate: (...args: unknown[]) => lookupTemplate(...args),
}));
// Imported AFTER the mock is declared so the provider picks up the mock.
import {
PageEmbedLookupProvider,
usePageEmbedLookup,
} from "./page-embed-lookup-context";
function ok(id: string): PageTemplateLookup {
return {
sourcePageId: id,
slugId: `slug-${id}`,
title: `T-${id}`,
icon: null,
content: { type: "doc" },
sourceUpdatedAt: "2026-01-01T00:00:00.000Z",
};
}
// Probe that subscribes to a sourceId and exposes its latest result + refresh.
function Probe({
id,
sink,
}: {
id: string;
sink: (api: ReturnType<typeof usePageEmbedLookup>) => void;
}) {
const api = usePageEmbedLookup(id);
sink(api);
return <div>{api.result ? "loaded" : "pending"}</div>;
}
describe("PageEmbedLookupProvider (batching / dedup / refresh)", () => {
beforeEach(() => {
vi.useFakeTimers();
lookupTemplate.mockReset();
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
it("dedups two subscribers for the same id into a single lookup call; both get the result", async () => {
let a: ReturnType<typeof usePageEmbedLookup> | null = null;
let b: ReturnType<typeof usePageEmbedLookup> | null = null;
lookupTemplate.mockResolvedValue({ items: [ok("p1")] });
render(
<PageEmbedLookupProvider>
<Probe id="p1" sink={(x) => (a = x)} />
<Probe id="p1" sink={(x) => (b = x)} />
</PageEmbedLookupProvider>,
);
// Subscriptions run in effects + the 10ms debounce batches them together.
await act(async () => {
await vi.advanceTimersByTimeAsync(20);
});
expect(lookupTemplate).toHaveBeenCalledTimes(1);
expect(lookupTemplate).toHaveBeenCalledWith({ sourcePageIds: ["p1"] });
expect(a!.result).toEqual(ok("p1"));
expect(b!.result).toEqual(ok("p1"));
});
it("batches two distinct ids subscribed within the window into one call", async () => {
lookupTemplate.mockResolvedValue({ items: [ok("p1"), ok("p2")] });
render(
<PageEmbedLookupProvider>
<Probe id="p1" sink={() => {}} />
<Probe id="p2" sink={() => {}} />
</PageEmbedLookupProvider>,
);
await act(async () => {
await vi.advanceTimersByTimeAsync(20);
});
expect(lookupTemplate).toHaveBeenCalledTimes(1);
expect(lookupTemplate.mock.calls[0][0]).toEqual({
sourcePageIds: ["p1", "p2"],
});
});
it("refresh() clears the cache and re-fetches", async () => {
let a: ReturnType<typeof usePageEmbedLookup> | null = null;
lookupTemplate.mockResolvedValue({ items: [ok("p1")] });
render(
<PageEmbedLookupProvider>
<Probe id="p1" sink={(x) => (a = x)} />
</PageEmbedLookupProvider>,
);
await act(async () => {
await vi.advanceTimersByTimeAsync(20);
});
expect(lookupTemplate).toHaveBeenCalledTimes(1);
// refresh resolves once the next batch flush completes.
await act(async () => {
const p = a!.refresh();
await vi.advanceTimersByTimeAsync(20);
await p;
});
expect(lookupTemplate).toHaveBeenCalledTimes(2);
});
it("a rejected lookup resolves refresh() waiters, clears inFlight, and logs the error (not swallowed)", async () => {
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
let a: ReturnType<typeof usePageEmbedLookup> | null = null;
lookupTemplate.mockRejectedValueOnce(new Error("boom"));
render(
<PageEmbedLookupProvider>
<Probe id="p1" sink={(x) => (a = x)} />
</PageEmbedLookupProvider>,
);
// Initial subscription enqueues a lookup that rejects.
await act(async () => {
await vi.advanceTimersByTimeAsync(20);
});
expect(errSpy).toHaveBeenCalled();
// The error message is surfaced, not swallowed.
expect(errSpy.mock.calls[0][0]).toContain("[pageEmbed] template lookup failed");
// inFlight was cleared on failure, so a refresh re-enqueues and resolves.
lookupTemplate.mockResolvedValueOnce({ items: [ok("p1")] });
let resolved = false;
await act(async () => {
const p = a!.refresh().then(() => {
resolved = true;
});
await vi.advanceTimersByTimeAsync(20);
await p;
});
expect(resolved).toBe(true);
expect(a!.result).toEqual(ok("p1"));
errSpy.mockRestore();
});
});
@@ -55,7 +55,9 @@ export function PageEmbedLookupProvider({
try {
const { items } = await lookupTemplate({ sourcePageIds: ids });
const returned = new Set<string>();
for (const r of items) {
returned.add(r.sourcePageId);
resultCacheRef.current.set(r.sourcePageId, r);
inFlightRef.current.delete(r.sourcePageId);
const subs = subscribersRef.current.get(r.sourcePageId);
@@ -64,6 +66,17 @@ export function PageEmbedLookupProvider({
}
resolveWaiters(r.sourcePageId);
}
// Harden against a partial/short server response: any requested id not
// present in `items` would otherwise stay in `inFlightRef` forever
// (subscribe/refresh are guarded by `!inFlightRef.has(id)`) and its
// refresh() promise would never resolve. Clear + resolve those ids,
// mirroring the catch branch, so no id can be stranded in-flight.
for (const id of ids) {
if (!returned.has(id)) {
inFlightRef.current.delete(id);
resolveWaiters(id);
}
}
} catch (err) {
// Surface the failure: errors must never be swallowed silently.
console.error("[pageEmbed] template lookup failed", err);
@@ -6,6 +6,7 @@ import { IconFileText, IconSearch } from "@tabler/icons-react";
import type { Editor, Range } from "@tiptap/core";
import { searchSuggestions } from "@/features/search/services/search-service";
import type { IPage } from "@/features/page/types/page.types";
import { buildPickerQuery, excludeHost } from "./page-embed-picker.utils";
export const PAGE_EMBED_PICKER_EVENT = "open-page-embed-picker";
@@ -43,21 +44,13 @@ export default function PageEmbedPicker() {
const { data, isFetching } = useQuery({
queryKey: ["page-embed-template-picker", query],
queryFn: () =>
searchSuggestions({
query,
includePages: true,
onlyTemplates: true,
limit: 20,
}),
queryFn: () => searchSuggestions(buildPickerQuery(query)),
enabled: opened,
staleTime: 30 * 1000,
});
const hostPageId = detailRef.current?.hostPageId;
const pages = ((data?.pages ?? []) as IPage[]).filter(
(p) => p && p.id !== hostPageId,
);
const pages = excludeHost((data?.pages ?? []) as IPage[], hostPageId);
const handleSelect = (page: IPage) => {
const detail = detailRef.current;
@@ -0,0 +1,43 @@
import { describe, it, expect } from "vitest";
import { excludeHost, buildPickerQuery } from "./page-embed-picker.utils";
import type { IPage } from "@/features/page/types/page.types";
function page(id: string): IPage {
return { id, title: id, slugId: `slug-${id}` } as IPage;
}
describe("excludeHost", () => {
it("drops the host page from the results (self-embed guard)", () => {
const result = excludeHost([page("a"), page("host"), page("b")], "host");
expect(result.map((p) => p.id)).toEqual(["a", "b"]);
});
it("returns all pages when hostPageId is undefined", () => {
const result = excludeHost([page("a"), page("b")], undefined);
expect(result.map((p) => p.id)).toEqual(["a", "b"]);
});
it("drops null/blank entries", () => {
const result = excludeHost(
[page("a"), null as unknown as IPage, page("b")],
"host",
);
expect(result.map((p) => p.id)).toEqual(["a", "b"]);
});
});
describe("buildPickerQuery", () => {
it("passes onlyTemplates:true with the query and page inclusion", () => {
expect(buildPickerQuery("foo")).toEqual({
query: "foo",
includePages: true,
onlyTemplates: true,
limit: 20,
});
});
it("preserves an empty query", () => {
expect(buildPickerQuery("").query).toBe("");
expect(buildPickerQuery("").onlyTemplates).toBe(true);
});
});
@@ -0,0 +1,27 @@
import type { IPage } from "@/features/page/types/page.types";
import type { SearchSuggestionParams } from "@/features/search/types/search.types";
/**
* Self-embed guard at insertion time: drop the host page (and any null/blank
* entries) from the picker results so the current page can't embed itself.
*/
export function excludeHost(
pages: IPage[],
hostPageId: string | undefined,
): IPage[] {
return pages.filter((p) => p && p.id !== hostPageId);
}
/**
* Build the search-suggestions query for the template picker. Always restricts
* to template-flagged pages (`onlyTemplates`) and includes pages, mirroring the
* inline query args in PageEmbedPicker.
*/
export function buildPickerQuery(query: string): SearchSuggestionParams {
return {
query,
includePages: true,
onlyTemplates: true,
limit: 20,
};
}
@@ -2,10 +2,9 @@ import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { ActionIcon, Menu, Tooltip } from "@mantine/core";
import {
IconAlertTriangle,
IconArrowsMaximize,
IconDots,
IconExternalLink,
IconEyeOff,
IconFileText,
IconInfoCircle,
IconRefresh,
IconRepeat,
@@ -21,8 +20,8 @@ import { usePageEmbedLookup } from "./page-embed-lookup-context";
import {
PageEmbedAncestryProvider,
usePageEmbedAncestry,
PAGE_EMBED_MAX_DEPTH,
} from "./page-embed-ancestry-context";
import { decideEmbedState } from "./decide-embed-state";
import PageEmbedContent from "./page-embed-content";
function Placeholder({
@@ -99,13 +98,15 @@ function PageEmbedBody({
}
};
// --- Cycle / depth guard (evaluated before any lookup is rendered) ---------
// Self-embed or a source already present in the ancestor chain → cycle.
const isCycle =
!!sourcePageId &&
(ancestry.chain.includes(sourcePageId) ||
ancestry.hostPageId === sourcePageId);
const isTooDeep = ancestry.chain.length >= PAGE_EMBED_MAX_DEPTH;
// --- Cycle / depth / availability decision (pure, unit-tested) ------------
// Evaluated before any nested editor is rendered.
const embedState = decideEmbedState({
sourcePageId,
chain: ancestry.chain,
hostPageId: ancestry.hostPageId,
available,
result,
});
const sourceTitle =
result && !("status" in result) ? result.title : null;
@@ -136,20 +137,6 @@ function PageEmbedBody({
<IconRefresh size={14} />
</ActionIcon>
</Tooltip>
{sourceHref && (
<Tooltip label={t("Open source page")}>
<ActionIcon
component={Link}
to={sourceHref}
variant="subtle"
color="gray"
size="sm"
style={{ textDecoration: "none", borderBottom: "none" }}
>
<IconExternalLink size={14} />
</ActionIcon>
</Tooltip>
)}
<Menu position="bottom-end" withinPortal onChange={trackOpen}>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" size="sm">
@@ -170,13 +157,18 @@ function PageEmbedBody({
) : null;
const header =
sourceTitle || sourceIcon ? (
// Render the badge whenever the source resolves (sourceHref), not only when
// it has a title/icon — the title link is now the single way to open the
// source, so it must not disappear when title and icon are both empty.
sourceTitle || sourceIcon || sourceHref ? (
<div className={classes.transclusionBadge}>
{sourceIcon ? `${sourceIcon} ` : <IconArrowsMaximize size={12} />}
{sourceIcon ? `${sourceIcon} ` : <IconFileText size={12} />}
{sourceHref ? (
<Link
to={sourceHref}
style={{ borderBottom: "none", textDecoration: "none" }}
title={t("Open source page")}
aria-label={t("Open source page")}
>
{sourceTitle || t("Untitled")}
</Link>
@@ -187,28 +179,28 @@ function PageEmbedBody({
) : null;
let body: React.ReactNode;
if (!sourcePageId) {
if (embedState === "no_source") {
body = (
<Placeholder
icon={<IconInfoCircle size={18} stroke={1.6} />}
label={t("No page selected")}
/>
);
} else if (isCycle) {
} else if (embedState === "cycle") {
body = (
<Placeholder
icon={<IconRepeat size={18} stroke={1.6} />}
label={t("Circular embed: this page is already shown above")}
/>
);
} else if (isTooDeep) {
} else if (embedState === "too_deep") {
body = (
<Placeholder
icon={<IconRepeat size={18} stroke={1.6} />}
label={t("Embed nesting limit reached")}
/>
);
} else if (!available) {
} else if (embedState === "unavailable") {
// No lookup context (e.g. public share) → placeholder, no fetch in MVP.
body = (
<Placeholder
@@ -216,18 +208,28 @@ function PageEmbedBody({
label={t("Embedded page is not available here")}
/>
);
} else if (!result) {
} else if (embedState === "loading") {
body = <div style={{ minHeight: 24 }} />;
} else if (!("status" in result)) {
} else if (embedState === "ok" && result && !("status" in result)) {
body = (
<PageEmbedAncestryProvider
sourcePageId={sourcePageId}
hostPageId={hostPageId}
>
<PageEmbedContent content={result.content} />
{/*
Tiptap's EditorProvider consumes `content` only at initial mount, so a
changed `content` prop (e.g. after Refresh re-fetches fresh content)
would not update the read-only sub-editor. Key on the source's
updatedAt to remount PageEmbedContent (and its inner EditorProvider)
whenever the source page changes, applying the refreshed content.
*/}
<PageEmbedContent
key={result.sourceUpdatedAt}
content={result.content}
/>
</PageEmbedAncestryProvider>
);
} else if (result.status === "no_access") {
} else if (embedState === "no_access") {
body = (
<Placeholder
icon={<IconEyeOff size={18} stroke={1.6} />}
@@ -28,6 +28,7 @@ import {
IconTag,
IconMoodSmile,
IconRotate2,
IconSuperscript,
IconArrowsMaximize,
} from "@tabler/icons-react";
import { PAGE_EMBED_PICKER_EVENT } from "@/features/editor/components/page-embed/page-embed-picker";
@@ -368,6 +369,14 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).setDetails().run(),
},
{
title: "Footnote",
description: "Insert a footnote reference.",
searchTerms: ["footnote", "note", "reference", "сноска", "примечание"],
icon: IconSuperscript,
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).setFootnote().run(),
},
{
title: "Callout",
description: "Insert callout notice.",
@@ -183,7 +183,8 @@
}
:global(.react-renderer.node-transclusionSource.ProseMirror-selectednode),
:global(.react-renderer.node-transclusionReference.ProseMirror-selectednode) {
:global(.react-renderer.node-transclusionReference.ProseMirror-selectednode),
:global(.react-renderer.node-pageEmbed.ProseMirror-selectednode) {
outline: none;
}
@@ -63,6 +63,9 @@ import {
TransclusionReference,
PageEmbed,
TableView,
FootnoteReference,
FootnotesList,
FootnoteDefinition,
} from "@docmost/editor-ext";
import {
randomElement,
@@ -94,6 +97,9 @@ import PdfView from "@/features/editor/components/pdf/pdf-view.tsx";
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
import TransclusionView from "@/features/editor/components/transclusion/transclusion-view.tsx";
import TransclusionReferenceView from "@/features/editor/components/transclusion/transclusion-reference-view.tsx";
import FootnoteReferenceView from "@/features/editor/components/footnote/footnote-reference-view.tsx";
import FootnotesListView from "@/features/editor/components/footnote/footnotes-list-view.tsx";
import FootnoteDefinitionView from "@/features/editor/components/footnote/footnote-definition-view.tsx";
import PageEmbedView from "@/features/editor/components/page-embed/page-embed-view.tsx";
import { common, createLowlight } from "lowlight";
import plaintext from "highlight.js/lib/languages/plaintext";
@@ -392,6 +398,19 @@ export const mainExtensions = [
TransclusionReference.configure({
view: TransclusionReferenceView,
}),
FootnoteReference.configure({
view: FootnoteReferenceView,
// Skip orphan-cleanup on remote/collaboration steps so collaborating
// clients never fight over footnote integrity (deterministic numbering
// decorations handle the rest).
isRemoteTransaction: (tr: any) => isChangeOrigin(tr),
}),
FootnotesList.configure({
view: FootnotesListView,
}),
FootnoteDefinition.configure({
view: FootnoteDefinitionView,
}),
PageEmbed.configure({
view: PageEmbedView,
}),
@@ -48,9 +48,16 @@ export default function ReadonlyPageEditor({
}, []);
const extensions = useMemo(() => {
const filteredExtensions = mainExtensions.filter(
(ext) => ext.name !== "uniqueID",
);
const filteredExtensions = mainExtensions
.filter((ext) => ext.name !== "uniqueID")
// Read-only must only DECORATE footnotes (numbering), never mutate the
// doc. Disable the footnote sync/integrity plugin so a programmatic
// setContent on a doc the viewer can't edit is never rewritten.
.map((ext) =>
ext.name === "footnoteReference"
? ext.configure({ enableSync: false })
: ext,
);
return [
...filteredExtensions,
@@ -2,13 +2,14 @@ import { useRef } from "react";
import { Link, useParams } from "react-router-dom";
import { useAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { ActionIcon, rem } from "@mantine/core";
import { ActionIcon, rem, Tooltip } from "@mantine/core";
import {
IconChevronDown,
IconChevronRight,
IconFileDescription,
IconPlus,
IconPointFilled,
IconTemplate,
} from "@tabler/icons-react";
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
@@ -171,6 +172,25 @@ export function SpaceTreeRow({
<span className={classes.text}>{node.name || t("untitled")}</span>
{node.isTemplate === true && (
<Tooltip label={t("Template")} withArrow>
<IconTemplate
size={14}
stroke={1.5}
// Visual-only indicator: subtle and never shrinks. Pointer events
// stay enabled so the Tooltip's hover handlers fire; clicks fall
// through to the row link since no stopPropagation is used.
style={{
flexShrink: 0,
marginLeft: rem(4),
color: "var(--mantine-color-dimmed)",
}}
aria-label={t("Template")}
role="img"
/>
</Tooltip>
)}
<div className={classes.actions}>
<NodeMenu node={node} canEdit={canEdit} />
@@ -0,0 +1,228 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { createRef } from "react";
import { render, waitFor, cleanup } from "@testing-library/react";
// --- Mocks for the heavy / networked module graph ---------------------------
// SpaceTree pulls in query hooks, page services, i18n, notifications and two
// child render components. The expandAll contract is exercised purely through
// the imperative ref, so we mock everything that would otherwise need a real
// server / router and stub the visual children to empty renders.
const getSpaceTreeMock = vi.fn();
const notificationsShowMock = vi.fn();
vi.mock("@/features/page/services/page-service.ts", () => ({
getSpaceTree: (...args: unknown[]) => getSpaceTreeMock(...args),
getPageBreadcrumbs: vi.fn(),
}));
vi.mock("@/features/page/queries/page-query.ts", () => ({
// No root pages and no further pages — the data-load effect is inert so the
// test fully controls the tree through expandAll.
useGetRootSidebarPagesQuery: () => ({
data: undefined,
hasNextPage: false,
fetchNextPage: vi.fn(),
isFetching: false,
}),
usePageQuery: () => ({ data: undefined }),
fetchAllAncestorChildren: vi.fn(),
}));
vi.mock("@/features/page/tree/hooks/use-tree-mutation.ts", () => ({
useTreeMutation: () => ({ handleMove: vi.fn() }),
}));
vi.mock("@mantine/notifications", () => ({
notifications: { show: (...args: unknown[]) => notificationsShowMock(...args) },
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("react-router-dom", () => ({
useParams: () => ({ pageSlug: undefined }),
}));
vi.mock("@/lib", () => ({
extractPageSlugId: () => undefined,
}));
vi.mock("@/lib/config.ts", () => ({
isCompactPageTreeEnabled: () => false,
}));
// Stub the visual children so we don't drag in the full DnD / Mantine stack.
vi.mock("./doc-tree", () => ({
DocTree: () => null,
ROW_HEIGHT_COMPACT: 28,
ROW_HEIGHT_STANDARD: 32,
}));
vi.mock("./space-tree-row", () => ({
SpaceTreeRow: () => null,
}));
vi.mock("@mantine/core", () => ({
Text: ({ children }: { children?: unknown }) => children ?? null,
}));
// The real openTreeNodesAtom is localStorage-backed (atomWithStorage +
// getOnInit), which crashes under jsdom's localStorage shim here. Swap in a
// plain in-memory atom with the same read value (OpenMap) and the same setter
// shape (value OR functional updater) so the component's open-state logic runs
// unchanged while staying inside the test store.
vi.mock("@/features/page/tree/atoms/open-tree-nodes-atom.ts", async () => {
const { atom } = await import("jotai");
type OpenMap = Record<string, boolean>;
const base = atom<OpenMap>({});
const openTreeNodesAtom = atom(
(get) => get(base),
(get, set, update: OpenMap | ((prev: OpenMap) => OpenMap)) => {
const next =
typeof update === "function"
? (update as (prev: OpenMap) => OpenMap)(get(base))
: update;
set(base, next);
},
);
return { openTreeNodesAtom };
});
import SpaceTree, { SpaceTreeApi } from "./space-tree";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { openTreeNodesAtom } from "@/features/page/tree/atoms/open-tree-nodes-atom.ts";
import { createStore, Provider } from "jotai";
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
// A flat space-tree response (parentPageId pointers) that buildTree +
// buildTreeWithChildren nest into a multi-level tree. Depth > 1 lets us assert
// expandAll never fans out into per-branch fetches (no N+1).
function spaceTreeItems(): SpaceTreeNode[] {
const n = (
id: string,
parentPageId: string | null,
position: string,
): SpaceTreeNode => ({
id,
slugId: `slug-${id}`,
name: id,
icon: undefined,
position,
spaceId: "space-1",
parentPageId: parentPageId as unknown as string,
hasChildren: false,
children: [],
});
return [
n("root", null, "a0"),
n("branch", "root", "a1"),
n("leaf", "branch", "a1"),
];
}
function renderTree(store: ReturnType<typeof createStore>) {
const ref = createRef<SpaceTreeApi>();
render(
<Provider store={store}>
<SpaceTree ref={ref} spaceId="space-1" readOnly={false} />
</Provider>,
);
return ref;
}
beforeEach(() => {
getSpaceTreeMock.mockReset();
notificationsShowMock.mockReset();
// jsdom's localStorage shim here lacks `clear`; guard it. Each test uses a
// fresh jotai store anyway, so cross-test open-state never leaks.
try {
localStorage.clear?.();
} catch {
/* ignore — fresh store per test isolates state */
}
});
afterEach(() => {
cleanup();
});
describe("SpaceTree.expandAll (integration via ref)", () => {
it("makes exactly ONE getSpaceTree call regardless of depth (no N+1)", async () => {
getSpaceTreeMock.mockResolvedValue(spaceTreeItems());
const store = createStore();
const ref = renderTree(store);
await ref.current!.expandAll();
expect(getSpaceTreeMock).toHaveBeenCalledTimes(1);
expect(getSpaceTreeMock).toHaveBeenCalledWith({ spaceId: "space-1" });
// Every branch node (root, branch) is opened; the leaf needs no entry.
const openMap = store.get(openTreeNodesAtom);
expect(openMap["root"]).toBe(true);
expect(openMap["branch"]).toBe(true);
expect(openMap["leaf"]).toBeUndefined();
// The full tree replaced the current-space nodes.
const data = store.get(treeDataAtom);
expect(data.map((d) => d.id)).toEqual(["root"]);
});
it("shows a notification and still resets isExpanding when getSpaceTree rejects", async () => {
getSpaceTreeMock.mockRejectedValue(new Error("boom"));
const store = createStore();
const ref = renderTree(store);
await ref.current!.expandAll();
expect(notificationsShowMock).toHaveBeenCalledTimes(1);
expect(notificationsShowMock).toHaveBeenCalledWith(
expect.objectContaining({ color: "red" }),
);
// isExpanding must be reset in the finally block even on failure.
await waitFor(() => {
expect(ref.current!.isExpanding).toBe(false);
});
});
it("aborts the merge when the space switches mid-flight", async () => {
// getSpaceTree resolves only after we flip the tree to a different space,
// simulating the user navigating away while the request is in flight.
let resolveTree: (v: SpaceTreeNode[]) => void = () => {};
getSpaceTreeMock.mockImplementation(
() =>
new Promise<SpaceTreeNode[]>((resolve) => {
resolveTree = resolve;
}),
);
const store = createStore();
const ref = createRef<SpaceTreeApi>();
const { rerender } = render(
<Provider store={store}>
<SpaceTree ref={ref} spaceId="space-1" readOnly={false} />
</Provider>,
);
const promise = ref.current!.expandAll();
// Switch the space mid-flight: spaceIdRef.current becomes "space-2".
rerender(
<Provider store={store}>
<SpaceTree ref={ref} spaceId="space-2" readOnly={false} />
</Provider>,
);
// Now resolve the in-flight request for the OLD space.
resolveTree(spaceTreeItems());
await promise;
// The merge must have been aborted: no tree data written, no branches opened.
expect(store.get(treeDataAtom)).toEqual([]);
const openMap = store.get(openTreeNodesAtom);
expect(openMap["root"]).toBeUndefined();
expect(openMap["branch"]).toBeUndefined();
});
});
@@ -27,6 +27,8 @@ import {
mergeRootTrees,
collectAllIds,
collectBranchIds,
openBranches,
closeIds,
} from "@/features/page/tree/utils/utils.ts";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { treeModel } from "@/features/page/tree/model/tree-model";
@@ -236,11 +238,7 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
// Open every branch node (node with children) of the current space only.
const branchIds = collectBranchIds(fullTree);
setOpenTreeNodes((prev) => {
const next = { ...prev };
for (const id of branchIds) next[id] = true;
return next;
});
setOpenTreeNodes((prev) => openBranches(prev, branchIds));
} catch (err: any) {
// Never swallow: log full error + surface the real reason.
console.error("[tree] expandAll failed", err);
@@ -261,11 +259,7 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
// other spaces' expanded state is left intact.
const ids = collectAllIds(filteredData);
setOpenTreeNodes((prev) => {
const next = { ...prev };
for (const id of ids) next[id] = false;
return next;
});
setOpenTreeNodes((prev) => closeIds(prev, ids));
}, [filteredData, setOpenTreeNodes]);
useImperativeHandle(
@@ -196,6 +196,17 @@ describe('treeModel.insertByPosition', () => {
const t = treeModel.insertByPosition(roots, null, node);
expect(t.map((n) => n.id)).toEqual(['a', 'b', 'c', 'x']);
});
it('tie-break: a node whose position EQUALS a sibling lands deterministically (strict >)', () => {
// The insertion index is the first sibling whose position sorts STRICTLY
// after the new node's. An equal sibling is not strictly after, so it is
// skipped — the new node lands immediately AFTER every equal-position
// sibling and before the first strictly-greater one. This is deterministic:
// a tie always resolves the same way on every client.
const node: P = { id: 'x', name: 'X', position: 'a2' }; // equals b's position
const t = treeModel.insertByPosition(roots, null, node);
expect(t.map((n) => n.id)).toEqual(['a', 'b', 'x', 'c']);
});
});
// addTreeNode idempotency: the receiver early-returns when the node id already
@@ -692,4 +703,45 @@ describe('treeModel.move', () => {
});
expect(out.tree).toBe(fixture);
});
it('cross-parent move does NOT apply the same-parent adjust (no off-by-one)', () => {
// Source `x3` sits at index 2 in parent `x`; target `y1` sits at index 0 in
// parent `y`. sourceInfo.index (2) > info.index (0) AND the parents differ,
// so the `sameParent && source.index < info.index` adjust must be 0 — the
// node must land at index 0 in `y`, not at index -1 (which would silently
// drop it at a wrong slot / off-by-one).
const crossFixture: N[] = [
{
id: 'x',
name: 'X',
children: [
{ id: 'x1', name: 'X1' },
{ id: 'x2', name: 'X2' },
{ id: 'x3', name: 'X3' },
],
},
{
id: 'y',
name: 'Y',
children: [
{ id: 'y1', name: 'Y1' },
{ id: 'y2', name: 'Y2' },
],
},
];
const { tree: t, result } = treeModel.move(crossFixture, 'x3', {
kind: 'reorder-before',
targetId: 'y1',
});
expect(result).toEqual({ parentId: 'y', index: 0 });
expect(treeModel.find(t, 'y')?.children?.map((n) => n.id)).toEqual([
'x3',
'y1',
'y2',
]);
expect(treeModel.find(t, 'x')?.children?.map((n) => n.id)).toEqual([
'x1',
'x2',
]);
});
});
@@ -1,6 +1,14 @@
import { describe, it, expect } from "vitest";
import { buildTree } from "./utils";
import {
buildTree,
buildTreeWithChildren,
collectAllIds,
collectBranchIds,
openBranches,
closeIds,
} from "./utils";
import type { IPage } from "@/features/page/types/page.types.ts";
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
function page(id: string, position: string): IPage {
return {
@@ -15,6 +23,44 @@ function page(id: string, position: string): IPage {
} as IPage;
}
// Flat SpaceTreeNode factory for buildTreeWithChildren (it consumes a flat list
// with parentPageId pointers and nests them).
function flatNode(
id: string,
parentPageId: string | null,
position: string,
): SpaceTreeNode {
return {
id,
slugId: `slug-${id}`,
name: id.toUpperCase(),
icon: undefined,
position,
spaceId: "space-1",
parentPageId: parentPageId as unknown as string,
hasChildren: false,
children: [],
};
}
// Nested SpaceTreeNode factory for collectAllIds / collectBranchIds.
function treeNode(
id: string,
children: SpaceTreeNode[] = [],
): SpaceTreeNode {
return {
id,
slugId: `slug-${id}`,
name: id.toUpperCase(),
icon: undefined,
position: "a0",
spaceId: "space-1",
parentPageId: null as unknown as string,
hasChildren: children.length > 0,
children,
};
}
describe("buildTree", () => {
it("builds one node per unique page", () => {
const tree = buildTree([page("a", "a1"), page("b", "a2")]);
@@ -38,3 +84,192 @@ describe("buildTree", () => {
expect(new Set(ids).size).toBe(ids.length);
});
});
describe("collectBranchIds", () => {
it("returns every node-with-children id in a multi-level tree", () => {
const tree = [
treeNode("root", [
treeNode("branch1", [treeNode("leaf1")]),
treeNode("leaf2"),
]),
treeNode("root2", [treeNode("leaf3")]),
];
expect(collectBranchIds(tree).sort()).toEqual([
"branch1",
"root",
"root2",
]);
});
it("returns [] for a leaf-only tree", () => {
const tree = [treeNode("a"), treeNode("b"), treeNode("c")];
expect(collectBranchIds(tree)).toEqual([]);
});
it("does NOT include a node whose children is an empty array", () => {
// hasChildren-less / empty-children nodes are leaves for expansion purposes.
const tree = [treeNode("a", [])];
expect(collectBranchIds(tree)).toEqual([]);
});
it("returns every ancestor id in a deep single chain", () => {
const chain = treeNode("a", [
treeNode("b", [treeNode("c", [treeNode("d")])]),
]);
// a, b, c are branches; d is the leaf.
expect(collectBranchIds([chain])).toEqual(["a", "b", "c"]);
});
it("returns [] for an empty tree", () => {
expect(collectBranchIds([])).toEqual([]);
});
});
describe("collectAllIds", () => {
it("returns every id (roots, branches, leaves)", () => {
const tree = [
treeNode("root", [
treeNode("branch1", [treeNode("leaf1")]),
treeNode("leaf2"),
]),
treeNode("root2"),
];
expect(collectAllIds(tree).sort()).toEqual([
"branch1",
"leaf1",
"leaf2",
"root",
"root2",
]);
});
it("returns every id in a deep chain", () => {
const chain = treeNode("a", [
treeNode("b", [treeNode("c", [treeNode("d")])]),
]);
expect(collectAllIds([chain])).toEqual(["a", "b", "c", "d"]);
});
it("returns [] for an empty tree", () => {
expect(collectAllIds([])).toEqual([]);
});
it("is a superset of collectBranchIds for the same tree (property)", () => {
const tree = [
treeNode("root", [
treeNode("branch1", [treeNode("leaf1"), treeNode("leaf2")]),
treeNode("branch2", [treeNode("leaf3")]),
treeNode("leaf4"),
]),
treeNode("root2", [treeNode("leaf5")]),
];
const all = new Set(collectAllIds(tree));
const branches = collectBranchIds(tree);
for (const id of branches) {
expect(all.has(id)).toBe(true);
}
// And the superset is strictly larger (it also has the leaves).
expect(all.size).toBeGreaterThan(branches.length);
});
});
describe("buildTreeWithChildren", () => {
it("nests a flat list and sorts siblings by position", () => {
// Provided out of position order to prove the sort.
const flat = [
flatNode("root", null, "a0"),
flatNode("c2", "root", "a4"),
flatNode("c1", "root", "a1"),
];
const tree = buildTreeWithChildren(flat);
expect(tree.map((n) => n.id)).toEqual(["root"]);
expect(tree[0].children.map((n) => n.id)).toEqual(["c1", "c2"]);
});
it("recomputes hasChildren to true for nodes that gain children", () => {
// Parent ships with hasChildren=false; building must flip it true.
const flat = [
flatNode("root", null, "a0"),
flatNode("child", "root", "a1"),
];
expect(flat[0].hasChildren).toBe(false);
const tree = buildTreeWithChildren(flat);
expect(tree[0].hasChildren).toBe(true);
});
it("treats a node whose parentPageId is ABSENT from the list as a root (no crash)", () => {
// Permission-trimmed response: `orphan`'s parent `missing` was filtered out
// server-side. The function must not throw and must surface the orphan as a
// root rather than dropping or crashing on it.
const flat = [
flatNode("root", null, "a0"),
flatNode("orphan", "missing", "a2"),
];
let tree: SpaceTreeNode[] = [];
expect(() => {
tree = buildTreeWithChildren(flat);
}).not.toThrow();
expect(tree.map((n) => n.id).sort()).toEqual(["orphan", "root"]);
});
});
describe("openBranches", () => {
it("sets all given ids to true", () => {
const next = openBranches({}, ["a", "b", "c"]);
expect(next).toEqual({ a: true, b: true, c: true });
});
it("preserves pre-existing open ids and other-space ids", () => {
const prev = { existing: true, "other-space": true, closed: false };
const next = openBranches(prev, ["a"]);
expect(next).toEqual({
existing: true,
"other-space": true,
closed: false,
a: true,
});
});
it("does not mutate the input map", () => {
const prev = { a: false };
const next = openBranches(prev, ["a"]);
expect(prev).toEqual({ a: false });
expect(next).not.toBe(prev);
});
it("is idempotent", () => {
const once = openBranches({ z: true }, ["a", "b"]);
const twice = openBranches(once, ["a", "b"]);
expect(twice).toEqual(once);
});
});
describe("closeIds", () => {
it("flips current-space ids to false while leaving OTHER-space ids untouched", () => {
const prev = {
"current-1": true,
"current-2": true,
"other-space": true,
};
const next = closeIds(prev, ["current-1", "current-2"]);
expect(next).toEqual({
"current-1": false,
"current-2": false,
"other-space": true, // untouched
});
});
it("does not mutate the input map", () => {
const prev = { a: true };
const next = closeIds(prev, ["a"]);
expect(prev).toEqual({ a: true });
expect(next).not.toBe(prev);
});
it("is idempotent", () => {
const once = closeIds({ keep: true }, ["a", "b"]);
const twice = closeIds(once, ["a", "b"]);
expect(twice).toEqual(once);
expect(twice).toEqual({ keep: true, a: false, b: false });
});
});
@@ -142,11 +142,17 @@ export function buildTreeWithChildren(items: SpaceTreeNode[]): SpaceTreeNode[] {
// Build the tree array
items.forEach((item) => {
const node = nodeMap[item.id];
if (item.parentPageId !== null) {
// A permission-trimmed response can include a node whose `parentPageId` is
// not in the list (the parent was filtered out server-side). Treat such an
// orphan as a root instead of dereferencing an absent parent and throwing
// "Cannot read properties of undefined". Happy-path behaviour is unchanged:
// a node whose parent IS present still nests under it.
if (item.parentPageId !== null && nodeMap[item.parentPageId]) {
// Find the parent node and add the current node to its children
nodeMap[item.parentPageId].children.push(node);
} else {
// If the item has no parent, it's a root node, so add it to the result array
// If the item has no parent (or its parent isn't loaded), it's a root
// node, so add it to the result array.
result.push(node);
}
});
@@ -254,3 +260,30 @@ export function collectBranchIds(nodes: SpaceTreeNode[]): string[] {
walk(nodes);
return ids;
}
// The open-state map (`openTreeNodesAtom`) is shared across spaces. Pure
// next-map helpers for expand/collapse so the merge logic can be unit-tested
// without rendering SpaceTree. Both return a fresh map and never mutate the
// input — ids not in `ids` (e.g. other spaces) are carried over untouched.
// Set each id in `ids` to true (open). Pre-existing entries (including other
// spaces' open state) are preserved.
export function openBranches(
prevMap: Record<string, boolean>,
ids: string[],
): Record<string, boolean> {
const next = { ...prevMap };
for (const id of ids) next[id] = true;
return next;
}
// Set each id in `ids` to false (closed). Entries not listed (e.g. other
// spaces' ids) are left exactly as they were.
export function closeIds(
prevMap: Record<string, boolean>,
ids: string[],
): Record<string, boolean> {
const next = { ...prevMap };
for (const id of ids) next[id] = false;
return next;
}
@@ -7,8 +7,6 @@ import {
Box,
Group,
Paper,
ScrollArea,
Stack,
Text,
Textarea,
Tooltip,
@@ -22,6 +20,7 @@ import {
import { useTranslation } from "react-i18next";
import { useChat, type UIMessage } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import MessageList from "@/features/ai-chat/components/message-list.tsx";
interface ShareAiWidgetProps {
/** The share id (or key) the assistant is scoped to. */
@@ -30,17 +29,6 @@ interface ShareAiWidgetProps {
pageId: string;
}
/** Concatenate the visible text parts of a UIMessage. */
function messageText(message: UIMessage): string {
return (message.parts ?? [])
.filter(
(p): p is { type: "text"; text: string } =>
p?.type === "text" && typeof (p as { text?: string }).text === "string",
)
.map((p) => p.text)
.join("");
}
/**
* Lightweight, EPHEMERAL "Ask AI" widget for a public shared page.
*
@@ -49,6 +37,15 @@ function messageText(message: UIMessage): string {
* memory (this component's `useChat` store) and is sent with `credentials:
* "omit"` to the anonymous `/api/shares/ai/stream` endpoint. The server stores
* nothing.
*
* Presentation is now shared with the internal chat: the same `MessageList`
* renders the streamed transcript, so the public share gets the SAME
* incremental markdown render, animated typing indicator, and tool-call cards
* as the internal chat. Only the anonymous specifics differ no auth, no
* history, `credentials: "omit"`, suppressed page citations (an anonymous
* reader cannot open the linked internal pages), neutralized internal markdown
* links (so internal UUIDs/auth-gated routes in the answer don't leak as
* clickable links), and a documentation-focused empty state.
*/
export default function ShareAiWidget({ shareId, pageId }: ShareAiWidgetProps) {
const { t } = useTranslation();
@@ -147,53 +144,39 @@ export default function ShareAiWidget({ shareId, pageId }: ShareAiWidgetProps) {
</ActionIcon>
</Group>
<ScrollArea style={{ flex: 1 }} p="sm" scrollbarSize={6} type="scroll">
{messages.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" mt="lg">
{t("Ask a question about this documentation.")}
</Text>
) : (
<Stack gap="sm">
{messages.map((message) => (
<Box
key={message.id}
style={{
alignSelf:
message.role === "user" ? "flex-end" : "flex-start",
maxWidth: "85%",
}}
>
<Paper
p="xs"
radius="md"
bg={
message.role === "user"
? "var(--mantine-color-blue-light)"
: "var(--mantine-color-default-hover)"
}
>
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{messageText(message) ||
(isStreaming ? t("Thinking…") : "")}
</Text>
</Paper>
</Box>
))}
</Stack>
)}
{/* Shared transcript: same incremental streaming render, animated typing
indicator, markdown, and tool-call cards as the internal chat. The
share is anonymous, so page citation links are suppressed (an
anonymous reader cannot open the linked internal pages). */}
<Box style={{ flex: 1, minHeight: 0, display: "flex", padding: "var(--mantine-spacing-sm)" }}>
<MessageList
messages={messages}
isStreaming={isStreaming}
showCitations={false}
// Anonymous reader: neutralize internal/relative links in the
// assistant's markdown so internal UUIDs/auth-gated routes don't
// leak as clickable links (external http(s) links are kept).
neutralizeInternalLinks={true}
emptyState={
<Text size="sm" c="dimmed" ta="center">
{t("Ask a question about this documentation.")}
</Text>
}
/>
</Box>
{error && (
<Alert
variant="light"
color="red"
icon={<IconAlertTriangle size={16} />}
mt="sm"
title={t("Something went wrong")}
>
{t("The assistant is unavailable right now. Please try again.")}
</Alert>
)}
</ScrollArea>
{error && (
<Alert
variant="light"
color="red"
icon={<IconAlertTriangle size={16} />}
mx="sm"
mb="xs"
title={t("Something went wrong")}
>
{t("The assistant is unavailable right now. Please try again.")}
</Alert>
)}
<Group
gap="xs"
@@ -0,0 +1,264 @@
import { describe, it, expect } from "vitest";
import {
applyAddTreeNode,
applyMoveTreeNode,
applyDeleteTreeNode,
} from "./tree-socket-reducers";
import { treeModel } from "@/features/page/tree/model/tree-model";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
// Minimal node factory — fills the SpaceTreeNode shape required fields while
// letting tests override the bits that matter (position, parentPageId, etc).
function node(
id: string,
overrides: Partial<SpaceTreeNode> = {},
): SpaceTreeNode {
return {
id,
slugId: `slug-${id}`,
name: id.toUpperCase(),
icon: undefined,
position: "a0",
spaceId: "space-1",
parentPageId: null as unknown as string,
hasChildren: false,
children: [],
...overrides,
};
}
describe("applyMoveTreeNode", () => {
// Destination parent `dst` is loaded with three positioned children; the moved
// node `src` is a sibling at root with a later position.
const buildTree = (): SpaceTreeNode[] => [
node("dst", {
position: "a0",
hasChildren: true,
children: [
node("c1", { position: "a1", parentPageId: "dst" }),
node("c2", { position: "a3", parentPageId: "dst" }),
node("c3", { position: "a5", parentPageId: "dst" }),
],
}),
node("src", { position: "a9" }),
];
it("places the node by position in the MIDDLE slot of the destination", () => {
const tree = buildTree();
const next = applyMoveTreeNode(tree, {
id: "src",
parentId: "dst",
oldParentId: null,
index: 0,
position: "a4",
pageData: {},
});
expect(treeModel.find(next, "dst")?.children?.map((n) => n.id)).toEqual([
"c1",
"c2",
"src",
"c3",
]);
});
it("falls back to REMOVING the node when destination parent is not loaded (no leak)", () => {
const tree = buildTree();
const next = applyMoveTreeNode(tree, {
id: "src",
parentId: "not-loaded",
oldParentId: null,
index: 0,
position: "a4",
pageData: {},
});
// The source must not linger at its old place — it is removed entirely.
expect(treeModel.find(next, "src")).toBeNull();
// Destination children are untouched.
expect(treeModel.find(next, "dst")?.children?.map((n) => n.id)).toEqual([
"c1",
"c2",
"c3",
]);
});
it("flips the OLD parent's hasChildren to false when it is left childless", () => {
// src is the only child of `old`; moving it to `dst` empties `old`.
const tree: SpaceTreeNode[] = [
node("old", {
position: "a0",
hasChildren: true,
children: [node("src", { position: "a1", parentPageId: "old" })],
}),
node("dst", { position: "a2", hasChildren: false }),
];
const next = applyMoveTreeNode(tree, {
id: "src",
parentId: "dst",
oldParentId: "old",
index: 0,
position: "a1",
pageData: {},
});
expect(treeModel.find(next, "old")?.hasChildren).toBe(false);
});
it("flips the NEW parent's hasChildren to true", () => {
// dst starts as a childless leaf; moving src into it must flip the chevron.
const tree: SpaceTreeNode[] = [
node("dst", { position: "a0", hasChildren: false }),
node("src", { position: "a9" }),
];
const next = applyMoveTreeNode(tree, {
id: "src",
parentId: "dst",
oldParentId: null,
index: 0,
position: "a1",
pageData: {},
});
expect(treeModel.find(next, "dst")?.hasChildren).toBe(true);
expect(treeModel.find(next, "dst")?.children?.map((n) => n.id)).toEqual([
"src",
]);
});
it("returns prev unchanged when the source node is not found", () => {
const tree = buildTree();
const next = applyMoveTreeNode(tree, {
id: "ghost",
parentId: "dst",
oldParentId: null,
index: 0,
position: "a4",
pageData: {},
});
expect(next).toBe(tree);
});
it("applies authoritative pageData (title/icon/hasChildren) to the moved node", () => {
const tree = buildTree();
const next = applyMoveTreeNode(tree, {
id: "src",
parentId: "dst",
oldParentId: null,
index: 0,
position: "a4",
pageData: { title: "Renamed", icon: "fire", hasChildren: true },
});
const moved = treeModel.find(next, "src");
expect(moved?.name).toBe("Renamed");
expect(moved?.icon).toBe("fire");
expect(moved?.hasChildren).toBe(true);
expect(moved?.position).toBe("a4");
});
});
describe("applyDeleteTreeNode", () => {
it("removes the node together with its descendants", () => {
const tree: SpaceTreeNode[] = [
node("p", {
position: "a0",
hasChildren: true,
children: [
node("child", {
position: "a1",
parentPageId: "p",
hasChildren: true,
children: [node("grandchild", { position: "a1", parentPageId: "child" })],
}),
],
}),
];
const next = applyDeleteTreeNode(tree, {
node: node("child", { parentPageId: "p" }),
});
expect(treeModel.find(next, "child")).toBeNull();
expect(treeModel.find(next, "grandchild")).toBeNull();
expect(treeModel.find(next, "p")).not.toBeNull();
});
it("returns prev unchanged when the node is already gone (idempotent)", () => {
const tree: SpaceTreeNode[] = [node("a", { position: "a0" })];
const next = applyDeleteTreeNode(tree, {
node: node("ghost"),
});
expect(next).toBe(tree);
});
it("flips the parent's hasChildren to false when it is left childless", () => {
const tree: SpaceTreeNode[] = [
node("p", {
position: "a0",
hasChildren: true,
children: [node("only", { position: "a1", parentPageId: "p" })],
}),
];
const next = applyDeleteTreeNode(tree, {
node: node("only", { parentPageId: "p" }),
});
expect(treeModel.find(next, "p")?.hasChildren).toBe(false);
expect(treeModel.find(next, "p")?.children).toEqual([]);
});
it("leaves the parent's hasChildren true when other children remain", () => {
const tree: SpaceTreeNode[] = [
node("p", {
position: "a0",
hasChildren: true,
children: [
node("c1", { position: "a1", parentPageId: "p" }),
node("c2", { position: "a2", parentPageId: "p" }),
],
}),
];
const next = applyDeleteTreeNode(tree, {
node: node("c1", { parentPageId: "p" }),
});
expect(treeModel.find(next, "p")?.hasChildren).toBe(true);
});
});
describe("applyAddTreeNode", () => {
const roots = (): SpaceTreeNode[] => [
node("a", { position: "a0" }),
node("b", { position: "a2" }),
node("c", { position: "a4" }),
];
it("inserts the new node by position among siblings", () => {
const tree = roots();
const next = applyAddTreeNode(tree, {
parentId: null as unknown as string,
index: 0,
data: node("x", { position: "a3" }),
});
expect(next.map((n) => n.id)).toEqual(["a", "b", "x", "c"]);
});
it("returns prev unchanged when the id is already present (idempotent)", () => {
const tree = roots();
const next = applyAddTreeNode(tree, {
parentId: null as unknown as string,
index: 0,
data: node("b", { position: "a9" }),
});
expect(next).toBe(tree);
expect(next.map((n) => n.id)).toEqual(["a", "b", "c"]);
});
it("flips the new parent's hasChildren to true", () => {
// Parent `p` is a childless leaf; adding a child must flip its chevron.
const tree: SpaceTreeNode[] = [
node("p", { position: "a0", hasChildren: false }),
];
const next = applyAddTreeNode(tree, {
parentId: "p",
index: 0,
data: node("child", { position: "a1", parentPageId: "p" }),
});
expect(treeModel.find(next, "p")?.hasChildren).toBe(true);
expect(treeModel.find(next, "p")?.children?.map((n) => n.id)).toEqual([
"child",
]);
});
});
@@ -0,0 +1,164 @@
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { treeModel } from "@/features/page/tree/model/tree-model";
import type {
AddTreeNodeEvent,
MoveTreeNodeEvent,
DeleteTreeNodeEvent,
UpdateEvent,
} from "@/features/websocket/types";
// Pure tree transforms for the `useTreeSocket` reducer arms. Extracted from the
// hook so the realtime tree behaviour can be unit-tested without rendering the
// hook, the socket, or jotai. The hook calls these inside its `setData`.
//
// IMPORTANT: these are PURE — no `queryClient`, no notifications, no atoms. The
// delete arm's `queryClient.invalidateQueries` side effect stays in the hook;
// `applyDeleteTreeNode` is a pure tree transform only.
// `updateOne` for a page: patch the in-tree node's name/icon from the payload.
// No-op (returns the same reference) when the node isn't loaded on this client.
export function applyUpdateOne(
prev: SpaceTreeNode[],
event: UpdateEvent,
): SpaceTreeNode[] {
if (!treeModel.find(prev, event.id)) return prev;
let next = prev;
if (event.payload?.title !== undefined) {
next = treeModel.update(next, event.id, {
name: event.payload.title,
} as Partial<SpaceTreeNode>);
}
if (event.payload?.icon !== undefined) {
next = treeModel.update(next, event.id, {
icon: event.payload.icon,
} as Partial<SpaceTreeNode>);
}
return next;
}
// `addTreeNode`: insert the new node by its fractional `position` among the
// already-loaded siblings (not the sender's absolute index). Idempotent — if the
// id already exists (optimistic author insert or re-delivery) returns prev
// unchanged. Flips the new parent's `hasChildren` to true so the chevron renders.
export function applyAddTreeNode(
prev: SpaceTreeNode[],
payload: AddTreeNodeEvent["payload"],
): SpaceTreeNode[] {
// Idempotent: the author already inserted the node optimistically, and a node
// may be re-delivered — never insert a duplicate id.
if (treeModel.find(prev, payload.data.id)) return prev;
const newParentId = payload.parentId as string | null;
// Insert by `position` among already-loaded siblings (not the sender's
// absolute index) so order is consistent across clients with different loaded
// sets.
let next = treeModel.insertByPosition(prev, newParentId, payload.data);
// Mirror the emitter: flip new parent's hasChildren to true so the chevron
// renders on the receiver.
if (newParentId) {
next = treeModel.update(next, newParentId, {
hasChildren: true,
} as Partial<SpaceTreeNode>);
}
return next;
}
// `moveTreeNode`: place the moved node by its fractional `position` among the new
// siblings (NOT the sender's absolute index). If the destination parent isn't
// loaded on this client, fall back to removing the source so the UI stays
// consistent. Applies authoritative `pageData` fields and mirrors the
// `hasChildren` bookkeeping for both the old and the new parent.
export function applyMoveTreeNode(
prev: SpaceTreeNode[],
payload: MoveTreeNodeEvent["payload"],
): SpaceTreeNode[] {
const sourceBefore = treeModel.find(prev, payload.id);
if (!sourceBefore) return prev;
const oldParentId = (sourceBefore as SpaceTreeNode).parentPageId ?? null;
const newParentId = payload.parentId as string | null;
// Place the node by its fractional `position` among the new siblings — NOT by
// the sender's absolute `index` (the sender computed that against its own
// loaded set, which differs from this receiver's). Using the position keeps
// the visible order correct on every client; placing at `index: 0` would
// wrongly drop reordered/moved nodes at the top of their new sibling list.
const placed = treeModel.placeByPosition(prev, payload.id, {
parentId: newParentId,
position: payload.position,
});
// `placeByPosition` silently returns the same reference if the destination
// parent isn't loaded on this client. Falling back to removing the source
// keeps the UI consistent (the source reappears when the user expands the new
// parent and lazy-load fetches it).
if (placed === prev) {
return treeModel.remove(prev, payload.id);
}
// Apply the authoritative node fields the move payload carries (`pageData`) so
// receivers don't keep a stale title/icon/chevron on the moved node.
// `placeByPosition` already set `position`.
const pageData = payload.pageData as
| {
title?: string | null;
icon?: string | null;
hasChildren?: boolean;
}
| undefined;
const patch: Partial<SpaceTreeNode> = {
position: payload.position,
// Honest type: a root move has a null parent, so this is `string | null`,
// not always `string`.
parentPageId: newParentId as string | null,
};
if (pageData) {
// The tree node stores the title as `name`.
if (pageData.title !== undefined) patch.name = pageData.title ?? "";
if (pageData.icon !== undefined) patch.icon = pageData.icon ?? undefined;
if (pageData.hasChildren !== undefined)
patch.hasChildren = pageData.hasChildren;
}
let next = treeModel.update(placed, payload.id, patch);
// Mirror the emitter's hasChildren bookkeeping so both clients converge to the
// same chevron state.
if (oldParentId) {
const oldParent = treeModel.find(next, oldParentId);
if (!oldParent?.children?.length) {
next = treeModel.update(next, oldParentId, {
hasChildren: false,
} as Partial<SpaceTreeNode>);
}
}
if (newParentId) {
next = treeModel.update(next, newParentId, {
hasChildren: true,
} as Partial<SpaceTreeNode>);
}
return next;
}
// `deleteTreeNode`: remove the node (and its descendants) from the tree.
// Idempotent — if the node is already gone returns prev unchanged. Mirrors the
// `hasChildren` bookkeeping: a parent left childless flips `hasChildren` false.
//
// PURE: the `queryClient.invalidateQueries` side effect lives in the hook, not
// here.
export function applyDeleteTreeNode(
prev: SpaceTreeNode[],
payload: DeleteTreeNodeEvent["payload"],
): SpaceTreeNode[] {
if (!treeModel.find(prev, payload.node.id)) return prev;
let next = treeModel.remove(prev, payload.node.id);
// Mirror the emitter's hasChildren bookkeeping so both clients converge to the
// same chevron state when the last child is deleted.
const parentPageId = payload.node.parentPageId;
if (parentPageId) {
const parent = treeModel.find(next, parentPageId);
if (!parent?.children?.length) {
next = treeModel.update(next, parentPageId, {
hasChildren: false,
} as Partial<SpaceTreeNode>);
}
}
return next;
}
@@ -6,6 +6,12 @@ import { WebSocketEvent } from "@/features/websocket/types";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { useQueryClient } from "@tanstack/react-query";
import { treeModel } from "@/features/page/tree/model/tree-model";
import {
applyUpdateOne,
applyAddTreeNode,
applyMoveTreeNode,
applyDeleteTreeNode,
} from "@/features/websocket/tree-socket-reducers.ts";
import localEmitter from "@/lib/local-emitter.ts";
export const useTreeSocket = () => {
@@ -35,138 +41,26 @@ export const useTreeSocket = () => {
switch (event.operation) {
case "updateOne":
if (event.entity[0] === "pages") {
setTreeData((prev) => {
if (!treeModel.find(prev, event.id)) return prev;
let next = prev;
if (event.payload?.title !== undefined) {
next = treeModel.update(next, event.id, {
name: event.payload.title,
} as Partial<SpaceTreeNode>);
}
if (event.payload?.icon !== undefined) {
next = treeModel.update(next, event.id, {
icon: event.payload.icon,
} as Partial<SpaceTreeNode>);
}
return next;
});
setTreeData((prev) => applyUpdateOne(prev, event));
}
break;
case "addTreeNode":
setTreeData((prev) => {
// Idempotent: the author already inserted the node optimistically,
// and a node may be re-delivered — never insert a duplicate id.
if (treeModel.find(prev, event.payload.data.id)) return prev;
const newParentId = event.payload.parentId as string | null;
// Insert by `position` among already-loaded siblings (not the
// sender's absolute index) so order is consistent across clients
// with different loaded sets.
let next = treeModel.insertByPosition(
prev,
newParentId,
event.payload.data,
);
// Mirror the emitter: flip new parent's hasChildren to true so
// the chevron renders on the receiver.
if (newParentId) {
next = treeModel.update(next, newParentId, {
hasChildren: true,
} as Partial<SpaceTreeNode>);
}
return next;
});
setTreeData((prev) => applyAddTreeNode(prev, event.payload));
break;
case "moveTreeNode":
setTreeData((prev) => {
const sourceBefore = treeModel.find(prev, event.payload.id);
if (!sourceBefore) return prev;
const oldParentId =
(sourceBefore as SpaceTreeNode).parentPageId ?? null;
const newParentId = event.payload.parentId as string | null;
// Place the node by its fractional `position` among the new
// siblings — NOT by the sender's absolute `index` (the sender
// computed that against its own loaded set, which differs from
// this receiver's). Using the position keeps the visible order
// correct on every client; placing at `index: 0` would wrongly
// drop reordered/moved nodes at the top of their new sibling list.
const placed = treeModel.placeByPosition(prev, event.payload.id, {
parentId: newParentId,
position: event.payload.position,
});
// `placeByPosition` silently returns the same reference if the
// destination parent isn't loaded on this client. Falling back to
// removing the source keeps the UI consistent (the source will
// reappear when the user expands the new parent and lazy-load
// fetches it).
if (placed === prev) {
return treeModel.remove(prev, event.payload.id);
}
// Apply the authoritative node fields the move payload carries
// (`pageData`) so receivers don't keep a stale title/icon/chevron
// on the moved node. `placeByPosition` already set `position`.
const pageData = event.payload.pageData as
| {
title?: string | null;
icon?: string | null;
hasChildren?: boolean;
}
| undefined;
const patch: Partial<SpaceTreeNode> = {
position: event.payload.position,
// Honest type: a root move has a null parent, so this is
// `string | null`, not always `string`.
parentPageId: newParentId as string | null,
};
if (pageData) {
// The tree node stores the title as `name`.
if (pageData.title !== undefined) patch.name = pageData.title ?? "";
if (pageData.icon !== undefined)
patch.icon = pageData.icon ?? undefined;
if (pageData.hasChildren !== undefined)
patch.hasChildren = pageData.hasChildren;
}
let next = treeModel.update(placed, event.payload.id, patch);
// Mirror the emitter's hasChildren bookkeeping so both clients
// converge to the same chevron state.
if (oldParentId) {
const oldParent = treeModel.find(next, oldParentId);
if (!oldParent?.children?.length) {
next = treeModel.update(next, oldParentId, {
hasChildren: false,
} as Partial<SpaceTreeNode>);
}
}
if (newParentId) {
next = treeModel.update(next, newParentId, {
hasChildren: true,
} as Partial<SpaceTreeNode>);
}
return next;
});
setTreeData((prev) => applyMoveTreeNode(prev, event.payload));
break;
case "deleteTreeNode":
// The `invalidateQueries` side effect stays in the hook; the tree
// transform (`applyDeleteTreeNode`) is pure. Only invalidate when the
// node is actually in the tree (mirrors the pure reducer's early-out).
setTreeData((prev) => {
if (!treeModel.find(prev, event.payload.node.id)) return prev;
queryClient.invalidateQueries({
queryKey: ["pages", event.payload.node.slugId].filter(Boolean),
});
let next = treeModel.remove(prev, event.payload.node.id);
// Mirror the emitter's hasChildren bookkeeping so both clients
// converge to the same chevron state when the last child is deleted.
const parentPageId = event.payload.node.parentPageId;
if (parentPageId) {
const parent = treeModel.find(next, parentPageId);
if (!parent?.children?.length) {
next = treeModel.update(next, parentPageId, {
hasChildren: false,
} as Partial<SpaceTreeNode>);
}
if (treeModel.find(prev, event.payload.node.id)) {
queryClient.invalidateQueries({
queryKey: ["pages", event.payload.node.slugId].filter(Boolean),
});
}
return next;
return applyDeleteTreeNode(prev, event.payload);
});
break;
}
@@ -1,5 +1,9 @@
import { describe, it, expect } from 'vitest';
import { resolveCardStatus } from './ai-provider-settings';
import {
resolveCardStatus,
isEndpointConfigured,
resolveKeyField,
} from './ai-provider-settings';
describe('resolveCardStatus', () => {
it('returns "off" when not configured and not enabled', () => {
@@ -18,3 +22,52 @@ describe('resolveCardStatus', () => {
expect(resolveCardStatus(true, true)).toBe('ready');
});
});
describe('isEndpointConfigured', () => {
it('configured when model and the endpoint own base URL are set', () => {
expect(isEndpointConfigured('m', 'https://own', '')).toBe(true);
});
it('configured by inheriting the chat base URL when own base is empty', () => {
expect(isEndpointConfigured('m', '', 'https://chat')).toBe(true);
});
it('not configured when model is set but both base URLs are empty', () => {
expect(isEndpointConfigured('m', '', '')).toBe(false);
});
it('not configured when both base URLs are whitespace-only', () => {
expect(isEndpointConfigured('m', ' ', '\t')).toBe(false);
});
it('not configured when the model is whitespace-only', () => {
expect(isEndpointConfigured(' ', 'https://own', 'https://chat')).toBe(
false,
);
});
});
describe('resolveKeyField (write-only key payload)', () => {
// The same logic backs all three keys (chat / embedding / stt) in buildPayload.
it('typed a value -> set the new key', () => {
expect(resolveKeyField('sk-new', false)).toEqual({
set: true,
value: 'sk-new',
});
});
it('typed a value wins even if cleared was also flagged', () => {
expect(resolveKeyField('sk-new', true)).toEqual({
set: true,
value: 'sk-new',
});
});
it('cleared (empty buffer) -> set the key to empty string', () => {
expect(resolveKeyField('', true)).toEqual({ set: true, value: '' });
});
it('untouched (empty buffer, not cleared) -> omit the key', () => {
expect(resolveKeyField('', false)).toEqual({ set: false });
});
});
@@ -98,6 +98,33 @@ export function resolveCardStatus(
return enabled ? "warning" : "off";
}
// Pure + unit-testable. A non-chat endpoint (embeddings / voice) is "configured"
// when its model is set AND it has a usable base URL: either its own base URL is
// non-empty, or the chat base URL is non-empty (inherited when own is empty).
// All inputs are trimmed so whitespace-only values do not count as filled.
export function isEndpointConfigured(
model: string,
ownBase: string,
chatBase: string,
): boolean {
return (
model.trim() !== "" && (ownBase.trim() !== "" || chatBase.trim() !== "")
);
}
// Pure + unit-testable. Write-only API-key payload semantics:
// - typed a value (buffer non-empty) -> set it
// - explicitly cleared -> send '' to clear the stored key
// - untouched (empty buffer, not cleared) -> omit the key entirely
export function resolveKeyField(
buffer: string,
cleared: boolean,
): { set: true; value: string } | { set: false } {
if (buffer.length > 0) return { set: true, value: buffer };
if (cleared) return { set: true, value: "" };
return { set: false };
}
// Translate the dot's tooltip label. Kept in one place so all three endpoint
// cards share identical wording.
function cardStatusLabel(status: CardStatus, t: (k: string) => string): string {
@@ -263,29 +290,23 @@ export default function AiProviderSettings() {
sttApiStyle: values.sttApiStyle,
};
// Key semantics (never send the stored key back):
// Key semantics (never send the stored key back) — see resolveKeyField:
// - typed a value -> set it
// - explicitly cleared -> send '' to clear
// - untouched -> omit the key entirely (leave unchanged)
if (values.apiKey.length > 0) {
payload.apiKey = values.apiKey;
} else if (keyCleared) {
payload.apiKey = "";
}
const apiKeyField = resolveKeyField(values.apiKey, keyCleared);
if (apiKeyField.set) payload.apiKey = apiKeyField.value;
// Same write-only semantics for the embedding-specific key.
if (values.embeddingApiKey.length > 0) {
payload.embeddingApiKey = values.embeddingApiKey;
} else if (embeddingKeyCleared) {
payload.embeddingApiKey = "";
}
const embeddingKeyField = resolveKeyField(
values.embeddingApiKey,
embeddingKeyCleared,
);
if (embeddingKeyField.set) payload.embeddingApiKey = embeddingKeyField.value;
// Same write-only semantics for the STT-specific key.
if (values.sttApiKey.length > 0) {
payload.sttApiKey = values.sttApiKey;
} else if (sttKeyCleared) {
payload.sttApiKey = "";
}
const sttKeyField = resolveKeyField(values.sttApiKey, sttKeyCleared);
if (sttKeyField.set) payload.sttApiKey = sttKeyField.value;
return payload;
}
@@ -460,12 +481,12 @@ export default function AiProviderSettings() {
const v = form.values;
const chatBase = v.baseUrl.trim();
const chatConfigured = v.chatModel.trim() !== "" && chatBase !== "";
const embedConfigured =
v.embeddingModel.trim() !== "" &&
(v.embeddingBaseUrl.trim() !== "" || chatBase !== "");
const sttConfigured =
v.sttModel.trim() !== "" &&
(v.sttBaseUrl.trim() !== "" || chatBase !== "");
const embedConfigured = isEndpointConfigured(
v.embeddingModel,
v.embeddingBaseUrl,
v.baseUrl,
);
const sttConfigured = isEndpointConfigured(v.sttModel, v.sttBaseUrl, v.baseUrl);
const chatStatus = resolveCardStatus(chatConfigured, chatEnabled);
const embedStatus = resolveCardStatus(embedConfigured, searchEnabled);
+23 -2
View File
@@ -23,6 +23,7 @@
"migration:reset": "tsx src/database/migrate.ts down-to NO_MIGRATIONS",
"migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/database/types/db.d.ts",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"pretest": "pnpm --filter @docmost/editor-ext build",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
@@ -162,10 +163,30 @@
"moduleFileExtensions": [
"js",
"json",
"ts"
"ts",
"tsx"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"testPathIgnorePatterns": [
"/node_modules/",
"<rootDir>/core/auth/auth.controller.spec.ts",
"<rootDir>/core/auth/services/auth.service.spec.ts",
"<rootDir>/core/auth/services/token.service.spec.ts",
"<rootDir>/core/comment/comment.service.spec.ts",
"<rootDir>/core/group/group.controller.spec.ts",
"<rootDir>/core/group/services/group.service.spec.ts",
"<rootDir>/core/page/page.controller.spec.ts",
"<rootDir>/core/page/services/page.service.spec.ts",
"<rootDir>/core/search/search.controller.spec.ts",
"<rootDir>/core/search/search.service.spec.ts",
"<rootDir>/core/space/services/space.service.spec.ts",
"<rootDir>/core/space/space.controller.spec.ts",
"<rootDir>/core/user/user.controller.spec.ts",
"<rootDir>/core/workspace/services/workspace.service.spec.ts",
"<rootDir>/integrations/environment/environment.service.spec.ts",
"<rootDir>/integrations/storage/storage.service.spec.ts"
],
"transform": {
"happy-dom.+\\.js$": [
"babel-jest",
@@ -182,7 +203,7 @@
]
}
],
"^.+\\.(t|j)s$": "ts-jest"
"^.+\\.(t|j)sx?$": "ts-jest"
},
"transformIgnorePatterns": [
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom)(@|/))"
@@ -45,6 +45,9 @@ import {
htmlToMarkdown,
TransclusionSource,
TransclusionReference,
FootnoteReference,
FootnotesList,
FootnoteDefinition,
PageEmbed,
} from '@docmost/editor-ext';
import { generateText, getSchema, JSONContent } from '@tiptap/core';
@@ -115,6 +118,9 @@ export const tiptapExtensions = [
Status,
TransclusionSource,
TransclusionReference,
FootnoteReference,
FootnotesList,
FootnoteDefinition,
PageEmbed,
] as any;
@@ -0,0 +1,61 @@
import { htmlToJson, jsonToHtml } from './collaboration.util';
const findFirst = (json: any, type: string): any | undefined => {
if (!json || typeof json !== 'object') return undefined;
if (json.type === type) return json;
if (Array.isArray(json.content)) {
for (const child of json.content) {
const found = findFirst(child, type);
if (found) return found;
}
}
return undefined;
};
/**
* Guards the fragile parse-priority approach that lets a `footnoteReference`
* NODE win over the `Superscript` MARK for `<sup>` elements. In the server
* `tiptapExtensions` list, Superscript is registered BEFORE the footnote nodes,
* so without the priority guard a `<sup data-footnote-ref>` would be parsed as
* an (empty) superscript mark and the footnote reference would be lost.
*/
describe('footnote reference vs superscript mark (server schema round-trip)', () => {
const HTML =
'<p>Water' +
'<sup data-footnote-ref data-id="fn1"></sup>' +
' here.</p>' +
'<section data-footnotes>' +
'<div data-footnote-def data-id="fn1"><p>First note.</p></div>' +
'</section>';
it('parses <sup data-footnote-ref> into a footnoteReference NODE (not a superscript mark)', () => {
const json = htmlToJson(HTML);
const ref = findFirst(json, 'footnoteReference');
expect(ref).toBeDefined();
expect(ref.attrs.id).toBe('fn1');
// It must NOT have been swallowed as a superscript mark on text.
const superscriptText = JSON.stringify(json).includes('"superscript"');
expect(superscriptText).toBe(false);
// The matching definition survives too.
const def = findFirst(json, 'footnoteDefinition');
expect(def).toBeDefined();
expect(def.attrs.id).toBe('fn1');
});
it('round-trips an empty footnoteReference back to <sup data-footnote-ref>', () => {
const json = htmlToJson(HTML);
const html = jsonToHtml(json);
expect(html).toContain('data-footnote-ref');
expect(html).toContain('data-id="fn1"');
// And a second parse still yields the node (stable round-trip).
const json2 = htmlToJson(html);
const ref2 = findFirst(json2, 'footnoteReference');
expect(ref2).toBeDefined();
expect(ref2.attrs.id).toBe('fn1');
});
});
@@ -0,0 +1,70 @@
import { markdownToHtml, encodeHtmlEmbedSource } from '@docmost/editor-ext';
import { htmlToJson } from '../../../collaboration/collaboration.util';
import { hasHtmlEmbedNode, stripHtmlEmbedNodes } from './html-embed.util';
/**
* CONTRACT (security): an attacker who controls imported markdown/HTML could try
* to smuggle an htmlEmbed in the *serialized* DOM form
* <div data-type="htmlEmbed" data-source="...">
* directly, bypassing the editor's `<!--html-embed:-->` comment marker.
*
* This exercises the REAL server import conversion path that ImportService uses
* (`markdownToHtml` then `htmlToJson`; `processHTML` adds only a cheerio
* link/iframe normalize pass which does not touch htmlEmbed divs) and asserts
* the ACTUAL behaviour so we know whether the strip gate can be bypassed.
*
* FINDING (documented): the raw embed div DOES round-trip through marked +
* htmlToJson into a real `htmlEmbed` node, so `hasHtmlEmbedNode` returns true and
* `stripHtmlEmbedNodes` removes it. The serialized-form bypass is therefore
* detectable and STRIPPABLE the write-path gate covers it.
*/
describe('htmlEmbed smuggled via the raw serialized div in imported markdown/HTML', () => {
it('round-trips through markdownToHtml -> htmlToJson and is DETECTED (base64 data-source)', async () => {
const source = '<script>steal()</script>';
const encoded = encodeHtmlEmbedSource(source);
const md = [
'Hello',
'',
`<div data-type="htmlEmbed" data-source="${encoded}"></div>`,
'',
'World',
].join('\n');
const html = await markdownToHtml(md);
// marked preserves the raw block-level div verbatim.
expect(html).toContain('data-type="htmlEmbed"');
const json = htmlToJson(html);
// The div parses into a real htmlEmbed node carrying the decoded source.
expect(hasHtmlEmbedNode(json)).toBe(true);
// Because it is detected, the write-path gate can strip it for non-admins.
const stripped = stripHtmlEmbedNodes(json);
expect(hasHtmlEmbedNode(stripped)).toBe(false);
// Surrounding non-embed content is retained.
expect(JSON.stringify(stripped)).toContain('Hello');
expect(JSON.stringify(stripped)).toContain('World');
});
it('round-trips through direct HTML conversion (htmlToJson) and is DETECTED', () => {
const source = '<script>steal()</script>';
const encoded = encodeHtmlEmbedSource(source);
const html = `<p>Hello</p><div data-type="htmlEmbed" data-source="${encoded}"></div><p>World</p>`;
const json = htmlToJson(html);
expect(hasHtmlEmbedNode(json)).toBe(true);
expect(hasHtmlEmbedNode(stripHtmlEmbedNodes(json))).toBe(false);
});
it('is still DETECTED even when the data-source is NOT valid base64', async () => {
// A naive raw inline source (HTML-escaped, not base64) still parses as an
// htmlEmbed NODE — the decoder just yields an empty source. Detection (and
// therefore stripping) does not depend on the source being well-formed, so
// the bypass cannot be hidden by sending a malformed data-source.
const md = `<div data-type="htmlEmbed" data-source="&lt;script&gt;x&lt;/script&gt;"></div>`;
const html = await markdownToHtml(md);
const json = htmlToJson(html);
expect(hasHtmlEmbedNode(json)).toBe(true);
expect(hasHtmlEmbedNode(stripHtmlEmbedNodes(json))).toBe(false);
});
});
@@ -105,6 +105,70 @@ describe('stripHtmlEmbedNodes', () => {
const result = stripHtmlEmbedNodes(root);
expect(hasHtmlEmbedNode(result)).toBe(false);
});
it('strips a deeply nested htmlEmbed (3+ levels: callout > column > paragraph-sibling)', () => {
// htmlEmbed sits as a sibling of a paragraph, nested four containers deep.
const doc = {
type: 'doc',
content: [
{
type: 'callout',
content: [
{
type: 'columns',
content: [
{
type: 'column',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'deep keep' }],
},
{ type: 'htmlEmbed', attrs: { source: '<script>x</script>' } },
],
},
],
},
],
},
],
};
const result = stripHtmlEmbedNodes(doc);
expect(hasHtmlEmbedNode(result)).toBe(false);
const col = findFirstChild(result, 'column');
// Sibling paragraph survives; only the embed is removed.
expect(col.content).toHaveLength(1);
expect(col.content[0].type).toBe('paragraph');
expect(col.content[0].content[0].text).toBe('deep keep');
});
it('returns non-object / null / array-without-content nodes unchanged', () => {
// Non-object inputs are returned as-is (callers persist what they got).
expect(stripHtmlEmbedNodes(null as any)).toBeNull();
expect(stripHtmlEmbedNodes(undefined as any)).toBeUndefined();
expect(stripHtmlEmbedNodes('not-a-node' as any)).toBe('not-a-node');
expect(stripHtmlEmbedNodes(42 as any)).toBe(42);
// An object node with no `content` array is returned shallow-cloned, equal.
const leaf = { type: 'paragraph', attrs: { id: 'x' } };
const out = stripHtmlEmbedNodes(leaf);
expect(out).toEqual(leaf);
expect(out).not.toBe(leaf); // new object, input not mutated
});
it('yields empty content (not null/undefined) for a doc whose only child is an htmlEmbed', () => {
const doc = {
type: 'doc',
content: [{ type: 'htmlEmbed', attrs: { source: '<b>only</b>' } }],
};
const result = stripHtmlEmbedNodes(doc) as any;
expect(Array.isArray(result.content)).toBe(true);
expect(result.content).toHaveLength(0);
expect(result.content).not.toBeNull();
expect(result.content).not.toBeUndefined();
expect(hasHtmlEmbedNode(result)).toBe(false);
});
});
describe('collectHtmlEmbedSources', () => {
@@ -270,6 +334,38 @@ describe('stripDisallowedHtmlEmbedNodes', () => {
});
});
describe('hasHtmlEmbedNode (root/odd-shape detection)', () => {
it('returns true when the ROOT node itself is an htmlEmbed (not only a child)', () => {
const rootEmbed = { type: 'htmlEmbed', attrs: { source: '<script>r</script>' } };
expect(hasHtmlEmbedNode(rootEmbed)).toBe(true);
});
it('returns false for a doc with embed-like TEXT but no htmlEmbed node', () => {
// The literal string "htmlEmbed" appears only as text content, not as a
// node type, so it must NOT be detected.
const doc = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'type: htmlEmbed <div data-type="htmlEmbed">' },
],
},
],
};
expect(hasHtmlEmbedNode(doc)).toBe(false);
});
it('returns false for non-object / null / array inputs', () => {
expect(hasHtmlEmbedNode(null)).toBe(false);
expect(hasHtmlEmbedNode(undefined)).toBe(false);
expect(hasHtmlEmbedNode('htmlEmbed')).toBe(false);
// A bare array (no `content` wrapper) has no node `type`, so it's false.
expect(hasHtmlEmbedNode([{ type: 'htmlEmbed' }] as any)).toBe(false);
});
});
describe('canAuthorHtmlEmbed', () => {
it('allows owner and admin', () => {
expect(canAuthorHtmlEmbed('owner')).toBe(true);
@@ -4,6 +4,7 @@ import {
serializeSteps,
rowToUiMessage,
prepareAgentStep,
buildErrorAssistantRecord,
MAX_AGENT_STEPS,
FINAL_STEP_INSTRUCTION,
} from './ai-chat.service';
@@ -229,3 +230,32 @@ describe('prepareAgentStep', () => {
expect(atBoundary?.toolChoice).toBe('none');
});
});
/**
* Unit test for buildErrorAssistantRecord: the pure helper that shapes the
* assistant-message record persisted on a first-turn (or any) stream failure.
* The streamText onError callback builds the formatted error text via
* describeProviderError (tested separately) and hands it to this helper; pinning
* the record shape here covers the persist-assistant-on-error logic without
* having to seam streamText itself.
*/
describe('buildErrorAssistantRecord', () => {
it('records an empty turn with the error text in metadata (finishReason=error)', () => {
const rec = buildErrorAssistantRecord('401: Unauthorized');
expect(rec).toEqual({
text: '',
toolCalls: null,
metadata: { finishReason: 'error', parts: [], error: '401: Unauthorized' },
});
});
it('always produces empty text + empty parts so a failed turn is still recorded', () => {
const rec = buildErrorAssistantRecord('boom');
// No partial text and no UI parts: the turn exists in history but renders as
// an error, with the cause preserved in metadata.error.
expect(rec.text).toBe('');
expect(rec.metadata.parts).toEqual([]);
expect(rec.toolCalls).toBeNull();
expect(rec.metadata.error).toBe('boom');
});
});
@@ -257,6 +257,9 @@ export class AiChatService {
sessionId,
workspace.id,
chatId,
// Same open-page value used by the system prompt above; exposed to the
// model via getCurrentPage so page identity survives prompt mangling.
body.openPage,
);
// Merge in admin-configured external MCP tools (web search, etc.; §6.8).
@@ -384,11 +387,7 @@ export class AiChatService {
this.logger.error(`AI chat stream error: ${errorText}`, e?.stack);
// Persist whatever text we have (likely empty) so the turn is recorded,
// and record the error text in metadata so it is visible in history.
await persistAssistant({
text: '',
toolCalls: null,
metadata: { finishReason: 'error', parts: [], error: errorText },
});
await persistAssistant(buildErrorAssistantRecord(errorText));
await closeExternalClients();
},
onAbort: async ({ steps }) => {
@@ -710,6 +709,26 @@ export function rowToUiMessage(row: AiChatMessage): Omit<UIMessage, 'id'> & {
return { id: row.id, role, parts: parts as UIMessage['parts'] };
}
/**
* Build the assistant-message record persisted when a turn fails before any text
* is produced (the streamText onError path). Pure: it takes the formatted error
* text and returns the exact `{ text, toolCalls, metadata }` payload handed to
* persistAssistant, so the first-turn-failure recording shape is unit-testable
* without seaming streamText. The empty text + empty parts mean the failed turn
* is still recorded in history, with the provider cause visible in metadata.
*/
export function buildErrorAssistantRecord(errorText: string): {
text: string;
toolCalls: null;
metadata: { finishReason: 'error'; parts: []; error: string };
} {
return {
text: '',
toolCalls: null,
metadata: { finishReason: 'error', parts: [], error: errorText },
};
}
/**
* Reduce SDK step objects to a compact, JSON-serializable trace for the
* `tool_calls` column. Stores only what the UI action-log and history need
@@ -0,0 +1,256 @@
import { HttpException } from '@nestjs/common';
import {
resolveShareAssistantRequest,
uiMessageTextLength,
type ShareAssistantDeps,
} from './public-share-chat.controller';
import { AiNotConfiguredException } from '../../integrations/ai/ai-not-configured.exception';
import {
MAX_SHARE_MESSAGES,
MAX_SHARE_MESSAGE_CHARS,
} from './public-share-chat.service';
import type { UIMessage } from 'ai';
/**
* Unit tests for the extracted pre-hijack funnel (resolveShareAssistantRequest)
* and the exported size helper (uiMessageTextLength). The funnel order is
* security-relevant: the first failing gate must win, every failure must throw
* BEFORE any stream/hijack, and the access-shaped failures must all 404 (no
* existence leak). These exercise each branch with hand-rolled mocks no Nest
* module graph, no DB.
*/
describe('resolveShareAssistantRequest (extracted controller funnel)', () => {
/** A fully-passing dep set; individual tests override single collaborators. */
function makeDeps(over: {
assistantEnabled?: boolean;
getShareForPage?: jest.Mock;
isSharingAllowed?: jest.Mock;
findById?: jest.Mock;
hasRestrictedAncestor?: jest.Mock;
resolveShareRole?: jest.Mock;
getShareChatModel?: jest.Mock;
tryConsumeWorkspaceQuota?: jest.Mock;
} = {}) {
const aiSettings = {
isPublicShareAssistantEnabled: jest
.fn()
.mockResolvedValue(over.assistantEnabled ?? true),
};
const shareService = {
getShareForPage:
over.getShareForPage ??
jest.fn().mockResolvedValue({
id: 'SHARE-A',
pageId: 'root-page',
spaceId: 'space-1',
sharedPage: { id: 'root-page', title: 'Root' },
}),
isSharingAllowed:
over.isSharingAllowed ?? jest.fn().mockResolvedValue(true),
};
const pageRepo = {
findById:
over.findById ?? jest.fn().mockResolvedValue({ id: 'opened-uuid' }),
};
const pagePermissionRepo = {
hasRestrictedAncestor:
over.hasRestrictedAncestor ?? jest.fn().mockResolvedValue(false),
};
const publicShareChat = {
resolveShareRole:
over.resolveShareRole ?? jest.fn().mockResolvedValue(null),
getShareChatModel:
over.getShareChatModel ?? jest.fn().mockResolvedValue('MODEL'),
tryConsumeWorkspaceQuota:
over.tryConsumeWorkspaceQuota ?? jest.fn().mockResolvedValue(true),
};
const deps: ShareAssistantDeps = {
aiSettings: aiSettings as never,
shareService: shareService as never,
pageRepo: pageRepo as never,
pagePermissionRepo: pagePermissionRepo as never,
publicShareChat: publicShareChat as never,
};
return {
deps,
aiSettings,
shareService,
pageRepo,
pagePermissionRepo,
publicShareChat,
};
}
const body = (over: Record<string, unknown> = {}) => ({
shareId: 'SHARE-A',
pageId: 'opened-page',
messages: [],
...over,
});
/** Run the funnel and capture the thrown HttpException status (or null). */
async function statusOf(
deps: ShareAssistantDeps,
b: Record<string, unknown>,
): Promise<number | null> {
try {
await resolveShareAssistantRequest(deps, {
workspaceId: 'ws-1',
body: b as never,
});
return null;
} catch (err) {
if (err instanceof HttpException) return err.getStatus();
throw err;
}
}
it('happy path: returns the resolved, non-null request', async () => {
const { deps } = makeDeps();
const out = await resolveShareAssistantRequest(deps, {
workspaceId: 'ws-1',
body: body() as never,
});
expect(out.shareId).toBe('SHARE-A');
expect(out.share.id).toBe('SHARE-A');
expect(out.model).toBe('MODEL');
expect(out.role).toBeNull();
expect(out.openedPage).toEqual({ id: 'opened-page', title: 'Root' });
});
it('assistant disabled => 404 and NO share/page/model lookups', async () => {
const { deps, shareService, pageRepo, publicShareChat } = makeDeps({
assistantEnabled: false,
});
expect(await statusOf(deps, body())).toBe(404);
expect(shareService.getShareForPage).not.toHaveBeenCalled();
expect(pageRepo.findById).not.toHaveBeenCalled();
expect(publicShareChat.getShareChatModel).not.toHaveBeenCalled();
});
it('share.id !== body.shareId => 404 (cross-share id swap rejected)', async () => {
const { deps, publicShareChat } = makeDeps({
getShareForPage: jest.fn().mockResolvedValue({
id: 'OTHER-SHARE',
pageId: 'root',
spaceId: 'space-1',
sharedPage: null,
}),
});
expect(await statusOf(deps, body({ shareId: 'SHARE-A' }))).toBe(404);
// Never reached the model resolution for an unusable share.
expect(publicShareChat.getShareChatModel).not.toHaveBeenCalled();
});
it('opened page unresolvable (pageRepo.findById -> null) => fail-closed 404', async () => {
const { deps } = makeDeps({
findById: jest.fn().mockResolvedValue(null),
});
expect(await statusOf(deps, body())).toBe(404);
});
it('restricted descendant => 404 (same as out-of-tree, no existence leak)', async () => {
const { deps, pagePermissionRepo } = makeDeps({
hasRestrictedAncestor: jest.fn().mockResolvedValue(true),
});
expect(await statusOf(deps, body())).toBe(404);
expect(pagePermissionRepo.hasRestrictedAncestor).toHaveBeenCalled();
});
it('getShareChatModel throws AiNotConfiguredException => 503', async () => {
const { deps } = makeDeps({
getShareChatModel: jest
.fn()
.mockRejectedValue(new AiNotConfiguredException()),
});
expect(await statusOf(deps, body())).toBe(503);
});
it('getShareChatModel throws a non-AiNotConfigured error => re-thrown (not a 503/404)', async () => {
const boom = new Error('boom');
const { deps } = makeDeps({
getShareChatModel: jest.fn().mockRejectedValue(boom),
});
await expect(
resolveShareAssistantRequest(deps, {
workspaceId: 'ws-1',
body: body() as never,
}),
).rejects.toBe(boom);
});
it('tryConsumeWorkspaceQuota false => 429 thrown BEFORE any stream', async () => {
const { deps, publicShareChat } = makeDeps({
tryConsumeWorkspaceQuota: jest.fn().mockResolvedValue(false),
});
expect(await statusOf(deps, body())).toBe(429);
// The quota gate ran AFTER the model resolved (provider configured) but the
// function returns/throws before producing a streamable request.
expect(publicShareChat.tryConsumeWorkspaceQuota).toHaveBeenCalledWith('ws-1');
});
it('messages over MAX_SHARE_MESSAGES => 413', async () => {
const { deps } = makeDeps();
const tooMany = Array.from({ length: MAX_SHARE_MESSAGES + 1 }, () => ({
role: 'user',
parts: [{ type: 'text', text: 'hi' }],
}));
expect(await statusOf(deps, body({ messages: tooMany }))).toBe(413);
});
it('a single message over MAX_SHARE_MESSAGE_CHARS => 413 (uiMessageTextLength)', async () => {
const { deps } = makeDeps();
const huge = {
role: 'user',
parts: [{ type: 'text', text: 'x'.repeat(MAX_SHARE_MESSAGE_CHARS + 1) }],
};
expect(await statusOf(deps, body({ messages: [huge] }))).toBe(413);
});
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.
const { deps } = makeDeps({
tryConsumeWorkspaceQuota: jest.fn().mockResolvedValue(false),
});
const huge = {
role: 'user',
parts: [{ type: 'text', text: 'x'.repeat(MAX_SHARE_MESSAGE_CHARS + 1) }],
};
expect(await statusOf(deps, body({ messages: [huge] }))).toBe(429);
});
});
describe('uiMessageTextLength', () => {
it('returns 0 for an undefined / parts-less / non-array message', () => {
expect(uiMessageTextLength(undefined)).toBe(0);
expect(uiMessageTextLength({} as UIMessage)).toBe(0);
expect(uiMessageTextLength({ parts: 'nope' } as never)).toBe(0);
});
it('sums the lengths of ONLY the text parts', () => {
const msg = {
role: 'user',
parts: [
{ type: 'text', text: 'hello' }, // 5
{ type: 'tool-call', text: 'IGNORED' }, // non-text: ignored
{ type: 'text', text: 'world!' }, // 6
{ type: 'text' }, // no text field: ignored
],
} as unknown as UIMessage;
expect(uiMessageTextLength(msg)).toBe(11);
});
it('matches the 413 boundary used by the funnel', () => {
const atCap = {
role: 'user',
parts: [{ type: 'text', text: 'x'.repeat(MAX_SHARE_MESSAGE_CHARS) }],
} as unknown as UIMessage;
const overCap = {
role: 'user',
parts: [{ type: 'text', text: 'x'.repeat(MAX_SHARE_MESSAGE_CHARS + 1) }],
} as unknown as UIMessage;
expect(uiMessageTextLength(atCap)).toBe(MAX_SHARE_MESSAGE_CHARS);
expect(uiMessageTextLength(overCap)).toBeGreaterThan(MAX_SHARE_MESSAGE_CHARS);
});
});
@@ -77,142 +77,25 @@ export class PublicShareChatController {
@AuthWorkspace() workspace: Workspace,
): Promise<void> {
const body = (req.body ?? {}) as PublicShareChatStreamBody;
const shareId = typeof body.shareId === 'string' ? body.shareId.trim() : '';
const pageId = typeof body.pageId === 'string' ? body.pageId.trim() : '';
// ---- Guardrail funnel (order matters; each failure exits before stream) ----
// 1. Workspace master toggle. 404 (do not reveal the feature exists).
const assistantEnabled = await this.aiSettings.isPublicShareAssistantEnabled(
workspace.id,
// The whole pre-hijack fact-resolution + cap-ordering block is a pure-ish
// helper (collaborators passed in) so every funnel branch — 404 disabled /
// share-mismatch / page-unresolvable / restricted, 503 unconfigured, 429
// over-cap, 413 too many/too long — is unit-testable against the red-team
// boundaries without the full Nest/DB graph. It throws the SAME HttpException
// the controller would, and never starts streaming.
const resolved = await resolveShareAssistantRequest(
{
aiSettings: this.aiSettings,
shareService: this.shareService,
pageRepo: this.pageRepo,
pagePermissionRepo: this.pagePermissionRepo,
publicShareChat: this.publicShareChat,
},
{ workspaceId: workspace.id, body },
);
// 2. Share usable? Resolved via the page's share membership, since the page
// resolution (getShareForPage) ALSO yields the share + workspace. We
// still need basic input to attempt it.
// 3. Page in share? The same getShareForPage lookup confirms the opened page
// resolves to THIS share tree, PLUS an explicit restricted-ancestor gate
// (getShareForPage itself does NOT exclude restricted descendants) so a
// restricted page hidden from the public view is graded not-in-share.
// (shareUsable + pageInShare are set together below; the funnel grades
// them as distinct ordered steps.)
let share: Awaited<ReturnType<ShareService['getShareForPage']>> | undefined;
let shareUsable = false;
let pageInShare = false;
if (assistantEnabled && shareId && pageId) {
// getShareForPage walks up the tree to the nearest ancestor share,
// enforces share.workspaceId === workspaceId and includeSubPages, and
// returns undefined when the page is not publicly reachable. NOTE: it
// joins only the `shares` table — it does NOT exclude restricted
// descendants — so a restricted page inside an includeSubPages share
// still resolves here. We add an explicit restricted-ancestor gate below
// (same as the public view) so the opened page's title never leaks into
// the system prompt for a page the public view 404s.
share = await this.shareService.getShareForPage(pageId, workspace.id);
if (share && share.id === shareId) {
// Confirm sharing is still allowed for the share's space (and not
// disabled at workspace/space level) — same gate the public views use.
const sharingAllowed = await this.shareService.isSharingAllowed(
workspace.id,
share.spaceId,
);
// A restricted descendant is hidden from the public share view; treat
// the opened page as not-in-share so the funnel returns the SAME 404 it
// returns for an out-of-tree page (uniform, no existence leak).
// hasRestrictedAncestor matches on the page UUID only, while the
// opened pageId may be a slugId, so resolve to the UUID first (cheap
// base-fields lookup, mirroring how getSharedPage resolves the page
// before its restricted check).
const openedPageRow = await this.pageRepo.findById(pageId);
const restricted = openedPageRow
? await this.pagePermissionRepo.hasRestrictedAncestor(
openedPageRow.id,
)
: true; // unresolvable opened page => fail closed (treat as not-in-share)
// The security-relevant combination (server-resolved share id ===
// requested shareId, + sharingAllowed, + the restricted gate) is a pure,
// unit-tested helper so the access join point can be exercised against
// the red-team boundaries without the full Nest/DB graph.
({ shareUsable, pageInShare } = deriveShareAccess({
resolvedShareId: share.id,
requestedShareId: shareId,
sharingAllowed,
restricted,
}));
}
}
// 4. Provider configured? Resolve the model now so an unconfigured provider
// yields a clean 503 (AiNotConfiguredException) BEFORE hijack. Only
// attempt this once the earlier gates passed, to avoid leaking timing.
let model: Awaited<ReturnType<PublicShareChatService['getShareChatModel']>> | undefined;
// Admin-selected identity (agent role) for the anonymous assistant, resolved
// server-authoritatively. null = built-in locked persona.
let role: AiAgentRole | null = null;
let providerConfigured = false;
if (assistantEnabled && shareUsable && pageInShare) {
try {
role = await this.publicShareChat.resolveShareRole(workspace.id);
model = await this.publicShareChat.getShareChatModel(workspace.id, role);
providerConfigured = true;
} catch (err) {
if (err instanceof AiNotConfiguredException) {
providerConfigured = false;
} else {
throw err;
}
}
}
const outcome = evaluateShareAssistantFunnel({
assistantEnabled,
shareUsable,
pageInShare,
providerConfigured,
});
if (outcome.ok === false) {
// 404 for everything access-shaped (feature/share/page); 503 for config.
if (outcome.status === 503) {
throw new ServiceUnavailableException('AI is not configured');
}
throw new NotFoundException('Not found');
}
// 5. Per-WORKSPACE anti-abuse cap (IP-independent; defense in depth). The
// per-IP @Throttle above can be evaded by an attacker rotating
// `X-Forwarded-For` (the app runs with trustProxy), and each evaded call
// spends REAL tokens on the workspace owner's paid AI provider. This cap
// is keyed by the server-resolved workspace id (never attacker-
// controllable), so it bounds the owner's bill even when the per-IP limit
// is fully defeated via XFF spoofing. Checked here, BEFORE res.hijack(),
// so an over-cap workspace gets a clean 429 and spends nothing. NOTE:
// production should ALSO front this endpoint with a trusted proxy that
// REWRITES (not appends) XFF so the per-IP throttle stays meaningful.
if (!(await this.publicShareChat.tryConsumeWorkspaceQuota(workspace.id))) {
throw new HttpException(
'This documentation assistant is temporarily busy. Please try again later.',
HttpStatus.TOO_MANY_REQUESTS,
);
}
// ---- Validate / bound the payload (cheap caps; ephemeral, never stored) ----
const messages = Array.isArray(body.messages)
? (body.messages as UIMessage[])
: [];
if (messages.length > MAX_SHARE_MESSAGES) {
throw new HttpException('Too many messages', 413);
}
for (const m of messages) {
const text = uiMessageTextLength(m);
if (text > MAX_SHARE_MESSAGE_CHARS) {
throw new HttpException('Message too long', 413);
}
}
const openedPage = {
id: pageId,
title: share?.sharedPage?.title ?? undefined,
};
const { shareId, share, model, role, messages, openedPage } = resolved;
// Abort the agent loop when the client disconnects (mirrors ai-chat).
const controller = new AbortController();
@@ -230,15 +113,15 @@ export class PublicShareChatController {
workspaceId: workspace.id,
shareId,
share: {
id: share!.id,
pageId: share!.pageId,
sharedPage: share!.sharedPage,
id: share.id,
pageId: share.pageId,
sharedPage: share.sharedPage,
},
openedPage,
messages,
res,
signal: controller.signal,
model: model!,
model,
role,
});
} catch (err) {
@@ -255,8 +138,174 @@ export class PublicShareChatController {
}
}
/** Sum of the text-part lengths of a UIMessage (cheap, for the size cap). */
function uiMessageTextLength(message: UIMessage | undefined): number {
/**
* The collaborators the pre-hijack funnel needs. Declared as the minimal slice
* of each injected service it actually calls, so the resolver can be unit-tested
* with hand-rolled mocks (no Nest module graph, no DB).
*/
export interface ShareAssistantDeps {
aiSettings: Pick<AiSettingsService, 'isPublicShareAssistantEnabled'>;
shareService: Pick<
ShareService,
'getShareForPage' | 'isSharingAllowed'
>;
pageRepo: Pick<PageRepo, 'findById'>;
pagePermissionRepo: Pick<PagePermissionRepo, 'hasRestrictedAncestor'>;
publicShareChat: Pick<
PublicShareChatService,
| 'resolveShareRole'
| 'getShareChatModel'
| 'tryConsumeWorkspaceQuota'
>;
}
/** The resolved, validated request ready to stream (everything is non-null). */
export interface ResolvedShareAssistantRequest {
shareId: string;
share: NonNullable<Awaited<ReturnType<ShareService['getShareForPage']>>>;
model: Awaited<ReturnType<PublicShareChatService['getShareChatModel']>>;
role: AiAgentRole | null;
messages: UIMessage[];
openedPage: { id: string; title?: string };
}
/**
* Pre-hijack fact-resolution + cap-ordering for the anonymous public-share
* assistant, extracted from the controller so every funnel branch is unit-
* testable without the Nest/DB graph. Order is security-relevant and each
* failure exits BEFORE any stream/hijack:
* 1. assistant toggle off => 404 (no share/page/model lookups);
* 2. share/page access (deriveShareAccess + evaluateShareAssistantFunnel) =>
* 404 (uniform; restricted descendant and out-of-tree look identical);
* 3. provider unconfigured => 503 (AiNotConfiguredException), other errors
* re-thrown;
* 4. per-workspace quota exhausted => 429 (BEFORE any stream/hijack);
* 5. payload caps => 413 (too many messages / a single message too long).
* Throws the SAME HttpException the controller would; returns the resolved,
* non-null request otherwise.
*/
export async function resolveShareAssistantRequest(
deps: ShareAssistantDeps,
input: { workspaceId: string; body: PublicShareChatStreamBody },
): Promise<ResolvedShareAssistantRequest> {
const { workspaceId, body } = input;
const shareId = typeof body.shareId === 'string' ? body.shareId.trim() : '';
const pageId = typeof body.pageId === 'string' ? body.pageId.trim() : '';
// 1. Workspace master toggle. 404 (do not reveal the feature exists).
const assistantEnabled =
await deps.aiSettings.isPublicShareAssistantEnabled(workspaceId);
// 2/3. Share usable? Page in share? Resolved via the page's share membership,
// since getShareForPage ALSO yields the share + workspace. The opened
// page is then gated by an explicit restricted-ancestor check (which
// getShareForPage does NOT do) so a restricted page hidden from the
// public view is graded not-in-share.
let share: Awaited<ReturnType<ShareService['getShareForPage']>> | undefined;
let shareUsable = false;
let pageInShare = false;
if (assistantEnabled && shareId && pageId) {
share = await deps.shareService.getShareForPage(pageId, workspaceId);
if (share && share.id === shareId) {
const sharingAllowed = await deps.shareService.isSharingAllowed(
workspaceId,
share.spaceId,
);
// hasRestrictedAncestor matches on the page UUID only, while the opened
// pageId may be a slugId, so resolve to the UUID first (cheap base-fields
// lookup). An unresolvable opened page fails closed (not-in-share).
const openedPageRow = await deps.pageRepo.findById(pageId);
const restricted = openedPageRow
? await deps.pagePermissionRepo.hasRestrictedAncestor(openedPageRow.id)
: true;
({ shareUsable, pageInShare } = deriveShareAccess({
resolvedShareId: share.id,
requestedShareId: shareId,
sharingAllowed,
restricted,
}));
}
}
// 4. Provider configured? Resolve the model now so an unconfigured provider
// yields a clean 503 BEFORE hijack. Only after the access gates pass, to
// avoid leaking timing.
let model:
| Awaited<ReturnType<PublicShareChatService['getShareChatModel']>>
| undefined;
let role: AiAgentRole | null = null;
let providerConfigured = false;
if (assistantEnabled && shareUsable && pageInShare) {
try {
role = await deps.publicShareChat.resolveShareRole(workspaceId);
model = await deps.publicShareChat.getShareChatModel(workspaceId, role);
providerConfigured = true;
} catch (err) {
if (err instanceof AiNotConfiguredException) {
providerConfigured = false;
} else {
throw err;
}
}
}
const outcome = evaluateShareAssistantFunnel({
assistantEnabled,
shareUsable,
pageInShare,
providerConfigured,
});
if (outcome.ok === false) {
// 404 for everything access-shaped (feature/share/page); 503 for config.
if (outcome.status === 503) {
throw new ServiceUnavailableException('AI is not configured');
}
throw new NotFoundException('Not found');
}
// 5. Per-WORKSPACE anti-abuse cap (IP-independent; defense in depth). Checked
// BEFORE res.hijack(), so an over-cap workspace gets a clean 429 and spends
// nothing.
if (!(await deps.publicShareChat.tryConsumeWorkspaceQuota(workspaceId))) {
throw new HttpException(
'This documentation assistant is temporarily busy. Please try again later.',
HttpStatus.TOO_MANY_REQUESTS,
);
}
// ---- Validate / bound the payload (cheap caps; ephemeral, never stored) ----
const messages = Array.isArray(body.messages)
? (body.messages as UIMessage[])
: [];
if (messages.length > MAX_SHARE_MESSAGES) {
throw new HttpException('Too many messages', 413);
}
for (const m of messages) {
if (uiMessageTextLength(m) > MAX_SHARE_MESSAGE_CHARS) {
throw new HttpException('Message too long', 413);
}
}
const openedPage = {
id: pageId,
title: share?.sharedPage?.title ?? undefined,
};
// The funnel passed, so share/model are guaranteed present.
return {
shareId,
share: share!,
model: model!,
role,
messages,
openedPage,
};
}
/** Sum of the text-part lengths of a UIMessage (cheap, for the size cap).
* Exported so the 413 size-cap logic is unit-testable without the Nest/DB graph.
*/
export function uiMessageTextLength(message: UIMessage | undefined): number {
if (!message?.parts || !Array.isArray(message.parts)) return 0;
let total = 0;
for (const p of message.parts) {
@@ -7,7 +7,11 @@ import {
filterShareTranscript,
} from './public-share-chat.service';
import { PublicShareChatToolsService } from './tools/public-share-chat-tools.service';
import { PublicShareWorkspaceLimiter } from './public-share-workspace-limiter';
import {
PublicShareWorkspaceLimiter,
resolveShareAiWorkspaceMax,
SHARE_AI_WORKSPACE_MAX_PER_WINDOW,
} from './public-share-workspace-limiter';
/**
* Minimal in-memory fake of the slice of ioredis the sliding-window limiter
@@ -195,6 +199,54 @@ describe('buildShareSystemPrompt locking', () => {
expect(prompt).toContain('read-only assistant');
expect(prompt).toContain('anti prompt-injection');
});
it('an opened page with a title injects both the pageId and the title', () => {
const prompt = buildShareSystemPrompt({
share: null,
openedPage: { id: 'page-123', title: 'Getting Started' },
});
expect(prompt).toContain('(pageId: page-123)');
expect(prompt).toContain('"Getting Started"');
expect(prompt).toContain('the current page');
});
it('an opened page with a blank/whitespace title falls back to "Untitled"', () => {
const prompt = buildShareSystemPrompt({
share: null,
openedPage: { id: 'page-123', title: ' ' },
});
expect(prompt).toContain('(pageId: page-123)');
expect(prompt).toContain('"Untitled"');
});
it('an empty / blank pageId omits the opened-page context line entirely', () => {
const emptyId = buildShareSystemPrompt({
share: null,
openedPage: { id: '', title: 'Ignored' },
});
expect(emptyId).not.toContain('pageId:');
expect(emptyId).not.toContain('the current page');
const blankId = buildShareSystemPrompt({
share: null,
openedPage: { id: ' ', title: 'Ignored' },
});
expect(blankId).not.toContain('pageId:');
});
it('a present share title is injected; a blank share title is omitted', () => {
const withTitle = buildShareSystemPrompt({
share: { sharedPageTitle: 'Product Docs' },
openedPage: null,
});
expect(withTitle).toContain('titled "Product Docs"');
const blankTitle = buildShareSystemPrompt({
share: { sharedPageTitle: ' ' },
openedPage: null,
});
expect(blankTitle).not.toContain('This published documentation is titled');
});
});
describe('PublicShareChatService model fallback', () => {
@@ -306,6 +358,44 @@ describe('PublicShareChatService model fallback', () => {
});
});
describe('resolveShareAiWorkspaceMax (env-overridable per-workspace cap)', () => {
const ENV = 'SHARE_AI_WORKSPACE_MAX_PER_HOUR';
const original = process.env[ENV];
afterEach(() => {
if (original === undefined) delete process.env[ENV];
else process.env[ENV] = original;
});
it('uses a valid positive integer from the env', () => {
process.env[ENV] = '42';
expect(resolveShareAiWorkspaceMax()).toBe(42);
});
it('floors a float value', () => {
process.env[ENV] = '99.9';
expect(resolveShareAiWorkspaceMax()).toBe(99);
});
it('falls back to the default for an unparseable / NaN value', () => {
process.env[ENV] = 'not-a-number';
expect(resolveShareAiWorkspaceMax()).toBe(SHARE_AI_WORKSPACE_MAX_PER_WINDOW);
expect(SHARE_AI_WORKSPACE_MAX_PER_WINDOW).toBe(300);
});
it('falls back to the default when unset', () => {
delete process.env[ENV];
expect(resolveShareAiWorkspaceMax()).toBe(SHARE_AI_WORKSPACE_MAX_PER_WINDOW);
});
it('falls back to the default for zero or a negative value (no unlimited / negative cap)', () => {
process.env[ENV] = '0';
expect(resolveShareAiWorkspaceMax()).toBe(SHARE_AI_WORKSPACE_MAX_PER_WINDOW);
process.env[ENV] = '-5';
expect(resolveShareAiWorkspaceMax()).toBe(SHARE_AI_WORKSPACE_MAX_PER_WINDOW);
});
});
describe('PublicShareWorkspaceLimiter (cluster-wide sliding-window per-workspace cap)', () => {
it('allows up to the cap within a window, then 429s (returns false)', async () => {
const limiter = makeLimiter(3, 60_000, () => 1_000);
@@ -353,6 +443,23 @@ describe('PublicShareWorkspaceLimiter (cluster-wide sliding-window per-workspace
expect(await limiter.tryConsume('ws-1')).toBe(true);
});
it('consumes a distinct member slot per call at one FIXED clock value (no same-ms score-collision under-count)', async () => {
// All calls happen at the SAME millisecond. The limiter mints a unique member
// id per attempt, so distinct calls in the same ms must NOT collide on the
// sorted-set score and under-count: exactly `cap` calls are admitted, the
// rest rejected — even though every score is identical.
const cap = 5;
const limiter = makeLimiter(cap, 60_000, () => 7_000); // clock never advances
const results: boolean[] = [];
for (let i = 0; i < cap + 3; i++) {
results.push(await limiter.tryConsume('ws-1'));
}
// First `cap` admitted, the remaining 3 rejected.
expect(results.slice(0, cap)).toEqual(Array(cap).fill(true));
expect(results.slice(cap)).toEqual([false, false, false]);
expect(results.filter(Boolean)).toHaveLength(cap);
});
it('keeps separate budgets per workspace (one over-cap ws cannot starve another)', async () => {
const limiter = makeLimiter(1, 60_000, () => 1_000);
expect(await limiter.tryConsume('ws-a')).toBe(true);
@@ -93,6 +93,56 @@ describe('AiAgentRolesService guards', () => {
).rejects.toBeInstanceOf(BadRequestException);
expect(repo.update).not.toHaveBeenCalled();
});
it('instructions cleared to whitespace => BadRequest, repo.update NOT called', async () => {
const { service, repo } = makeService({ existing: makeRow() });
await expect(
service.update('ws-1', 'r1', {
instructions: ' ',
} as UpdateAgentRoleDto),
).rejects.toBeInstanceOf(BadRequestException);
expect(repo.update).not.toHaveBeenCalled();
});
it('concurrent soft-delete: row exists on the pre-update lookup but the re-fetch is undefined => BadRequest (not a TypeError)', async () => {
// findById returns the live row FIRST (pre-update guard passes), then the
// role is soft-deleted concurrently, so the POST-update re-fetch returns
// undefined. The service must surface a clean 400, never dereference
// undefined (which would throw a TypeError in toView).
const { service, repo } = makeService();
repo.findById
.mockResolvedValueOnce(makeRow())
.mockResolvedValueOnce(undefined);
await expect(
service.update('ws-1', 'r1', { name: 'X' } as UpdateAgentRoleDto),
).rejects.toBeInstanceOf(BadRequestException);
// The UPDATE ran (the row existed pre-update), but the re-fetch failed.
expect(repo.update).toHaveBeenCalled();
expect(repo.findById).toHaveBeenCalledTimes(2);
});
it('emoji/description tri-state: emoji:"" => null (clear), emoji omitted => undefined (unchanged), description:" " => null', async () => {
const { service, repo } = makeService({ existing: makeRow() });
// emoji explicitly emptied => clear to null; description whitespace => null.
await service.update('ws-1', 'r1', {
emoji: '',
description: ' ',
} as UpdateAgentRoleDto);
const patch1 = repo.update.mock.calls[0][2];
expect(patch1.emoji).toBeNull();
expect(patch1.description).toBeNull();
repo.update.mockClear();
// emoji omitted => unchanged (undefined passed through to the repo patch).
await service.update('ws-1', 'r1', {
name: 'Renamed',
} as UpdateAgentRoleDto);
const patch2 = repo.update.mock.calls[0][2];
expect(patch2.emoji).toBeUndefined();
expect(patch2.description).toBeUndefined();
});
});
describe('remove', () => {
@@ -136,6 +186,51 @@ describe('AiAgentRolesService guards', () => {
expect(repo.insert).not.toHaveBeenCalled();
});
it('modelConfig:{chatModel} only persists {chatModel} (no driver key)', async () => {
const { service, repo } = makeService();
await service.create('ws-1', 'u1', {
name: 'R',
instructions: 'do',
modelConfig: { chatModel: 'gpt-4o' },
} as CreateAgentRoleDto);
const values = repo.insert.mock.calls[0][0];
expect(values.modelConfig).toEqual({ chatModel: 'gpt-4o' });
expect('driver' in values.modelConfig).toBe(false);
});
it('modelConfig:{} (empty) normalizes to null', async () => {
const { service, repo } = makeService();
await service.create('ws-1', 'u1', {
name: 'R',
instructions: 'do',
modelConfig: {},
} as CreateAgentRoleDto);
expect(repo.insert.mock.calls[0][0].modelConfig).toBeNull();
});
it('modelConfig:{chatModel:" "} (whitespace-only) normalizes to null', async () => {
const { service, repo } = makeService();
await service.create('ws-1', 'u1', {
name: 'R',
instructions: 'do',
modelConfig: { chatModel: ' ' },
} as CreateAgentRoleDto);
expect(repo.insert.mock.calls[0][0].modelConfig).toBeNull();
});
it('modelConfig:{driver,chatModel} round-trips both fields (trimmed)', async () => {
const { service, repo } = makeService();
await service.create('ws-1', 'u1', {
name: 'R',
instructions: 'do',
modelConfig: { driver: 'gemini', chatModel: ' gemini-2.0-flash ' },
} as CreateAgentRoleDto);
expect(repo.insert.mock.calls[0][0].modelConfig).toEqual({
driver: 'gemini',
chatModel: 'gemini-2.0-flash',
});
});
it('duplicate name (Postgres 23505) => ConflictException (409), not 500', async () => {
const { service, repo } = makeService();
// The partial unique (workspace_id, name) index rejects the insert.
@@ -148,6 +243,28 @@ describe('AiAgentRolesService guards', () => {
).rejects.toBeInstanceOf(ConflictException);
});
it('duplicate name 409 message contains the TRIMMED submitted name', async () => {
const { service, repo } = makeService();
repo.insert.mockRejectedValueOnce({ code: '23505' });
await service
.create('ws-1', 'u1', {
name: ' Researcher ',
instructions: 'do',
} as CreateAgentRoleDto)
.then(
() => {
throw new Error('expected create to throw');
},
(err: unknown) => {
expect(err).toBeInstanceOf(ConflictException);
const message = (err as ConflictException).message;
// The trimmed name appears verbatim; the untrimmed padding does not.
expect(message).toContain('"Researcher"');
expect(message).not.toContain(' Researcher ');
},
);
});
it('non-unique-violation error is NOT swallowed (re-thrown as-is)', async () => {
const { service, repo } = makeService();
const other = Object.assign(new Error('boom'), { code: '23502' });
@@ -0,0 +1,30 @@
import { jsonbObject } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo';
/**
* Unit tests for jsonbObject: the repo helper that encodes a model_config object
* as a jsonb bind (or null when there is nothing to persist). It is the last
* line of defence before the column write, so the null-vs-bind decision is what
* matters here. We assert only null vs non-null because the non-null value is a
* kysely `sql` template fragment whose internal shape is an implementation
* detail of the SQL tag.
*/
describe('jsonbObject', () => {
it('returns null for null', () => {
expect(jsonbObject(null)).toBeNull();
});
it('returns null for undefined', () => {
expect(jsonbObject(undefined)).toBeNull();
});
it('returns null for an empty object (nothing to persist)', () => {
expect(jsonbObject({})).toBeNull();
});
it('returns a (non-null) jsonb bind for a non-empty object', () => {
const out = jsonbObject({ driver: 'gemini', chatModel: 'gemini-2.0-flash' });
// A real sql fragment is produced, never null/undefined.
expect(out).not.toBeNull();
expect(out).toBeDefined();
});
});
@@ -0,0 +1,135 @@
import { AiService } from '../../../integrations/ai/ai.service';
import { AiNotConfiguredException } from '../../../integrations/ai/ai-not-configured.exception';
import { roleModelOverride } from './role-model-config';
import type { AiAgentRole } from '@docmost/db/types/entity.types';
/**
* Contract test for the override SHAPE that travels from a role's persisted
* `model_config` (via roleModelOverride) into AiService.getChatModel.
*
* This is the seam between the two halves of the role-model feature:
* - roleModelOverride (pure) turns model_config into a ChatModelOverride;
* - getChatModel consumes that override to build the model (or to 503).
* Wiring the REAL roleModelOverride output into a unit-constructed AiService
* (with stubbed deps, no DB) pins that the two agree on the override contract:
* - a cross-driver override whose creds are absent => AiNotConfiguredException
* naming the role + driver;
* - a chatModel-only override keeps the workspace driver/creds (no creds
* lookup, no decrypt);
* - an ollama cross-driver override => 503 (no silent baseUrl reuse).
*/
describe('role override -> AiService.getChatModel contract', () => {
function role(modelConfig: unknown, name = 'Researcher'): AiAgentRole {
return { id: 'r1', name, modelConfig } as unknown as AiAgentRole;
}
function makeService(opts: {
workspaceDriver: string;
baseUrl?: string;
credsApiKeyEnc?: string;
}) {
const aiSettings = {
resolve: jest.fn().mockResolvedValue({
driver: opts.workspaceDriver,
chatModel: 'gpt-4o-mini',
apiKey: 'workspace-key',
baseUrl: opts.baseUrl,
}),
};
const aiProviderCredentialsRepo = {
find: jest
.fn()
.mockResolvedValue(
opts.credsApiKeyEnc ? { apiKeyEnc: opts.credsApiKeyEnc } : undefined,
),
};
const secretBox = { decryptSecret: jest.fn().mockReturnValue('decrypted') };
const service = new AiService(
aiSettings as never,
aiProviderCredentialsRepo as never,
secretBox as never,
);
return { service, aiSettings, aiProviderCredentialsRepo, secretBox };
}
it('cross-driver override with NO creds => 503 naming the role and the override driver', async () => {
const override = roleModelOverride(
role({ driver: 'gemini', chatModel: 'gemini-2.0-flash' }),
);
expect(override).toEqual({
driver: 'gemini',
chatModel: 'gemini-2.0-flash',
roleName: 'Researcher',
});
// Workspace is openai; the gemini override has no configured creds.
const { service, aiProviderCredentialsRepo } = makeService({
workspaceDriver: 'openai',
});
await service.getChatModel('ws-1', override).then(
() => {
throw new Error('expected getChatModel to throw');
},
(err: unknown) => {
expect(err).toBeInstanceOf(AiNotConfiguredException);
const message = (err as AiNotConfiguredException).message;
expect(message).toContain('gemini');
expect(message).toContain('Researcher');
},
);
expect(aiProviderCredentialsRepo.find).toHaveBeenCalledWith('ws-1', 'gemini');
});
it('chatModel-only override keeps the workspace driver/creds (no creds lookup, no decrypt)', async () => {
const override = roleModelOverride(role({ chatModel: 'gpt-4o' }));
// No driver in the override => the workspace driver/creds are reused.
expect(override).toEqual({
driver: undefined,
chatModel: 'gpt-4o',
roleName: 'Researcher',
});
const { service, aiProviderCredentialsRepo, secretBox } = makeService({
workspaceDriver: 'openai',
});
const model = await service.getChatModel('ws-1', override);
expect(model).toBeDefined();
expect(aiProviderCredentialsRepo.find).not.toHaveBeenCalled();
expect(secretBox.decryptSecret).not.toHaveBeenCalled();
});
it('ollama cross-driver override (workspace driver != ollama) => 503, no baseUrl reuse', async () => {
const override = roleModelOverride(
role({ driver: 'ollama', chatModel: 'llama3' }, 'Local'),
);
expect(override).toEqual({
driver: 'ollama',
chatModel: 'llama3',
roleName: 'Local',
});
const { service, aiProviderCredentialsRepo } = makeService({
workspaceDriver: 'openai',
baseUrl: 'https://openrouter.example/v1',
});
await service.getChatModel('ws-1', override).then(
() => {
throw new Error('expected getChatModel to throw');
},
(err: unknown) => {
expect(err).toBeInstanceOf(AiNotConfiguredException);
const message = (err as AiNotConfiguredException).message;
expect(message).toContain('ollama');
expect(message).toContain('openai');
expect(message).toContain('Local');
// The workspace gateway baseUrl must never be reused for ollama.
expect(message).not.toContain('openrouter.example');
},
);
// No creds lookup for ollama: we fail before reaching the creds branch.
expect(aiProviderCredentialsRepo.find).not.toHaveBeenCalled();
});
});
@@ -50,6 +50,11 @@ export class AiChatToolsService {
// agent write (REST + collab) records { actor:'agent', aiChatId } off a
// SIGNED claim — non-spoofable, never a client body field (§6.5/§6.6).
aiChatId: string,
// The page the user currently has open (from the request context), exposed
// to the model via getCurrentPage. Optional and last so existing callers
// keep compiling. Kept proxy-robust: the model can CALL for the current
// page instead of relying on it surviving in the system prompt text.
openedPage?: { id?: string; title?: string } | null,
): Promise<Record<string, Tool>> {
const apiUrl =
process.env.MCP_DOCMOST_API_URL ||
@@ -210,6 +215,23 @@ export class AiChatToolsService {
},
}),
getCurrentPage: tool({
description:
'Return the page the user is currently viewing — i.e. what "this page", ' +
'"the current page", or "here" refers to. Returns the page id and title, ' +
'or null if the user is not currently on a page. Call this first whenever ' +
'the user refers to the current page without giving an explicit id.',
inputSchema: z.object({}),
execute: async () => {
if (!openedPage?.id) {
return { page: null };
}
return {
page: { id: openedPage.id, title: openedPage.title ?? '' },
};
},
}),
getPage: tool({
description:
'Fetch a single page as Markdown by its page id. Returns the page ' +
@@ -0,0 +1,132 @@
import { PublicShareChatToolsService } from './public-share-chat-tools.service';
/**
* Mock-based integration tests for the anonymous public-share toolset built by
* forShare(). Constructed directly with hand-rolled collaborators (no Nest/DB):
* - listSharePages tree assembly (dedupe, single-page root fallback, fail-soft);
* - the blank-input guards on search / read.
*/
describe('PublicShareChatToolsService.forShare', () => {
type ToolExec = { execute: (args: unknown) => Promise<unknown> };
function makeService(over: {
getShareTree?: jest.Mock;
findById?: jest.Mock;
searchPage?: jest.Mock;
getShareForPage?: jest.Mock;
} = {}) {
const shareService = {
getShareTree: over.getShareTree ?? jest.fn(),
getShareForPage: over.getShareForPage ?? jest.fn(),
updatePublicAttachments: jest.fn(),
};
const searchService = { searchPage: over.searchPage ?? jest.fn() };
const pageRepo = { findById: over.findById ?? jest.fn() };
const pagePermissionRepo = { hasRestrictedAncestor: jest.fn() };
const svc = new PublicShareChatToolsService(
shareService as never,
searchService as never,
pageRepo as never,
pagePermissionRepo as never,
);
return { svc, shareService, searchService, pageRepo, pagePermissionRepo };
}
describe('listSharePages', () => {
it('includeSubPages tree: returns deduped, titled pages (root already in tree)', async () => {
// getShareTree returns the share root + descendants; the root IS in the
// tree, so no extra title lookup is needed and the tree is listed as-is.
const { svc, pageRepo } = makeService({
getShareTree: jest.fn().mockResolvedValue({
share: { pageId: 'root' },
pageTree: [
{ id: 'root', title: 'Home' },
{ id: 'child-1', title: 'Child One' },
{ id: 'child-2', title: 'Child Two' },
],
}),
});
const tools = svc.forShare('SHARE-A', 'ws-1');
const out = (await (tools.listSharePages as unknown as ToolExec).execute(
{},
)) as Array<{ id: string; title: string }>;
expect(out).toEqual([
{ id: 'root', title: 'Home' },
{ id: 'child-1', title: 'Child One' },
{ id: 'child-2', title: 'Child Two' },
]);
// The root was already in the tree => no fallback title lookup.
expect(pageRepo.findById).not.toHaveBeenCalled();
});
it('single-page share (empty tree): falls back to the root title and PREPENDS it', async () => {
const { svc, pageRepo } = makeService({
getShareTree: jest.fn().mockResolvedValue({
share: { pageId: 'root' },
pageTree: [], // includeSubPages=false => empty tree
}),
findById: jest.fn().mockResolvedValue({ id: 'root', title: 'Solo Page' }),
});
const tools = svc.forShare('SHARE-A', 'ws-1');
const out = (await (tools.listSharePages as unknown as ToolExec).execute(
{},
)) as Array<{ id: string; title: string }>;
expect(out).toEqual([{ id: 'root', title: 'Solo Page' }]);
expect(pageRepo.findById).toHaveBeenCalledWith('root');
});
it('de-duplicates pages by id, keeping the first (titled) occurrence', async () => {
const { svc } = makeService({
getShareTree: jest.fn().mockResolvedValue({
share: { pageId: 'root' },
pageTree: [
{ id: 'root', title: 'Home' },
{ id: 'dup', title: 'First' },
{ id: 'dup', title: 'Second (dropped)' },
{ id: 'root', title: 'Home again (dropped)' },
],
}),
});
const tools = svc.forShare('SHARE-A', 'ws-1');
const out = (await (tools.listSharePages as unknown as ToolExec).execute(
{},
)) as Array<{ id: string; title: string }>;
expect(out).toEqual([
{ id: 'root', title: 'Home' },
{ id: 'dup', title: 'First' },
]);
});
it('getShareTree throws => returns [] (fail-soft, never throws to the model)', async () => {
const { svc } = makeService({
getShareTree: jest.fn().mockRejectedValue(new Error('db down')),
});
const tools = svc.forShare('SHARE-A', 'ws-1');
await expect(
(tools.listSharePages as unknown as ToolExec).execute({}),
).resolves.toEqual([]);
});
});
describe('searchSharePages blank guard', () => {
it('blank query => [] WITHOUT calling searchService', async () => {
const { svc, searchService } = makeService({ searchPage: jest.fn() });
const tools = svc.forShare('SHARE-A', 'ws-1');
await expect(
(tools.searchSharePages as unknown as ToolExec).execute({ query: ' ' }),
).resolves.toEqual([]);
expect(searchService.searchPage).not.toHaveBeenCalled();
});
});
describe('getSharePage blank guard', () => {
it('blank pageId => throws "A pageId is required." WITHOUT calling getShareForPage', async () => {
const { svc, shareService } = makeService({ getShareForPage: jest.fn() });
const tools = svc.forShare('SHARE-A', 'ws-1');
await expect(
(tools.getSharePage as unknown as ToolExec).execute({ pageId: ' ' }),
).rejects.toThrow('A pageId is required.');
expect(shareService.getShareForPage).not.toHaveBeenCalled();
});
});
});
@@ -41,6 +41,20 @@ import {
} from '../../../integrations/audit/audit.service';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
// A valid bcrypt hash (cost 10, of an arbitrary throwaway string) used ONLY to
// equalize timing in verifyUserCredentials: when the email does not exist or
// the user is disabled, we still run ONE bcrypt comparison against this hash
// before throwing, so the missing/disabled path takes about the same time as
// the real-user wrong-password path. Without it, the "no bcrypt at all" branch
// returns measurably faster, leaking whether an email is registered (a user-
// enumeration timing oracle, now reachable via /mcp where throttling is only a
// spoofable in-memory limiter). This is never used as a real credential.
// The cost factor MUST match the production saltRounds (12 — see
// common/helpers/utils.ts hashPassword), otherwise the dummy compare runs
// faster than a real wrong-password compare and the timing oracle survives.
const DUMMY_PASSWORD_HASH =
'$2b$12$q/l637TULK3vU3Cmji0y8utpJS/UiftMi3Jdm4Tsi5EIv/0FE7WV.';
@Injectable()
export class AuthService {
constructor(
@@ -82,6 +96,12 @@ export class AuthService {
// recognises this exact message via isCredentialsFailure.
const errorMessage = CREDENTIALS_MISMATCH_MESSAGE;
if (!user || isUserDisabled(user)) {
// Constant-time intent: run ONE bcrypt comparison (against a dummy hash)
// even when the user is missing/disabled, so this path takes about the
// same time as the real-user wrong-password path below. This closes the
// user-enumeration timing oracle (registered vs. not). The result is
// intentionally discarded — we always throw the same credentials error.
await comparePasswordHash(loginDto.password, DUMMY_PASSWORD_HASH);
throw new UnauthorizedException(errorMessage);
}
@@ -100,4 +100,40 @@ describe('AuthService no-side-effect contract (item 4)', () => {
expect(verifyBody.includes(effect)).toBe(false);
}
});
// Item 4: user-enumeration timing-oracle fix. When the email is missing or the
// user is disabled, verifyUserCredentials must still run ONE bcrypt comparison
// (against a dummy hash) BEFORE throwing, so the missing/disabled path takes
// about the same time as the real-user wrong-password path. Asserted at the
// source level for the same reason as the rest of this file: AuthService cannot
// be imported under this jest config to spy on comparePasswordHash live.
describe('constant-time missing/disabled branch (item 4)', () => {
// Isolate the body of the `if (!user || isUserDisabled(user)) { ... }` guard.
const guardMatch = verifyBody.match(
/if \(!user \|\| isUserDisabled\(user\)\) \{([\s\S]*?)\n {4}\}/,
);
it('the missing/disabled guard runs a bcrypt compare before throwing', () => {
expect(guardMatch).not.toBeNull();
const guardBody = guardMatch![1];
// It performs the dummy bcrypt comparison...
expect(guardBody).toContain('comparePasswordHash');
// ...and only AFTER that throws the credentials error (compare precedes
// the throw STATEMENT — match `throw new`, not the word "throw" in a comment).
const compareIdx = guardBody.indexOf('comparePasswordHash');
const throwIdx = guardBody.indexOf('throw new');
expect(compareIdx).toBeGreaterThanOrEqual(0);
expect(throwIdx).toBeGreaterThan(compareIdx);
});
it('uses a module-level dummy hash constant (never a real credential)', () => {
// The dummy hash is a module-level constant referenced in the guard, not an
// inline literal recomputed per call.
expect(verifyBody).toContain('DUMMY_PASSWORD_HASH');
// Cost factor MUST be 12 to match production saltRounds, otherwise the
// dummy compare is faster than a real wrong-password compare and the
// timing oracle survives.
expect(source).toMatch(/const DUMMY_PASSWORD_HASH =\s*'\$2b\$12\$/);
});
});
});
@@ -0,0 +1,233 @@
import { UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';
import { CREDENTIALS_MISMATCH_MESSAGE } from '../auth.constants';
import { hashPassword } from '../../../common/helpers';
/**
* LIVE security contract for AuthService.verifyUserCredentials / login (M4
* item 5).
*
* The (now-fixed) jest config CAN import AuthService at the module level (the
* `^src/(.*)$` moduleNameMapper resolves the transitive `src/...` imports and the
* ts-jest transform loads the graph). AuthService cannot be `.compile()`-d via
* the Nest TestingModule (its full provider graph is not wired here), but it can
* be constructed directly with mocked collaborators which is exactly what we
* need to exercise the credential-check decision live.
*
* The load-bearing property: verifyUserCredentials (and login(), which reuses it)
* throws EXACTLY the shared CREDENTIALS_MISMATCH_MESSAGE for all three
* credentials-failure cases unknown email, disabled user, wrong password. The
* /mcp Basic brute-force limiter only counts a failure when it recognises THIS
* exact message (isCredentialsFailure in mcp-auth.helpers matches the same shared
* constant); a reword that diverged here would silently turn /mcp Basic into an
* unthrottled password-guessing oracle.
*/
const WORKSPACE_ID = 'ws-1';
// Build an AuthService with the dependencies verifyUserCredentials/login touch
// stubbed, and a userRepo whose findByEmail is overridable per test. Only the
// collaborators actually reached on these paths need real behaviour; the rest
// are inert mocks (constructor wiring only).
function makeAuthService(over: {
findByEmail?: jest.Mock;
} = {}): {
service: AuthService;
userRepo: { findByEmail: jest.Mock; updateLastLogin: jest.Mock };
sessionService: { createSessionAndToken: jest.Mock };
auditService: { log: jest.Mock };
} {
const userRepo = {
findByEmail: over.findByEmail ?? jest.fn(),
updateLastLogin: jest.fn().mockResolvedValue(undefined),
};
const sessionService = {
createSessionAndToken: jest.fn().mockResolvedValue('issued-token'),
};
const auditService = { log: jest.fn() };
// environmentService: isCloud() false (so throwIfEmailNotVerified does not
// require verification) + a stable app secret.
const environmentService = {
isCloud: jest.fn().mockReturnValue(false),
getAppSecret: jest.fn().mockReturnValue('test-secret'),
};
// Constructor signature (auth.service.ts): signupService, tokenService,
// sessionService, userSessionRepo, userRepo, userTokenRepo, mailService,
// domainService, environmentService, db, auditService.
const service = new (AuthService as unknown as new (...args: unknown[]) => AuthService)(
{}, // signupService
{}, // tokenService
sessionService, // sessionService
{}, // userSessionRepo
userRepo, // userRepo
{}, // userTokenRepo
{}, // mailService
{}, // domainService
environmentService, // environmentService
{}, // db
auditService, // auditService
);
return { service, userRepo, sessionService, auditService };
}
describe('AuthService.verifyUserCredentials (live credentials-mismatch contract)', () => {
it('UNKNOWN email -> throws exactly CREDENTIALS_MISMATCH_MESSAGE', async () => {
const { service } = makeAuthService({
findByEmail: jest.fn().mockResolvedValue(undefined),
});
await expect(
service.verifyUserCredentials(
{ email: 'nobody@example.com', password: 'whatever' },
WORKSPACE_ID,
),
).rejects.toMatchObject({ message: CREDENTIALS_MISMATCH_MESSAGE });
await expect(
service.verifyUserCredentials(
{ email: 'nobody@example.com', password: 'whatever' },
WORKSPACE_ID,
),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('DISABLED user -> throws exactly CREDENTIALS_MISMATCH_MESSAGE (no password oracle)', async () => {
// A deactivated user must be indistinguishable from a wrong password: same
// message, before any password comparison.
const passwordHash = await hashPassword('correct-horse');
const disabledUser = {
id: 'u-1',
email: 'disabled@example.com',
password: passwordHash,
deactivatedAt: new Date(),
deletedAt: null,
emailVerifiedAt: new Date(),
};
const { service } = makeAuthService({
findByEmail: jest.fn().mockResolvedValue(disabledUser),
});
await expect(
service.verifyUserCredentials(
{ email: 'disabled@example.com', password: 'correct-horse' },
WORKSPACE_ID,
),
).rejects.toMatchObject({ message: CREDENTIALS_MISMATCH_MESSAGE });
});
it('WRONG password -> throws exactly CREDENTIALS_MISMATCH_MESSAGE', async () => {
const passwordHash = await hashPassword('correct-horse');
const user = {
id: 'u-1',
email: 'user@example.com',
password: passwordHash,
deactivatedAt: null,
deletedAt: null,
emailVerifiedAt: new Date(),
};
const { service } = makeAuthService({
findByEmail: jest.fn().mockResolvedValue(user),
});
await expect(
service.verifyUserCredentials(
{ email: 'user@example.com', password: 'wrong-password' },
WORKSPACE_ID,
),
).rejects.toMatchObject({ message: CREDENTIALS_MISMATCH_MESSAGE });
});
it('CORRECT credentials -> resolves the matched user (no side effects here)', async () => {
const passwordHash = await hashPassword('correct-horse');
const user = {
id: 'u-1',
email: 'user@example.com',
password: passwordHash,
deactivatedAt: null,
deletedAt: null,
emailVerifiedAt: new Date(),
};
const { service, sessionService, auditService, userRepo } =
makeAuthService({ findByEmail: jest.fn().mockResolvedValue(user) });
const result = await service.verifyUserCredentials(
{ email: 'user@example.com', password: 'correct-horse' },
WORKSPACE_ID,
);
expect(result).toBe(user);
// verifyUserCredentials is non-side-effecting: no session/audit/lastLogin.
expect(sessionService.createSessionAndToken).not.toHaveBeenCalled();
expect(auditService.log).not.toHaveBeenCalled();
expect(userRepo.updateLastLogin).not.toHaveBeenCalled();
});
});
describe('AuthService.login (live credentials-mismatch contract via verifyUserCredentials)', () => {
it('UNKNOWN email -> login throws exactly CREDENTIALS_MISMATCH_MESSAGE, mints NO session', async () => {
const { service, sessionService } = makeAuthService({
findByEmail: jest.fn().mockResolvedValue(undefined),
});
await expect(
service.login(
{ email: 'nobody@example.com', password: 'whatever' },
WORKSPACE_ID,
),
).rejects.toMatchObject({ message: CREDENTIALS_MISMATCH_MESSAGE });
expect(sessionService.createSessionAndToken).not.toHaveBeenCalled();
});
it('WRONG password -> login throws exactly CREDENTIALS_MISMATCH_MESSAGE', async () => {
const passwordHash = await hashPassword('correct-horse');
const user = {
id: 'u-1',
email: 'user@example.com',
password: passwordHash,
deactivatedAt: null,
deletedAt: null,
emailVerifiedAt: new Date(),
};
const { service } = makeAuthService({
findByEmail: jest.fn().mockResolvedValue(user),
});
await expect(
service.login(
{ email: 'user@example.com', password: 'wrong-password' },
WORKSPACE_ID,
),
).rejects.toMatchObject({ message: CREDENTIALS_MISMATCH_MESSAGE });
});
it('CORRECT credentials -> login mints the session (the side-effecting path)', async () => {
const passwordHash = await hashPassword('correct-horse');
const user = {
id: 'u-1',
email: 'user@example.com',
password: passwordHash,
deactivatedAt: null,
deletedAt: null,
emailVerifiedAt: new Date(),
};
const { service, sessionService, auditService, userRepo } =
makeAuthService({ findByEmail: jest.fn().mockResolvedValue(user) });
await expect(
service.login(
{ email: 'user@example.com', password: 'correct-horse' },
WORKSPACE_ID,
),
).resolves.toBe('issued-token');
// login() reuses verifyUserCredentials but DOES run the three side effects.
expect(userRepo.updateLastLogin).toHaveBeenCalledWith('u-1', WORKSPACE_ID);
expect(auditService.log).toHaveBeenCalled();
expect(sessionService.createSessionAndToken).toHaveBeenCalledWith(user);
});
it('the message login throws is the SAME shared constant the /mcp limiter matches', () => {
// Cross-file coupling lock: the constant is the single source of truth shared
// by AuthService and mcp-auth.helpers.isCredentialsFailure.
expect(CREDENTIALS_MISMATCH_MESSAGE).toBe('Email or password does not match');
});
});
@@ -62,6 +62,7 @@ import { markdownToHtml } from '@docmost/editor-ext';
import { WatcherService } from '../../watcher/watcher.service';
import { sql } from 'kysely';
import { TransclusionService } from '../transclusion/transclusion.service';
import { remapPageEmbedSourceId } from '../transclusion/utils/transclusion-prosemirror.util';
import { AuthProvenanceData } from '../../../common/decorators/auth-provenance.decorator';
@Injectable()
@@ -713,12 +714,11 @@ export class PageService {
// source page is also part of the copied set, point at its new copy;
// otherwise leave it pointing at the original (live embed of original).
if (node.type.name === 'pageEmbed') {
const sourcePageId = node.attrs.sourcePageId;
if (sourcePageId && pageMap.has(sourcePageId)) {
const mappedPage = pageMap.get(sourcePageId);
//@ts-ignore
node.attrs.sourcePageId = mappedPage.newPageId;
}
// @ts-expect-error ProseMirror Attrs is read-only typed; intentional remap to the duplicated copy
node.attrs.sourcePageId = remapPageEmbedSourceId(
node.attrs.sourcePageId,
(id) => pageMap.get(id)?.newPageId,
);
}
// Update internal page links in link marks
@@ -67,6 +67,12 @@ export class PageTemplateController {
throw new NotFoundException('Page not found');
}
if (page.workspaceId !== user.workspaceId) {
// Defense-in-depth: never act on a page outside the caller's workspace.
// Use NotFound (not Forbidden) to avoid leaking cross-workspace existence.
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanEdit(page, user);
const isTemplate =
@@ -0,0 +1,200 @@
import {
remapPageEmbedSourceId,
remapPageEmbedSourceIds,
} from '../utils/transclusion-prosemirror.util';
/**
* Unit tests for the `pageEmbed` remap used by `PageService.duplicatePage`:
*
* - source page within the copied set -> rewrite to the COPY's new id
* - source page NOT in the copied set -> keep the ORIGINAL id (live embed)
*
* `remapPageEmbedSourceId` is the per-node decision the production
* `duplicatePage` callback now calls directly, so these tests guard the real
* path rather than a parallel copy. `remapPageEmbedSourceIds` is the JSON
* walker that delegates to the same helper; its tests exercise the shared
* decision transitively across nested ProseMirror containers.
*/
describe('remapPageEmbedSourceId (shared per-node decision used by duplicatePage)', () => {
it('returns the new copy id when the source IS in the copied set', () => {
const idMap = new Map([['old-src', 'new-copy']]);
const out = remapPageEmbedSourceId('old-src', (id) => idMap.get(id));
expect(out).toBe('new-copy');
});
it('returns the original id when the source is NOT in the copied set', () => {
const idMap = new Map([['old-src', 'new-copy']]);
const out = remapPageEmbedSourceId('external', (id) => idMap.get(id));
expect(out).toBe('external');
});
it('returns the original id when resolveNewId yields undefined', () => {
const out = remapPageEmbedSourceId('some-id', () => undefined);
expect(out).toBe('some-id');
});
it('leaves a null source unchanged without consulting the resolver', () => {
const resolve = jest.fn(() => 'should-not-be-used');
const out = remapPageEmbedSourceId(null, resolve);
expect(out).toBeNull();
expect(resolve).not.toHaveBeenCalled();
});
it('leaves an undefined source unchanged without consulting the resolver', () => {
const resolve = jest.fn(() => 'should-not-be-used');
const out = remapPageEmbedSourceId(undefined, resolve);
expect(out).toBeUndefined();
expect(resolve).not.toHaveBeenCalled();
});
});
describe('remapPageEmbedSourceIds (duplicatePage pageEmbed remap)', () => {
const docWithEmbeds = (ids: string[]) => ({
type: 'doc',
content: ids.map((id) => ({
type: 'pageEmbed',
attrs: { sourcePageId: id },
})),
});
it('remaps a source that IS within the copied set to its new copy id', () => {
const doc = docWithEmbeds(['old-src']);
const idMap = new Map([['old-src', 'new-copy']]);
const out = remapPageEmbedSourceIds(doc, idMap);
expect(out.content[0].attrs.sourcePageId).toBe('new-copy');
});
it('keeps the original id for a source NOT in the copied set', () => {
const doc = docWithEmbeds(['external']);
const idMap = new Map([['old-src', 'new-copy']]); // does not contain "external"
const out = remapPageEmbedSourceIds(doc, idMap);
expect(out.content[0].attrs.sourcePageId).toBe('external');
});
it('handles a mixed doc: in-set remapped, out-of-set preserved', () => {
const doc = docWithEmbeds(['in-set', 'external']);
const idMap = new Map([['in-set', 'in-set-copy']]);
const out = remapPageEmbedSourceIds(doc, idMap);
expect(out.content.map((n: any) => n.attrs.sourcePageId)).toEqual([
'in-set-copy',
'external',
]);
});
it('remaps pageEmbeds nested inside columns', () => {
const doc = {
type: 'doc',
content: [
{
type: 'columnList',
content: [
{
type: 'column',
content: [
{ type: 'pageEmbed', attrs: { sourcePageId: 'nested-in' } },
],
},
{
type: 'column',
content: [
{ type: 'pageEmbed', attrs: { sourcePageId: 'nested-out' } },
],
},
],
},
],
};
const idMap = new Map([['nested-in', 'nested-in-copy']]);
const out = remapPageEmbedSourceIds(doc, idMap) as any;
const col0 = out.content[0].content[0].content[0];
const col1 = out.content[0].content[1].content[0];
expect(col0.attrs.sourcePageId).toBe('nested-in-copy');
expect(col1.attrs.sourcePageId).toBe('nested-out');
});
it('remaps pageEmbeds nested inside a callout', () => {
const doc = {
type: 'doc',
content: [
{
type: 'callout',
content: [
{ type: 'pageEmbed', attrs: { sourcePageId: 'in-callout' } },
],
},
],
};
const idMap = new Map([['in-callout', 'in-callout-copy']]);
const out = remapPageEmbedSourceIds(doc, idMap) as any;
expect(out.content[0].content[0].attrs.sourcePageId).toBe(
'in-callout-copy',
);
});
it('does not descend into a transclusionSource (schema-isolated)', () => {
const doc = {
type: 'doc',
content: [
{
type: 'transclusionSource',
attrs: { id: 'src' },
content: [
{ type: 'pageEmbed', attrs: { sourcePageId: 'hidden' } },
],
},
],
};
const idMap = new Map([['hidden', 'should-not-apply']]);
const out = remapPageEmbedSourceIds(doc, idMap) as any;
// The embed inside a source must be left untouched.
expect(out.content[0].content[0].attrs.sourcePageId).toBe('hidden');
});
it('leaves embeds missing a sourcePageId untouched', () => {
const doc = {
type: 'doc',
content: [
{ type: 'pageEmbed', attrs: {} },
{ type: 'pageEmbed', attrs: { sourcePageId: '' } },
],
};
const idMap = new Map([['', 'x']]);
const out = remapPageEmbedSourceIds(doc, idMap) as any;
expect(out.content[0].attrs.sourcePageId).toBeUndefined();
expect(out.content[1].attrs.sourcePageId).toBe('');
});
it('returns the doc unchanged when idMap is empty', () => {
const doc = docWithEmbeds(['a', 'b']);
const out = remapPageEmbedSourceIds(doc, new Map());
expect(out.content.map((n: any) => n.attrs.sourcePageId)).toEqual([
'a',
'b',
]);
});
});
@@ -80,6 +80,67 @@ describe('collectPageEmbedsFromPmJson', () => {
};
expect(collectPageEmbedsFromPmJson(doc)).toEqual([]);
});
it('ignores a pageEmbed whose sourcePageId is not a string', () => {
const doc = {
type: 'doc',
content: [
{ type: 'pageEmbed', attrs: { sourcePageId: 123 as any } },
{ type: 'pageEmbed', attrs: { sourcePageId: null as any } },
{ type: 'pageEmbed', attrs: { sourcePageId: { nested: true } as any } },
{ type: 'pageEmbed', attrs: { sourcePageId: ['arr'] as any } },
// a valid one mixed in proves only the bad ones are dropped
{ type: 'pageEmbed', attrs: { sourcePageId: 'good' } },
],
};
expect(collectPageEmbedsFromPmJson(doc)).toEqual([
{ sourcePageId: 'good' },
]);
});
it('collects a pageEmbed nested under multiple block containers', () => {
const doc = {
type: 'doc',
content: [
{
type: 'callout',
content: [
{
type: 'columns',
content: [
{
type: 'column',
content: [
{
type: 'details',
content: [
{
type: 'pageEmbed',
attrs: { sourcePageId: 'deep' },
},
],
},
],
},
],
},
],
},
],
};
expect(collectPageEmbedsFromPmJson(doc)).toEqual([{ sourcePageId: 'deep' }]);
});
it('terminates (does not silently hang) on a self-referencing/cyclic object', () => {
// FINDING: there is NO explicit cycle guard. A hand-built cyclic JS object
// (which cannot arise from JSON parsing — the real input path) makes the
// recursive walk overflow the stack and throw a RangeError. It TERMINATES
// with a controlled error rather than recursing unboundedly forever, and a
// non-cyclic (JSON-shaped) document is never affected.
const node: any = { type: 'doc', content: [] };
node.content.push(node); // content array references its own parent node
expect(() => collectPageEmbedsFromPmJson(node)).toThrow(RangeError);
});
});
describe('pageEmbed HTML <-> JSON round-trip (server schema)', () => {
@@ -68,6 +68,7 @@ describe('TransclusionService — template access core (real filter)', () => {
{} as any, // attachmentRepo
{} as any, // storageService
{} as any, // pageAccessService
{} as any, // workspaceRepo
);
return { service, db, pageRepo, spaceMemberRepo, pagePermissionRepo };
@@ -172,8 +173,13 @@ describe('TransclusionService — template access core (real filter)', () => {
expect((items[2] as any).status).toBe('no_access'); // not space-visible
});
it('honours the DTO-level ≤50 cap by deduping ids passed to the filter', async () => {
// The DTO enforces ArrayMaxSize(50); the service dedupes before filtering.
it('dedupes source ids before passing them to the access filter', async () => {
// NOTE: this test only covers DEDUP, not the ≤50 cap. The ArrayMaxSize(50)
// limit is enforced by the DTO (validation layer), so it is never engaged in
// the service under unit test — the service receives an already-validated
// array and merely dedupes it. Renamed from the old "honours ≤50 cap" title,
// which misleadingly implied the cap was exercised here. A real cap test would
// belong in a controller/DTO-validation spec, not in this service unit test.
const ids = ['a', 'a', 'b'];
const { service, db } = makeService({
spaceVisibleRows: [],
@@ -187,8 +193,103 @@ describe('TransclusionService — template access core (real filter)', () => {
});
});
describe('TransclusionService.filterViewerAccessiblePageIds — AND ordering (content-leak control)', () => {
function makeDb(executeRows: Array<{ id: string }>) {
const builder: any = {};
builder.selectFrom = jest.fn(() => builder);
builder.select = jest.fn(() => builder);
builder.where = jest.fn(() => builder);
builder.execute = jest.fn(async () => executeRows);
return builder;
}
function makeService(opts: {
spaceVisibleRows: Array<{ id: string }>;
permissionAccessibleIds: string[];
}) {
const db = makeDb(opts.spaceVisibleRows);
const spaceMemberRepo = {
getUserSpaceIdsQuery: jest.fn(() => ({ __subquery: true })),
};
const filterAccessiblePageIds = jest
.fn()
.mockResolvedValue(opts.permissionAccessibleIds);
const pagePermissionRepo = { filterAccessiblePageIds };
const service = new TransclusionService(
db as any, // db
{} as any, // pageTransclusionsRepo
{} as any, // pageTransclusionReferencesRepo
{} as any, // pageTemplateReferencesRepo
{} as any, // pageRepo
pagePermissionRepo as any,
spaceMemberRepo as any,
{} as any, // attachmentRepo
{} as any, // storageService
{} as any, // pageAccessService
{} as any, // workspaceRepo
);
return { service, filterAccessiblePageIds };
}
it('space-visible AND permission-accessible → returned', async () => {
const { service } = makeService({
spaceVisibleRows: [{ id: 'p1' }],
permissionAccessibleIds: ['p1'],
});
const out = await service.filterViewerAccessiblePageIds(
['p1'],
'u1',
'w1',
);
expect(out).toEqual(['p1']);
});
it('space-visible but permission-rejected → dropped', async () => {
const { service, filterAccessiblePageIds } = makeService({
spaceVisibleRows: [{ id: 'p1' }],
permissionAccessibleIds: [],
});
const out = await service.filterViewerAccessiblePageIds(
['p1'],
'u1',
'w1',
);
expect(out).toEqual([]);
// The permission filter only ever sees the space-visible candidate.
expect(filterAccessiblePageIds).toHaveBeenCalledWith({
pageIds: ['p1'],
userId: 'u1',
});
});
it('NOT space-visible but permission-accessible → STILL dropped (AND-ordering enforced)', async () => {
// The page would pass page-level permission filtering, but it is not visible
// at the space level (e.g. a private space the viewer is not a member of).
// The space-visibility gate runs FIRST and short-circuits, so the page-level
// permission filter is never even consulted — preventing a private-space
// content leak via an unrestricted source page.
const { service, filterAccessiblePageIds } = makeService({
spaceVisibleRows: [],
permissionAccessibleIds: ['private-but-permitted'],
});
const out = await service.filterViewerAccessiblePageIds(
['private-but-permitted'],
'u1',
'w1',
);
expect(out).toEqual([]);
expect(filterAccessiblePageIds).not.toHaveBeenCalled();
});
});
describe('TransclusionService.syncPageTemplateReferences — workspace scoping', () => {
function makeService(opts: { inWorkspaceIds: string[] }) {
function makeService(opts: {
inWorkspaceIds: string[];
/** existing rows already persisted for the reference page */
existingSourceIds?: string[];
}) {
// db stub: the in-workspace existence query returns only allowed ids.
const builder: any = {};
builder.selectFrom = jest.fn(() => builder);
@@ -201,25 +302,37 @@ describe('TransclusionService.syncPageTemplateReferences — workspace scoping',
const insertMany = jest.fn().mockResolvedValue(undefined);
const deleteByReferenceAndSources = jest.fn().mockResolvedValue(undefined);
const pageTemplateReferencesRepo = {
findByReferencePageId: jest.fn().mockResolvedValue([]),
findByReferencePageId: jest
.fn()
.mockResolvedValue(
(opts.existingSourceIds ?? []).map((sourcePageId) => ({
sourcePageId,
})),
),
insertMany,
deleteByReferenceAndSources,
};
const service = new TransclusionService(
builder as any,
{} as any,
{} as any,
builder as any, // db
{} as any, // pageTransclusionsRepo
{} as any, // pageTransclusionReferencesRepo
pageTemplateReferencesRepo as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any, // pageRepo
{} as any, // pagePermissionRepo
{} as any, // spaceMemberRepo
{} as any, // attachmentRepo
{} as any, // storageService
{} as any, // pageAccessService
{} as any, // workspaceRepo
);
return { service, insertMany, pageTemplateReferencesRepo };
return {
service,
insertMany,
deleteByReferenceAndSources,
pageTemplateReferencesRepo,
};
}
function docWithEmbeds(sourceIds: string[]) {
@@ -264,4 +377,152 @@ describe('TransclusionService.syncPageTemplateReferences — workspace scoping',
expect(result.inserted).toBe(0);
expect(insertMany).not.toHaveBeenCalled();
});
it('DELETE branch: an existing in-workspace ref removed from the doc is deleted', async () => {
// 'gone' was referenced before but is no longer in the doc; 'stay' remains.
const { service, insertMany, deleteByReferenceAndSources } = makeService({
inWorkspaceIds: ['stay'],
existingSourceIds: ['stay', 'gone'],
});
const result = await service.syncPageTemplateReferences(
'host',
'w1',
docWithEmbeds(['stay']),
);
expect(result.deleted).toBe(1);
expect(result.inserted).toBe(0); // 'stay' already existed
expect(insertMany).not.toHaveBeenCalled();
expect(deleteByReferenceAndSources).toHaveBeenCalledTimes(1);
expect(deleteByReferenceAndSources).toHaveBeenCalledWith(
'host',
'w1', // workspace-scoped delete (#36 defense-in-depth)
['gone'],
undefined, // no trx supplied
);
});
it('does NOT delete a stale ref whose source is now cross-workspace if it is also still embedded', async () => {
// Edge: 'x' is still embedded in the doc but no longer in-workspace. It is
// not in desiredIds (filtered out) AND it exists → it should be deleted, not
// kept, because the reference graph must drop the cross-workspace edge.
const { service, deleteByReferenceAndSources } = makeService({
inWorkspaceIds: [], // 'x' no longer in-workspace
existingSourceIds: ['x'],
});
const result = await service.syncPageTemplateReferences(
'host',
'w1',
docWithEmbeds(['x']),
);
expect(result.deleted).toBe(1);
expect(deleteByReferenceAndSources).toHaveBeenCalledWith(
'host',
'w1', // workspace-scoped delete (#36 defense-in-depth)
['x'],
undefined,
);
});
});
describe('TransclusionService.insertTemplateReferencesForPages — per-workspace existence validation', () => {
/**
* Smart db stub: each existence query is `.where('id','in', ids)` +
* `.where('workspaceId','=', wsId)`; `.execute()` returns only the ids that
* `validByWorkspace[wsId]` declares in-workspace. The builder snapshots the
* last `id`-in list and `workspaceId` value per chain (selectFrom resets).
*/
function makeDb(validByWorkspace: Record<string, string[]>) {
const builder: any = {};
let curIds: string[] = [];
let curWs: string | undefined;
builder.selectFrom = jest.fn(() => {
curIds = [];
curWs = undefined;
return builder;
});
builder.select = jest.fn(() => builder);
builder.where = jest.fn((col: string, op: string, val: any) => {
if (col === 'id' && op === 'in') curIds = val;
if (col === 'workspaceId' && op === '=') curWs = val;
return builder;
});
builder.execute = jest.fn(async () => {
const valid = new Set(validByWorkspace[curWs ?? ''] ?? []);
return curIds.filter((id) => valid.has(id)).map((id) => ({ id }));
});
return builder;
}
function makeService(validByWorkspace: Record<string, string[]>) {
const insertMany = jest.fn().mockResolvedValue(undefined);
const pageTemplateReferencesRepo = { insertMany };
const service = new TransclusionService(
makeDb(validByWorkspace) as any, // db
{} as any, // pageTransclusionsRepo
{} as any, // pageTransclusionReferencesRepo
pageTemplateReferencesRepo as any,
{} as any, // pageRepo
{} as any, // pagePermissionRepo
{} as any, // spaceMemberRepo
{} as any, // attachmentRepo
{} as any, // storageService
{} as any, // pageAccessService
{} as any, // workspaceRepo
);
return { service, insertMany };
}
const embedDoc = (ids: string[]) => ({
type: 'doc',
content: ids.map((id) => ({
type: 'pageEmbed',
attrs: { sourcePageId: id },
})),
});
it('validates each workspace separately: a source in-ws for A but cross-ws for B inserts only the valid delta', async () => {
// 'shared' is in-workspace for wA but NOT for wB. Page A embeds 'shared'
// (valid → inserted). Page B embeds 'shared' (cross-ws for wB → dropped).
const { service, insertMany } = makeService({
wA: ['shared'],
wB: [], // 'shared' is not a page in wB
});
const result = await service.insertTemplateReferencesForPages([
{ id: 'pageA', workspaceId: 'wA', content: embedDoc(['shared']) },
{ id: 'pageB', workspaceId: 'wB', content: embedDoc(['shared']) },
]);
expect(result.inserted).toBe(1);
expect(insertMany).toHaveBeenCalledTimes(1);
expect(insertMany.mock.calls[0][0]).toEqual([
{ workspaceId: 'wA', referencePageId: 'pageA', sourcePageId: 'shared' },
]);
});
it('inserts the in-workspace deltas for both pages when each is valid in its own workspace', async () => {
const { service, insertMany } = makeService({
wA: ['a-src'],
wB: ['b-src'],
});
const result = await service.insertTemplateReferencesForPages([
{ id: 'pageA', workspaceId: 'wA', content: embedDoc(['a-src']) },
{ id: 'pageB', workspaceId: 'wB', content: embedDoc(['b-src']) },
]);
expect(result.inserted).toBe(2);
const rows = insertMany.mock.calls[0][0];
expect(rows).toEqual(
expect.arrayContaining([
{ workspaceId: 'wA', referencePageId: 'pageA', sourcePageId: 'a-src' },
{ workspaceId: 'wB', referencePageId: 'pageB', sourcePageId: 'b-src' },
]),
);
expect(rows).toHaveLength(2);
});
});
@@ -0,0 +1,183 @@
import { TransclusionService } from '../transclusion.service';
/**
* Edge-case + anti-leak coverage for `lookupTemplate` that the existing
* `page-template-lookup.spec.ts` (stubbed filter) and `page-template-access.spec.ts`
* (real filter, happy paths) do not exercise:
*
* 1. SECURITY anti-leak: when comment-mark stripping THROWS, the item must come
* back as `not_found` and NEVER carry raw content (the source's comment marks
* could otherwise leak to a viewer). See the `catch` branch in `lookupTemplate`.
* 2. A soft-deleted source page resolved through the REAL
* `filterViewerAccessiblePageIds` (space-visibility query filters `deletedAt`),
* asserting it maps to `not_found`/`no_access` rather than content.
*/
describe('TransclusionService.lookupTemplate — anti-leak catch branch', () => {
const now = new Date('2026-06-20T00:00:00.000Z');
function makeService(opts: {
accessibleIds: string[];
pages: Array<{
id: string;
slugId?: string;
title: string | null;
icon: string | null;
content: unknown;
updatedAt: Date;
}>;
}) {
const pageRepo = {
findManyByIds: jest.fn().mockResolvedValue(opts.pages),
};
const service = new TransclusionService(
{} as any, // db
{} as any, // pageTransclusionsRepo
{} as any, // pageTransclusionReferencesRepo
{} as any, // pageTemplateReferencesRepo
pageRepo as any,
{} as any, // pagePermissionRepo
{} as any, // spaceMemberRepo
{} as any, // attachmentRepo
{} as any, // storageService
{} as any, // pageAccessService
{} as any, // workspaceRepo
);
// Stub the access decision; we are testing the content-prep stage, not access.
jest
.spyOn(service, 'filterViewerAccessiblePageIds')
.mockResolvedValue(opts.accessibleIds);
return { service, pageRepo };
}
it('returns not_found (NOT raw content) when comment-mark stripping throws', async () => {
// An accessible, present page whose stored content is structurally invalid PM:
// a `text` node without a `text` field. `jsonToNode` (called inside the try
// block) throws "Invalid text node in JSON" on this, which exercises the
// service's catch -> not_found anti-leak guard. This uses a REAL malformed
// input (no module mocking) so the test stays faithful to production behaviour.
const malformedContent = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
// Missing `text` — Node.fromJSON rejects this and jsonToNode rethrows.
type: 'text',
marks: [{ type: 'comment', attrs: { commentId: 'leak-me' } }],
},
],
},
],
};
const { service } = makeService({
accessibleIds: ['p1'],
pages: [
{
id: 'p1',
slugId: 's1',
title: 'Secret',
icon: '📄',
content: malformedContent,
updatedAt: now,
},
],
});
// Silence the expected error log so the suite output stays clean.
jest.spyOn((service as any).logger, 'error').mockImplementation(() => {});
const { items } = await service.lookupTemplate(['p1'], 'u1', 'w1');
expect(items).toHaveLength(1);
const item = items[0] as any;
// Must degrade to not_found...
expect(item.status).toBe('not_found');
expect(item.sourcePageId).toBe('p1');
// ...and must NOT leak ANY content/metadata of the source page.
expect(item).not.toHaveProperty('content');
expect(item).not.toHaveProperty('title');
expect(item).not.toHaveProperty('icon');
expect(item).not.toHaveProperty('slugId');
expect(item).not.toHaveProperty('sourceUpdatedAt');
// Hard guarantee: the would-be-leaked comment mark appears nowhere in output.
expect(JSON.stringify(item)).not.toContain('leak-me');
expect(JSON.stringify(item)).not.toContain('comment');
});
});
describe('TransclusionService.lookupTemplate — soft-deleted source via real filter', () => {
const now = new Date('2026-06-20T00:00:00.000Z');
/**
* Chainable kysely `db` stub mirroring `page-template-access.spec.ts`. The
* space-visibility query in `filterViewerAccessiblePageIds` filters
* `where('deletedAt','is',null)`; a soft-deleted page is therefore absent from
* the rows we resolve here, so the REAL filter is what drops it.
*/
function makeDb(executeRows: Array<{ id: string }>) {
const builder: any = {};
builder.selectFrom = jest.fn(() => builder);
builder.select = jest.fn(() => builder);
builder.where = jest.fn(() => builder);
builder.execute = jest.fn(async () => executeRows);
return builder;
}
it('resolves a soft-deleted source to not_found/no_access through the REAL filter', async () => {
// The page IS soft-deleted, so the space-visibility query returns no rows for
// it (deletedAt filter). We let the real filter run end-to-end.
const db = makeDb([]); // soft-deleted -> excluded by the deletedAt='is null' clause
const spaceMemberRepo = {
getUserSpaceIdsQuery: jest.fn(() => ({ __subquery: true })),
};
const pagePermissionRepo = {
filterAccessiblePageIds: jest.fn().mockResolvedValue([]),
};
const pageRepo = {
// Even if it were queried, the page is gone; assert via the filter instead.
findManyByIds: jest.fn().mockResolvedValue([]),
};
const service = new TransclusionService(
db as any,
{} as any,
{} as any,
{} as any,
pageRepo as any,
pagePermissionRepo as any,
spaceMemberRepo as any,
{} as any,
{} as any,
{} as any,
{} as any,
);
const { items } = await service.lookupTemplate(['deleted-src'], 'u1', 'w1');
// Soft-deleted source must never resolve to content.
expect(items).toEqual([
{ sourcePageId: 'deleted-src', status: 'no_access' },
]);
const item = items[0] as any;
expect(item).not.toHaveProperty('content');
// The real filter short-circuited before page-permission filtering because
// the deletedAt-filtered space-visibility query returned nothing.
expect(pagePermissionRepo.filterAccessiblePageIds).not.toHaveBeenCalled();
// And the verb on the db builder included a deletedAt 'is null' guard, proving
// the real path (not a stub) excluded the soft-deleted page.
const deletedAtCall = db.where.mock.calls.find(
(c: any[]) => c[0] === 'deletedAt',
);
expect(deletedAtCall).toEqual(['deletedAt', 'is', null]);
});
});
@@ -1,4 +1,5 @@
import { TransclusionService } from '../transclusion.service';
import * as collabUtil from '../../../../collaboration/collaboration.util';
/**
* Exercises the pure access/mapping logic of `lookupTemplate`:
@@ -34,6 +35,7 @@ describe('TransclusionService.lookupTemplate (access mapping)', () => {
{} as any, // attachmentRepo
{} as any, // storageService
{} as any, // pageAccessService
{} as any, // workspaceRepo
);
jest
@@ -110,4 +112,61 @@ describe('TransclusionService.lookupTemplate (access mapping)', () => {
expect((items[1] as any).status).toBeUndefined();
expect((items[2] as any).status).toBe('no_access');
});
// Content-prep failure path: if jsonToNode throws for an accessible page, the
// item must degrade to not_found and NEVER return content (which would
// otherwise carry the source's un-stripped comment marks).
describe('content-prep failure → not_found', () => {
let jsonToNodeSpy: jest.SpyInstance;
afterEach(() => {
jsonToNodeSpy?.mockRestore();
});
it('maps to not_found and returns no content when jsonToNode throws', async () => {
// The page is accessible and present, but content preparation blows up.
jsonToNodeSpy = jest
.spyOn(collabUtil, 'jsonToNode')
.mockImplementation(() => {
throw new Error('boom');
});
const contentWithComment = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'secret',
marks: [{ type: 'comment', attrs: { commentId: 'leak' } }],
},
],
},
],
};
const { service } = makeService({
accessibleIds: ['p1'],
pages: [
{
id: 'p1',
title: 'T',
icon: null,
content: contentWithComment,
updatedAt: now,
},
],
});
// Silence the service's error logger for the expected throw.
jest.spyOn((service as any).logger, 'error').mockImplementation(() => {});
const { items } = await service.lookupTemplate(['p1'], 'u1', 'w1');
expect(items).toEqual([{ sourcePageId: 'p1', status: 'not_found' }]);
// Crucially: no content field, so no comment mark can leak.
expect((items[0] as any).content).toBeUndefined();
});
});
});
@@ -0,0 +1,310 @@
import { TransclusionService } from '../transclusion.service';
/**
* Covers two untested, high-risk write paths around `page_template_references`:
*
* 1. `syncPageTemplateReferences` the `toDelete` branch: stale references are
* removed when the host page no longer embeds a source, while genuinely new
* embeds are inserted. We assert `deleteByReferenceAndSources` / `insertMany`
* receive the correct rows and the returned `{ inserted, deleted }` counts.
*
* 2. `insertTemplateReferencesForPages` the multi-workspace grouping/filtering
* branch: candidate source ids are grouped per workspace, each workspace is
* validated independently, and cross-workspace sources are dropped.
*
* Setup/mocking mirrors the existing transclusion specs (page-template-access /
* page-template-lookup): `new TransclusionService(...)` is built with the same
* 11 positional mock args; only the deps each test touches are real stubs.
*/
/**
* Chainable kysely `db` stub used by `filterInWorkspaceSourceIds`. Every
* `selectFrom(...).select(...).where(...)` returns the same builder; `.execute()`
* resolves whatever rows the per-call resolver returns. The resolver receives
* the captured `where('id','in', <ids>)` and `where('workspaceId','=', ws)`
* arguments so a test can decide, per workspace, which ids "exist".
*/
function makeWorkspaceScopedDb(
resolve: (ids: string[], workspaceId: string) => string[],
) {
const state = { ids: [] as string[], workspaceId: '' };
const builder: any = {};
builder.selectFrom = jest.fn(() => builder);
builder.select = jest.fn(() => builder);
builder.where = jest.fn((col: string, _op: string, val: any) => {
if (col === 'id') state.ids = val as string[];
if (col === 'workspaceId') state.workspaceId = val as string;
return builder;
});
builder.execute = jest.fn(async () =>
resolve(state.ids, state.workspaceId).map((id) => ({ id })),
);
return builder;
}
function buildService(opts: {
db: any;
pageTemplateReferencesRepo: any;
}) {
return new TransclusionService(
opts.db,
{} as any, // pageTransclusionsRepo
{} as any, // pageTransclusionReferencesRepo
opts.pageTemplateReferencesRepo,
{} as any, // pageRepo
{} as any, // pagePermissionRepo
{} as any, // spaceMemberRepo
{} as any, // attachmentRepo
{} as any, // storageService
{} as any, // pageAccessService
{} as any, // workspaceRepo
);
}
const pageEmbedDoc = (sourceIds: string[]) => ({
type: 'doc',
content: sourceIds.map((id) => ({
type: 'pageEmbed',
attrs: { sourcePageId: id },
})),
});
describe('TransclusionService.syncPageTemplateReferences — toDelete branch', () => {
it('deletes stale references and inserts new ones with correct args/counts', async () => {
// Every candidate id is treated as in-workspace by the existence query.
const db = makeWorkspaceScopedDb((ids) => ids);
const insertMany = jest.fn().mockResolvedValue(undefined);
const deleteByReferenceAndSources = jest.fn().mockResolvedValue(undefined);
const pageTemplateReferencesRepo = {
// existing refs: "keep" stays embedded, "stale-a"/"stale-b" no longer are
findByReferencePageId: jest.fn().mockResolvedValue([
{ sourcePageId: 'keep' },
{ sourcePageId: 'stale-a' },
{ sourcePageId: 'stale-b' },
]),
insertMany,
deleteByReferenceAndSources,
};
const service = buildService({ db, pageTemplateReferencesRepo });
// host now embeds: keep (unchanged) + fresh (new). stale-a/stale-b gone.
const result = await service.syncPageTemplateReferences(
'host',
'w1',
pageEmbedDoc(['keep', 'fresh']),
);
expect(result).toEqual({ inserted: 1, deleted: 2 });
// only the genuinely new embed is inserted (keep already existed)
expect(insertMany).toHaveBeenCalledTimes(1);
expect(insertMany.mock.calls[0][0]).toEqual([
{ workspaceId: 'w1', referencePageId: 'host', sourcePageId: 'fresh' },
]);
// stale references removed, scoped to host + workspace
expect(deleteByReferenceAndSources).toHaveBeenCalledTimes(1);
const [refPageId, workspaceId, staleSources] =
deleteByReferenceAndSources.mock.calls[0];
expect(refPageId).toBe('host');
expect(workspaceId).toBe('w1');
expect([...staleSources].sort()).toEqual(['stale-a', 'stale-b']);
});
it('deletes ALL existing references when the host embeds nothing anymore', async () => {
const db = makeWorkspaceScopedDb((ids) => ids);
const insertMany = jest.fn().mockResolvedValue(undefined);
const deleteByReferenceAndSources = jest.fn().mockResolvedValue(undefined);
const pageTemplateReferencesRepo = {
findByReferencePageId: jest
.fn()
.mockResolvedValue([{ sourcePageId: 'a' }, { sourcePageId: 'b' }]),
insertMany,
deleteByReferenceAndSources,
};
const service = buildService({ db, pageTemplateReferencesRepo });
const result = await service.syncPageTemplateReferences(
'host',
'w1',
pageEmbedDoc([]), // no embeds left
);
expect(result).toEqual({ inserted: 0, deleted: 2 });
expect(insertMany).not.toHaveBeenCalled();
const [, , staleSources] = deleteByReferenceAndSources.mock.calls[0];
expect([...staleSources].sort()).toEqual(['a', 'b']);
});
it('treats a cross-workspace embed as stale: it never survives to be kept', async () => {
// existence query drops "cross-ws"; so an existing ref to it must be deleted
const db = makeWorkspaceScopedDb((ids) => ids.filter((id) => id !== 'cross-ws'));
const insertMany = jest.fn().mockResolvedValue(undefined);
const deleteByReferenceAndSources = jest.fn().mockResolvedValue(undefined);
const pageTemplateReferencesRepo = {
findByReferencePageId: jest
.fn()
.mockResolvedValue([{ sourcePageId: 'cross-ws' }]),
insertMany,
deleteByReferenceAndSources,
};
const service = buildService({ db, pageTemplateReferencesRepo });
// host still "embeds" cross-ws in its doc, but it is not in-workspace
const result = await service.syncPageTemplateReferences(
'host',
'w1',
pageEmbedDoc(['cross-ws']),
);
expect(result).toEqual({ inserted: 0, deleted: 1 });
expect(insertMany).not.toHaveBeenCalled();
const [, , staleSources] = deleteByReferenceAndSources.mock.calls[0];
expect([...staleSources]).toEqual(['cross-ws']);
});
it('no-ops both repos when desired and existing already match', async () => {
const db = makeWorkspaceScopedDb((ids) => ids);
const insertMany = jest.fn().mockResolvedValue(undefined);
const deleteByReferenceAndSources = jest.fn().mockResolvedValue(undefined);
const pageTemplateReferencesRepo = {
findByReferencePageId: jest
.fn()
.mockResolvedValue([{ sourcePageId: 'same' }]),
insertMany,
deleteByReferenceAndSources,
};
const service = buildService({ db, pageTemplateReferencesRepo });
const result = await service.syncPageTemplateReferences(
'host',
'w1',
pageEmbedDoc(['same']),
);
expect(result).toEqual({ inserted: 0, deleted: 0 });
expect(insertMany).not.toHaveBeenCalled();
expect(deleteByReferenceAndSources).not.toHaveBeenCalled();
});
});
describe('TransclusionService.insertTemplateReferencesForPages — multi-workspace grouping', () => {
it('groups candidates per workspace and validates each workspace independently', async () => {
// Each workspace "owns" only its own source ids. The existence query is
// workspace-scoped, so an id from another workspace is dropped.
const owned: Record<string, string[]> = {
w1: ['s1'],
w2: ['s2'],
};
const executeArgs: Array<{ ids: string[]; workspaceId: string }> = [];
const db = makeWorkspaceScopedDb((ids, workspaceId) => {
executeArgs.push({ ids: [...ids], workspaceId });
const ownedSet = new Set(owned[workspaceId] ?? []);
return ids.filter((id) => ownedSet.has(id));
});
const insertMany = jest.fn().mockResolvedValue(undefined);
const pageTemplateReferencesRepo = { insertMany };
const service = buildService({ db, pageTemplateReferencesRepo });
// page-a in w1 embeds s1 (valid) + s2 (belongs to w2 -> dropped)
// page-b in w2 embeds s2 (valid)
const result = await service.insertTemplateReferencesForPages([
{ id: 'page-a', workspaceId: 'w1', content: pageEmbedDoc(['s1', 's2']) },
{ id: 'page-b', workspaceId: 'w2', content: pageEmbedDoc(['s2']) },
]);
expect(result).toEqual({ inserted: 2 });
expect(insertMany).toHaveBeenCalledTimes(1);
const rows = insertMany.mock.calls[0][0];
expect(rows).toEqual([
{ workspaceId: 'w1', referencePageId: 'page-a', sourcePageId: 's1' },
{ workspaceId: 'w2', referencePageId: 'page-b', sourcePageId: 's2' },
]);
// one existence query per workspace, each scoped to that workspace's candidates
expect(executeArgs).toHaveLength(2);
const w1Call = executeArgs.find((c) => c.workspaceId === 'w1');
const w2Call = executeArgs.find((c) => c.workspaceId === 'w2');
expect(w1Call?.ids.sort()).toEqual(['s1', 's2']);
expect(w2Call?.ids).toEqual(['s2']);
});
it('drops every cross-workspace source and inserts nothing when none are in-workspace', async () => {
// No id is owned by its page's workspace -> all filtered out.
const db = makeWorkspaceScopedDb(() => []);
const insertMany = jest.fn().mockResolvedValue(undefined);
const service = buildService({
db,
pageTemplateReferencesRepo: { insertMany },
});
const result = await service.insertTemplateReferencesForPages([
{ id: 'page-a', workspaceId: 'w1', content: pageEmbedDoc(['x']) },
{ id: 'page-b', workspaceId: 'w2', content: pageEmbedDoc(['y']) },
]);
expect(result).toEqual({ inserted: 0 });
expect(insertMany).not.toHaveBeenCalled();
});
it('dedupes a sourceId shared by two pages in the same workspace into one validation', async () => {
const executeArgs: Array<{ ids: string[]; workspaceId: string }> = [];
const db = makeWorkspaceScopedDb((ids, workspaceId) => {
executeArgs.push({ ids: [...ids], workspaceId });
return ids; // all in-workspace
});
const insertMany = jest.fn().mockResolvedValue(undefined);
const service = buildService({
db,
pageTemplateReferencesRepo: { insertMany },
});
// both pages embed the same source "shared" in w1
const result = await service.insertTemplateReferencesForPages([
{ id: 'page-a', workspaceId: 'w1', content: pageEmbedDoc(['shared']) },
{ id: 'page-b', workspaceId: 'w1', content: pageEmbedDoc(['shared']) },
]);
// a row per (page, source) pair, but only one existence query for w1
expect(result).toEqual({ inserted: 2 });
expect(executeArgs).toHaveLength(1);
expect(executeArgs[0]).toEqual({ ids: ['shared'], workspaceId: 'w1' });
const rows = insertMany.mock.calls[0][0];
expect(rows).toEqual([
{ workspaceId: 'w1', referencePageId: 'page-a', sourcePageId: 'shared' },
{ workspaceId: 'w1', referencePageId: 'page-b', sourcePageId: 'shared' },
]);
});
it('returns inserted:0 without querying when no page has embeds', async () => {
const execute = jest.fn();
const db = makeWorkspaceScopedDb(() => {
execute();
return [];
});
const insertMany = jest.fn().mockResolvedValue(undefined);
const service = buildService({
db,
pageTemplateReferencesRepo: { insertMany },
});
const result = await service.insertTemplateReferencesForPages([
{ id: 'page-a', workspaceId: 'w1', content: pageEmbedDoc([]) },
]);
expect(result).toEqual({ inserted: 0 });
expect(insertMany).not.toHaveBeenCalled();
// filterInWorkspaceSourceIds short-circuits on empty candidates, so the
// existence query never runs.
expect(execute).not.toHaveBeenCalled();
});
});
@@ -1,7 +1,10 @@
import { Test } from '@nestjs/testing';
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { PageTemplateController } from '../page-template.controller';
import { TransclusionService } from '../transclusion.service';
import { TemplateLookupDto } from '../dto/template-lookup.dto';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { PageAccessService } from '../../page-access/page-access.service';
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
@@ -90,4 +93,52 @@ describe('PageTemplateController.toggleTemplate', () => {
);
expect(out).toEqual({ pageId: 'p1', isTemplate: false });
});
it('lookup forwards dto.sourcePageIds + user.id + user.workspaceId to the service', async () => {
const expected = { items: [] };
(transclusionService.lookupTemplate as jest.Mock).mockResolvedValue(
expected,
);
const dto = { sourcePageIds: ['s1', 's2'] } as any;
const out = await controller.lookup(dto, user);
expect(transclusionService.lookupTemplate).toHaveBeenCalledWith(
['s1', 's2'],
'u1', // user.id
'w1', // user.workspaceId
);
expect(out).toBe(expected);
});
});
describe('TemplateLookupDto validation (class-validator)', () => {
const uuid = (n: number) =>
`00000000-0000-4000-8000-${String(n).padStart(12, '0')}`;
it('accepts an array of <=50 valid UUIDs', async () => {
const dto = plainToInstance(TemplateLookupDto, {
sourcePageIds: [uuid(1), uuid(2)],
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it('rejects an over-cap array (ArrayMaxSize 50)', async () => {
const dto = plainToInstance(TemplateLookupDto, {
sourcePageIds: Array.from({ length: 51 }, (_, i) => uuid(i)),
});
const errors = await validate(dto);
expect(errors).toHaveLength(1);
expect(errors[0].constraints).toHaveProperty('arrayMaxSize');
});
it('rejects a non-UUID member (IsUUID each)', async () => {
const dto = plainToInstance(TemplateLookupDto, {
sourcePageIds: [uuid(1), 'not-a-uuid'],
});
const errors = await validate(dto);
expect(errors).toHaveLength(1);
expect(errors[0].constraints).toHaveProperty('isUuid');
});
});
@@ -56,6 +56,7 @@ function buildService(featureEnabled = true) {
{} as any, // db (unused on this path)
pageTransclusionsRepo as any,
pageTransclusionReferencesRepo as any,
{} as any, // pageTemplateReferencesRepo (unused on this path)
pageRepo as any,
{} as any, // pagePermissionRepo (unused)
{} as any, // spaceMemberRepo (unused)
@@ -317,6 +317,7 @@ export class TransclusionService {
if (toDelete.length > 0) {
await this.pageTemplateReferencesRepo.deleteByReferenceAndSources(
referencePageId,
workspaceId,
toDelete,
trx,
);
@@ -99,6 +99,64 @@ export function collectReferencesFromPmJson(
return out;
}
/**
* Decide the sourcePageId a duplicated pageEmbed should point to: the copy's new
* id when the embedded source is part of the copied set, otherwise the original
* (a live embed of the original page). Pure shared by PageService.duplicatePage
* (the real path) and the JSON walker below, so both stay in lockstep.
*/
export function remapPageEmbedSourceId(
sourcePageId: string | null | undefined,
resolveNewId: (id: string) => string | undefined,
): string | null | undefined {
if (sourcePageId) {
const mapped = resolveNewId(sourcePageId);
if (mapped) return mapped;
}
return sourcePageId;
}
/**
* Remap the `sourcePageId` of every `pageEmbed` node in a ProseMirror JSON doc
* according to `idMap` (old page id -> new page id). Delegates the per-node
* decision to the shared `remapPageEmbedSourceId` helper that
* `PageService.duplicatePage` also uses, so the production path and this walker
* stay in lockstep: when the embedded source page is part of the copied set
* (present in `idMap`) the embed is pointed at its new copy; otherwise the
* original `sourcePageId` is preserved so it stays a live embed of the original
* page. Mutates `doc` in place (and returns it) to match the service's in-place
* ProseMirror mutation. Recurses through arbitrary block containers (columns,
* callouts, etc.) the same way the collectors do, but does NOT descend into a
* `transclusionSource` (schema-isolated).
*/
export function remapPageEmbedSourceIds<T>(
doc: T,
idMap: Map<string, string>,
): T {
const visit = (node: any): void => {
if (!node || typeof node !== 'object') return;
if (node.type === PAGE_EMBED_TYPE) {
if (node.attrs) {
node.attrs.sourcePageId = remapPageEmbedSourceId(
node.attrs.sourcePageId,
(id) => idMap.get(id),
);
}
return; // atom node - no children
}
if (node.type === TRANSCLUSION_TYPE) return;
if (Array.isArray(node.content)) {
for (const child of node.content) visit(child);
}
};
visit(doc);
return doc;
}
/**
* Walks a ProseMirror JSON document and returns one snapshot per unique
* `sourcePageId` found on `pageEmbed` nodes (whole-page live embeds). Order
@@ -1,15 +1,19 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SearchController } from './search.controller';
// Direct instantiation with stub deps. The Test.createTestingModule form failed
// to resolve SearchService's @InjectKysely() connection token at compile() (the
// same Nest-DI/Kysely-token issue addressed in search.service.spec), and this
// unit only needs the controller to construct.
describe('SearchController', () => {
let controller: SearchController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [SearchController],
}).compile();
controller = module.get<SearchController>(SearchController);
beforeEach(() => {
controller = new SearchController(
{} as any, // searchService
{} as any, // spaceAbility
{} as any, // environmentService
{} as any, // moduleRef
);
});
it('should be defined', () => {
@@ -1,18 +1,101 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SearchService } from './search.service';
describe('SearchService', () => {
let service: SearchService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [SearchService],
}).compile();
service = module.get<SearchService>(SearchService);
});
it('should be defined', () => {
// Construct directly with stub deps. The previous Test.createTestingModule
// form could not resolve the @InjectKysely() connection token and failed at
// compile() — manual construction mirrors the rest of these unit specs.
const service = new SearchService(
{} as any, // db
{} as any, // pageRepo
{} as any, // shareRepo
{} as any, // spaceMemberRepo
{} as any, // pagePermissionRepo
);
expect(service).toBeDefined();
});
});
/**
* Focused coverage for the `onlyTemplates` flag in `searchSuggestions`, which
* restricts page suggestions to template pages (`is_template = true`). The kysely
* query builder and repos are mocked the same way the access specs mock chainable
* builders: every builder method returns the same builder, `.execute()` resolves
* the supplied rows. We assert whether `.where('isTemplate', '=', true)` is added.
*/
describe('SearchService.searchSuggestions — onlyTemplates filter', () => {
function makeService(pageRows: Array<{ id: string }>) {
// Chainable page-search builder. Record every `.where(...)` call so we can
// assert on the is_template restriction.
const pageBuilder: any = {};
pageBuilder.select = jest.fn(() => pageBuilder);
pageBuilder.where = jest.fn(() => pageBuilder);
pageBuilder.orderBy = jest.fn(() => pageBuilder);
pageBuilder.limit = jest.fn(() => pageBuilder);
pageBuilder.execute = jest.fn(async () => pageRows);
const db: any = {
// searchSuggestions only touches `pages` here (includePages: true).
selectFrom: jest.fn(() => pageBuilder),
};
const pageRepo = {
// `.select((eb) => this.pageRepo.withSpace(eb))` — return value is ignored
// by our builder stub, so a sentinel is enough.
withSpace: jest.fn(() => ({ __withSpace: true })),
};
const shareRepo = {};
const spaceMemberRepo = {
getUserSpaceIds: jest.fn().mockResolvedValue(['space-1']),
};
const pagePermissionRepo = {
// Let every found page through page-level permission filtering.
filterAccessiblePageIds: jest
.fn()
.mockImplementation(async ({ pageIds }: { pageIds: string[] }) => pageIds),
};
const service = new SearchService(
db as any,
pageRepo as any,
shareRepo as any,
spaceMemberRepo as any,
pagePermissionRepo as any,
);
return { service, db, pageBuilder };
}
const isTemplateWhereCall = (pageBuilder: any) =>
pageBuilder.where.mock.calls.find((c: any[]) => c[0] === 'isTemplate');
it('restricts page suggestions to is_template = true when onlyTemplates is set', async () => {
const { service, pageBuilder } = makeService([{ id: 'tmpl-1' }]);
const result = await service.searchSuggestions(
{ query: 'plan', includePages: true, onlyTemplates: true } as any,
'user-1',
'ws-1',
);
// The is_template restriction must be applied to the page query.
const call = isTemplateWhereCall(pageBuilder);
expect(call).toEqual(['isTemplate', '=', true]);
// Sanity: the (template) page made it through.
expect(result.pages.map((p: any) => p.id)).toEqual(['tmpl-1']);
});
it('does NOT restrict to templates when onlyTemplates is absent', async () => {
const { service, pageBuilder } = makeService([{ id: 'any-1' }]);
await service.searchSuggestions(
{ query: 'plan', includePages: true } as any,
'user-1',
'ws-1',
);
// No is_template clause should be added for a normal page suggestion search.
expect(isTemplateWhereCall(pageBuilder)).toBeUndefined();
});
});
@@ -131,3 +131,131 @@ describe('ShareService htmlEmbed server-authoritative kill-switch (real code)',
expect(hasHtmlEmbedNode(out)).toBe(true);
});
});
// Exercises the REAL ShareService.lookupTransclusionForShare post-processing for
// the share-served transclusion path: the same server-authoritative htmlEmbed
// kill-switch must apply to each transcluded item's content, and a not_found
// item must never be run through prepareContentForShare (so its absent content
// can't be serialized/leaked). The access graph (shareRepo / isSharingAllowed /
// getShareForPage / restricted-ancestor) is stubbed so the strip/serve mapping
// runs deterministically; lookupWithAccessSet is mocked to control the items.
describe('ShareService.lookupTransclusionForShare htmlEmbed kill-switch (real code)', () => {
const SHARE = 'share-1';
const SPACE = 'space-1';
const SRC = 'src-page';
function buildTransclusionService(opts: {
htmlEmbed?: boolean | undefined;
items: any[];
}) {
const shareRepo = {
findById: jest.fn(async () => ({
id: SHARE,
workspaceId: WS,
spaceId: SPACE,
})),
};
const pageRepo = { findById: jest.fn() };
const pagePermissionRepo = {
hasRestrictedAncestor: jest.fn(async () => false),
};
const tokenService = {
generateAttachmentToken: jest.fn(async () => 'tok'),
};
const lookupWithAccessSet = jest.fn(async () => ({ items: opts.items }));
const transclusionService = { lookupWithAccessSet };
const workspaceRepo = {
findById: jest.fn(async () => ({
id: WS,
settings: { htmlEmbed: opts.htmlEmbed },
})),
};
const service = new ShareService(
shareRepo as any,
pageRepo as any,
pagePermissionRepo as any,
{} as any, // db (unused — isSharingAllowed stubbed below)
tokenService as any,
transclusionService as any,
workspaceRepo as any,
);
// isSharingAllowed and getShareForPage hit the raw db; stub them so the
// access chain resolves SRC as reachable and prepareContentForShare runs.
jest.spyOn(service, 'isSharingAllowed').mockResolvedValue(true);
jest
.spyOn(service, 'getShareForPage')
.mockResolvedValue({ pageId: SRC, spaceId: SPACE, id: 's2' } as any);
return { service, transclusionService, lookupWithAccessSet };
}
const transcludedItemWithEmbed = () => ({
sourcePageId: SRC,
transclusionId: 't1',
content: {
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'block body' }] },
{ type: 'htmlEmbed', attrs: { source: '<script>t()</script>' } },
],
},
sourceUpdatedAt: new Date('2026-06-20T00:00:00.000Z'),
});
const refs = [{ sourcePageId: SRC, transclusionId: 't1' }];
it('toggle OFF: strips htmlEmbed from each transcluded item content', async () => {
const { service } = buildTransclusionService({
htmlEmbed: false,
items: [transcludedItemWithEmbed()],
});
const { items } = await service.lookupTransclusionForShare(SHARE, refs, WS);
expect(items).toHaveLength(1);
const item = items[0] as any;
expect(item.status).toBeUndefined();
expect(hasHtmlEmbedNode(item.content)).toBe(false);
// Non-embed body of the transcluded block is preserved.
expect(JSON.stringify(item.content)).toContain('block body');
});
it('toggle ON: serves htmlEmbed in the transcluded item content', async () => {
const { service } = buildTransclusionService({
htmlEmbed: true,
items: [transcludedItemWithEmbed()],
});
const { items } = await service.lookupTransclusionForShare(SHARE, refs, WS);
const item = items[0] as any;
expect(item.status).toBeUndefined();
expect(hasHtmlEmbedNode(item.content)).toBe(true);
expect(JSON.stringify(item.content)).toContain('block body');
});
it('a not_found item is NOT run through prepareContentForShare (no token minting)', async () => {
const notFoundItem = {
sourcePageId: SRC,
transclusionId: 't1',
status: 'not_found' as const,
};
const { service } = buildTransclusionService({
htmlEmbed: true,
items: [notFoundItem],
});
// tokenService is reachable via the service; spy on it to assert it is never
// touched for a status item (prepareContentForShare mints tokens).
const tokenSpy = jest.spyOn(
(service as any).tokenService,
'generateAttachmentToken',
);
const { items } = await service.lookupTransclusionForShare(SHARE, refs, WS);
// not_found is collapsed to no_access for share viewers and carries NO content.
const item = items[0] as any;
expect(item.status).toBe('no_access');
expect(item.content).toBeUndefined();
expect(tokenSpy).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,111 @@
import { WorkspaceService } from './workspace.service';
/**
* Exercises the REAL WorkspaceService.update htmlEmbed-toggle persistence at the
* service seam: an update carrying `htmlEmbed` must call
* `workspaceRepo.updateSetting(workspaceId, 'htmlEmbed', value, trx)`, and an
* update WITHOUT it must not touch that setting. The repo, db transaction, and
* audit service are mocked; `executeTx` runs the callback against a fake trx.
*
* DEFERRED (DB-only): the "does not clobber sibling settings" guarantee is a
* jsonb merge property of `updateSetting`'s SQL and needs a real Postgres to
* assert. This spec only asserts the service-level CALL SHAPE.
*/
describe('WorkspaceService.update — htmlEmbed toggle persistence (real code)', () => {
function buildService(opts: { settingsBefore?: Record<string, any> }) {
const updateSetting = jest.fn().mockResolvedValue(undefined);
const updateWorkspace = jest.fn().mockResolvedValue(undefined);
const workspaceRepo = {
// First call: read settingsBefore. Second call: return the updated
// workspace (must include a licenseKey because update() destructures it).
findById: jest
.fn()
.mockResolvedValueOnce({ id: 'w1', settings: opts.settingsBefore ?? {} })
.mockResolvedValueOnce({ id: 'w1', name: 'WS', licenseKey: null }),
updateSetting,
updateWorkspace,
};
// Fake kysely db: only .transaction().execute(cb) is used on this path.
const db = {
transaction: jest.fn(() => ({
execute: jest.fn(async (cb: any) => cb({ __trx: true })),
})),
};
const auditService = { log: jest.fn() };
const service = new WorkspaceService(
workspaceRepo as any, // workspaceRepo
{} as any, // spaceService
{} as any, // spaceMemberService
{} as any, // groupRepo
{} as any, // groupUserRepo
{} as any, // userRepo
{} as any, // environmentService
{} as any, // domainService
{} as any, // licenseCheckService
{} as any, // shareRepo
{} as any, // watcherRepo
{} as any, // favoriteRepo
db as any, // db (InjectKysely)
{} as any, // attachmentQueue
{} as any, // billingQueue
{} as any, // aiQueue
auditService as any, // auditService
{} as any, // userSessionRepo
);
return { service, workspaceRepo, updateSetting, auditService };
}
it('persists htmlEmbed:true via updateSetting with the htmlEmbed key', async () => {
const { service, updateSetting } = buildService({});
await service.update('w1', { htmlEmbed: true } as any);
expect(updateSetting).toHaveBeenCalledTimes(1);
expect(updateSetting).toHaveBeenCalledWith(
'w1',
'htmlEmbed',
true,
expect.anything(), // the transaction handle
);
});
it('persists htmlEmbed:false (explicit disable is not dropped)', async () => {
const { service, updateSetting } = buildService({
settingsBefore: { htmlEmbed: true },
});
await service.update('w1', { htmlEmbed: false } as any);
expect(updateSetting).toHaveBeenCalledWith(
'w1',
'htmlEmbed',
false,
expect.anything(),
);
});
it('does NOT call updateSetting when htmlEmbed is undefined in the dto', async () => {
const { service, updateSetting } = buildService({});
await service.update('w1', { name: 'New name' } as any);
expect(updateSetting).not.toHaveBeenCalled();
});
it('audits the htmlEmbed change (before/after) when the value actually changes', async () => {
const { service, auditService } = buildService({
settingsBefore: { htmlEmbed: false },
});
await service.update('w1', { htmlEmbed: true } as any);
expect(auditService.log).toHaveBeenCalledTimes(1);
const logged = auditService.log.mock.calls[0][0];
expect(logged.changes.before.htmlEmbed).toBe(false);
expect(logged.changes.after.htmlEmbed).toBe(true);
});
});
@@ -132,7 +132,7 @@ export class AiAgentRoleRepo {
* generated column type is the broad `JsonValue` union, which a concrete object
* type is not structurally assignable to.
*/
function jsonbObject(value: ModelConfigValue | undefined) {
export function jsonbObject(value: ModelConfigValue | undefined) {
if (value === null || value === undefined || Object.keys(value).length === 0) {
return null;
}
@@ -22,21 +22,6 @@ export class PageTemplateReferencesRepo {
.execute();
}
async findReferencePageIdsBySource(
sourcePageId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<string[]> {
const rows = await dbOrTx(this.db, trx)
.selectFrom('pageTemplateReferences')
.select('referencePageId')
.distinct()
.where('workspaceId', '=', workspaceId)
.where('sourcePageId', '=', sourcePageId)
.execute();
return rows.map((r) => r.referencePageId);
}
async insertMany(
rows: InsertablePageTemplateReference[],
trx?: KyselyTransaction,
@@ -53,12 +38,15 @@ export class PageTemplateReferencesRepo {
async deleteByReferenceAndSources(
referencePageId: string,
workspaceId: string,
sourcePageIds: string[],
trx?: KyselyTransaction,
): Promise<void> {
if (sourcePageIds.length === 0) return;
await dbOrTx(this.db, trx)
.deleteFrom('pageTemplateReferences')
// Defense-in-depth: scope deletes to the caller's workspace.
.where('workspaceId', '=', workspaceId)
.where('referencePageId', '=', referencePageId)
.where('sourcePageId', 'in', sourcePageIds)
.execute();
@@ -58,4 +58,26 @@ describe('describeProviderError', () => {
// 'e | response body: ' + 300 chars + '…'
expect(out.length).toBeLessThan('e | response body: '.length + 305);
});
it('uses the fallback for a numeric or boolean (non-object, non-string) error', () => {
// typeof number / boolean is neither 'object' nor a non-empty 'string', so
// the early branch returns the fallback verbatim.
expect(describeProviderError(500, 'AI stream error')).toBe('AI stream error');
expect(describeProviderError(0, 'AI stream error')).toBe('AI stream error');
expect(describeProviderError(true)).toBe('Unknown error');
expect(describeProviderError(false, 'fb')).toBe('fb');
});
it('statusCode present but message undefined => "<code>:" with no trailing space', () => {
// `${code}: ${undefined ?? ''}`.trim() collapses to just "<code>:".
expect(describeProviderError({ statusCode: 503 })).toBe('503:');
// The trailing space after the colon is trimmed away.
expect(describeProviderError({ statusCode: 503 }).endsWith(': ')).toBe(false);
});
it('object with neither message nor statusCode nor body => fallback', () => {
expect(describeProviderError({}, 'AI stream error')).toBe('AI stream error');
// An object carrying only unrelated keys is still treated as message-less.
expect(describeProviderError({ foo: 'bar' } as never)).toBe('Unknown error');
});
});
@@ -171,4 +171,117 @@ describe('AiService.getChatModel role model override', () => {
expect(aiProviderCredentialsRepo.find).not.toHaveBeenCalled();
expect(secretBox.decryptSecret).not.toHaveBeenCalled();
});
/**
* Build a service whose workspace driver is ollama (no apiKey, with a baseUrl).
* Complements makeService (which configures openai) for the same-driver and
* not-configured ollama cases.
*/
function makeOllamaService(over: { baseUrl?: string } = {}) {
const aiSettings = {
resolve: jest.fn().mockResolvedValue({
driver: 'ollama',
chatModel: 'llama3',
apiKey: undefined,
baseUrl: over.baseUrl ?? 'http://localhost:11434/v1',
}),
};
const aiProviderCredentialsRepo = { find: jest.fn() };
const secretBox = { decryptSecret: jest.fn() };
const service = new AiService(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
aiSettings as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
aiProviderCredentialsRepo as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
secretBox as any,
);
return { service, aiSettings, aiProviderCredentialsRepo, secretBox };
}
it('same-driver ollama override (workspace driver=ollama): reuses the workspace ollama baseUrl, no creds lookup/decrypt', async () => {
// Workspace driver IS ollama. A role that overrides to ollama (same driver)
// legitimately reuses the workspace's configured ollama endpoint — it must
// NOT hit the cross-driver 503 path, NOT query ai_provider_credentials, and
// NOT decrypt anything (ollama needs no key).
const { service, aiProviderCredentialsRepo, secretBox } = makeOllamaService();
const model = await service.getChatModel('ws-1', {
driver: 'ollama',
chatModel: 'llama3.1',
roleName: 'Local',
});
expect(model).toBeDefined();
expect(aiProviderCredentialsRepo.find).not.toHaveBeenCalled();
expect(secretBox.decryptSecret).not.toHaveBeenCalled();
});
it('chatModel-only override on an ollama workspace: reuses the workspace ollama baseUrl, no creds lookup', async () => {
// No override.driver on an ollama workspace => the workspace ollama driver +
// baseUrl are reused; no creds lookup, no decrypt (the cheap public-share
// model-only override path against an ollama workspace).
const { service, aiProviderCredentialsRepo, secretBox } = makeOllamaService();
const model = await service.getChatModel('ws-1', { chatModel: 'mistral' });
expect(model).toBeDefined();
expect(aiProviderCredentialsRepo.find).not.toHaveBeenCalled();
expect(secretBox.decryptSecret).not.toHaveBeenCalled();
});
it('blank chatModel guard: workspace has a driver but a blank chatModel and no override chatModel => AiNotConfiguredException', async () => {
// cfg.driver passes the first guard, but cfg.chatModel is blank and the
// override carries no chatModel, so the effective chatModel is empty.
const aiSettings = {
resolve: jest.fn().mockResolvedValue({
driver: 'openai',
chatModel: '',
apiKey: 'workspace-key',
baseUrl: undefined,
}),
};
const aiProviderCredentialsRepo = { find: jest.fn() };
const secretBox = { decryptSecret: jest.fn() };
const service = new AiService(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
aiSettings as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
aiProviderCredentialsRepo as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
secretBox as any,
);
await expect(
// Override has only a roleName, no chatModel to fill the blank.
service.getChatModel('ws-1', { roleName: 'Writer' }),
).rejects.toBeInstanceOf(AiNotConfiguredException);
});
it('non-ollama driver with a missing apiKey => AiNotConfiguredException', async () => {
// Workspace is openai (non-ollama) with a model but NO apiKey: the combined
// `driver !== ollama && !apiKey` guard must 503.
const aiSettings = {
resolve: jest.fn().mockResolvedValue({
driver: 'openai',
chatModel: 'gpt-4o-mini',
apiKey: undefined,
baseUrl: undefined,
}),
};
const aiProviderCredentialsRepo = { find: jest.fn() };
const secretBox = { decryptSecret: jest.fn() };
const service = new AiService(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
aiSettings as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
aiProviderCredentialsRepo as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
secretBox as any,
);
await expect(service.getChatModel('ws-1')).rejects.toBeInstanceOf(
AiNotConfiguredException,
);
});
});
@@ -5,6 +5,7 @@
// the Authorization header.
import { UnauthorizedException } from '@nestjs/common';
import { timingSafeEqual } from 'node:crypto';
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
import { JwtType } from '../../core/auth/dto/jwt-payload';
import { CREDENTIALS_MISMATCH_MESSAGE } from '../../core/auth/auth.constants';
@@ -291,6 +292,14 @@ export interface BearerVerifyDeps {
workspaceId?: string;
sessionId?: string;
}>;
// The workspace id of THIS MCP instance, when the caller can resolve it (the
// community build is single-workspace, so McpService passes its default
// workspace's id). When provided, the token's `workspaceId` claim MUST equal
// it, mirroring JwtStrategy's `req.raw.workspaceId !== payload.workspaceId`
// guard so a valid ACCESS token from a DIFFERENT workspace cannot be replayed
// against this instance in a multi-workspace deployment. Optional so callers /
// tests that genuinely cannot resolve an instance workspace are unchanged.
expectedWorkspaceId?: string;
// Load the user (or undefined) for the disabled check.
findUser: (
sub: string,
@@ -321,6 +330,19 @@ export async function verifyBearerAccess(
throw new UnauthorizedException(generic);
}
// Bind the token to THIS instance's workspace (mirrors JwtStrategy). When the
// caller resolved an instance workspace id, a token whose `workspaceId` claim
// points at another workspace is rejected, so a valid ACCESS token minted in
// workspace B cannot be replayed against an MCP instance serving workspace A.
// In the single-workspace community build expectedWorkspaceId equals the only
// workspace, so this is a no-op there; it only bites a multi-workspace deploy.
if (
deps.expectedWorkspaceId &&
payload.workspaceId !== deps.expectedWorkspaceId
) {
throw new UnauthorizedException(generic);
}
const user = await deps.findUser(payload.sub, payload.workspaceId);
if (!user || user.deactivatedAt || user.deletedAt) {
throw new UnauthorizedException(generic);
@@ -342,21 +364,129 @@ export async function verifyBearerAccess(
/**
* Detect a genuine JSON-RPC `initialize` request from an already-parsed body.
* Mirrors the @modelcontextprotocol/sdk `isInitializeRequest` signal that
* packages/mcp/src/http.ts uses to decide whether to mint a session, but
* framework/SDK-free so it is unit-testable and usable from the CommonJS
* McpService. An initialize request is a single JSON-RPC object whose `method`
* is exactly 'initialize'; a batch (array) body is never an initialize request.
* Delegates to the @modelcontextprotocol/sdk `isInitializeRequest` predicate
* the SAME predicate packages/mcp/src/http.ts uses to decide whether to mint a
* session so the session-minting side (this server) and the session-creating
* side (http.ts) agree EXACTLY on what counts as an initialize request. The SDK
* predicate validates the full InitializeRequest shape (jsonrpc, id, method ===
* 'initialize', params incl. protocolVersion); a bare `{ method: 'initialize' }`
* with no params, a batch (array) body, etc. are NOT initialize requests.
*
* This is the second half of the session-INIT decision: `isSessionInit` is
* (no `mcp-session-id` header) AND `isInitializeRequestBody(body)`. Using it
* ensures the side-effecting login() (user_sessions insert + USER_LOGIN audit +
* lastLoginAt) only runs for a real initialize, never for an arbitrary
* header-less request that http.ts will subsequently 400.
* (no `mcp-session-id` header) AND `isInitializeRequestBody(body)`. Matching the
* SDK predicate exactly ensures the side-effecting login() (user_sessions insert
* + USER_LOGIN audit + lastLoginAt) only runs for a request http.ts will also
* accept as an initialize never for an arbitrary header-less request that
* http.ts would subsequently 400 (which would otherwise spam the audit log /
* grow user_sessions without ever creating an MCP session).
*/
export function isInitializeRequestBody(body: unknown): boolean {
if (!body || typeof body !== 'object' || Array.isArray(body)) return false;
return (body as { method?: unknown }).method === 'initialize';
return isInitializeRequest(body);
}
/**
* The outcome of McpService.handle's pre-hijack gauntlet, as a pure value the
* caller acts on. Either send a JSON error with a fixed status (`respond`), or
* proceed to hijack the response and delegate to the MCP transport (`hijack`).
* Keeping this a pure decision (no FastifyReply, no res.hijack) makes the
* status/body mapping unit-testable, and guarantees no error path can leak the
* password or Authorization header the body is only ever a fixed string or the
* UnauthorizedException's own message.
*/
export type McpHandleDecision =
| { kind: 'respond'; status: number; body: { error: string } }
| { kind: 'hijack' };
/**
* Pure mapping of McpService.handle's auth/enablement gauntlet to a response
* decision. Precedence mirrors handle():
* 1. shared X-MCP-Token mismatch -> 401 {error:'Unauthorized'} (no hijack).
* 2. workspace MCP disabled -> 403 {error:'MCP is disabled ...'}.
* 3. resolveSessionConfig threw:
* - an UnauthorizedException -> 401 with err.message (a SPECIFIC reason;
* never the password/header the message is the only thing surfaced).
* - any other error -> 500 generic 'Internal server error'.
* 4. otherwise (auth resolved) -> hijack and delegate to the transport.
*/
export function mapAuthResultToResponse(input: {
sharedTokenOk: boolean;
enabled: boolean;
error?: unknown;
}): McpHandleDecision {
if (!input.sharedTokenOk) {
return { kind: 'respond', status: 401, body: { error: 'Unauthorized' } };
}
if (!input.enabled) {
return {
kind: 'respond',
status: 403,
body: { error: 'MCP is disabled for this workspace' },
};
}
if (input.error !== undefined) {
if (input.error instanceof UnauthorizedException) {
return {
kind: 'respond',
status: 401,
body: { error: input.error.message },
};
}
return {
kind: 'respond',
status: 500,
body: { error: 'Internal server error' },
};
}
return { kind: 'hijack' };
}
// Result of the EE MFA module's requirement check for the Basic gate. Both
// flags absent/false means MFA does not block the password login.
export interface BasicGateMfaResult {
userHasMfa?: boolean;
requiresMfaSetup?: boolean;
}
/**
* Pure decision logic for the /mcp HTTP-Basic pre-token gate, replicating EXACTLY
* what AuthController.login enforces before issuing a token, so the Basic path is
* not an SSO/MFA bypass. Framework-free (no ModuleRef, no on-disk EE MFA module)
* so the SSO/MFA decision is unit-testable in isolation:
*
* - `ssoEnforced` true -> throw Unauthorized ("enforced SSO"); a password
* login is not allowed on an SSO-enforced workspace.
* - otherwise, `mfa` is the EE MFA module's requirement result (or undefined
* when no EE MFA module is bundled a community/fork build). If MFA is
* present and the user has MFA enabled OR needs MFA setup, throw Unauthorized
* telling the caller to use a Bearer access token (Basic cannot complete MFA).
* - no SSO + no MFA gate -> resolve (the Basic login is allowed to proceed).
*
* McpService.enforceBasicLoginGate wires the concrete `validateSsoEnforcement`
* result and the lazily-loaded MFA module result into this, so the gate decision
* itself carries no framework dependencies. Throws UnauthorizedException on
* rejection (surfaced as a clean 401); never logs the password.
*/
export function decideBasicGate(input: {
ssoEnforced: boolean;
mfa?: BasicGateMfaResult;
}): void {
if (input.ssoEnforced) {
throw new UnauthorizedException(
'This workspace has enforced SSO login. Use SSO; MCP HTTP Basic is not allowed.',
);
}
const mfa = input.mfa;
if (mfa && (mfa.userHasMfa || mfa.requiresMfaSetup)) {
throw new UnauthorizedException(
'This account requires multi-factor authentication. MCP HTTP Basic ' +
'cannot complete MFA — log in normally and use a Bearer access token ' +
'instead.',
);
}
}
/** Extract a Bearer token from an Authorization header (case-insensitive). */
@@ -0,0 +1,259 @@
import { UnauthorizedException } from '@nestjs/common';
// ---------------------------------------------------------------------------
// These tests exercise the REAL McpService.enforceBasicLoginGate (the pre-token
// SSO/MFA gate on the /mcp HTTP-Basic path). Unlike the resolveMcpSessionConfig
// tests in mcp.service.spec.ts — which STUB the gate and only assert it runs
// before login()/verifyCredentials — here the gate logic is instantiated for
// real and only its LEAF dependencies are mocked:
// - the workspace object (plain object with/without enforceSso),
// - the user credentials (plain object),
// - the lazily-required EE MFA module (jest.mock with { virtual: true } so we
// can simulate BOTH "bundled" and "not bundled" community-build states),
// - the injected MfaService instance (via a stub moduleRef).
//
// McpService cannot normally be imported under jest because it imports
// AuthService, which drags in the React email-template graph
// (@docmost/transactional/emails/*) that the jest moduleNameMapper does not
// resolve. We therefore mock the heavy collaborator modules (auth.service,
// token.service, the @docmost/db repos and mcp-auth.helpers) at the module
// level so importing mcp.service.ts succeeds. None of those are touched by the
// gate itself, so the gate runs unmodified against the real code path.
// ---------------------------------------------------------------------------
// The EE MFA module specifier the jest.mock below intercepts MUST be
// byte-for-byte the specifier that mcp.service.ts lazily require()s
// ('./../../ee/mfa/services/mfa.service'). jest.mock is hoisted above all
// non-hoisted code, so the path is inlined as a literal in the call below
// rather than referenced through a const (which would not yet be initialised).
// `{ virtual: true }` is required because the EE module does not exist in this
// OSS build (there is no src/ee directory) — without it jest cannot register a
// mock for a path it cannot resolve on disk.
// Mutable handle the virtual mock factory reads, so each test can decide whether
// the EE module is "bundled" (factory returns a MfaService class) or "not
// bundled" (factory throws, mimicking the require() failing on a community
// build). jest.mock is hoisted, so the factory must close over this lazily.
let mfaModuleState: { bundled: boolean; checkMfaRequirements?: jest.Mock } = {
bundled: false,
};
jest.mock(
'./../../ee/mfa/services/mfa.service',
() => {
if (!mfaModuleState.bundled) {
// Simulate a community/fork build with no EE MFA module: the real
// require() throws, which the gate catches as the "no MFA gate" path.
throw new Error('Cannot find module (EE MFA not bundled)');
}
// "Bundled" build: expose a MfaService class token. The actual instance the
// gate calls is resolved through moduleRef.get(MfaModule.MfaService), which
// our stub moduleRef returns regardless of the token identity.
class MfaService {}
return { MfaService };
},
{ virtual: true },
);
// --- Mock the heavy collaborator modules so importing mcp.service succeeds. ---
// The gate never calls into these; they exist only to satisfy the import graph.
jest.mock('../../core/auth/services/auth.service', () => ({
AuthService: class AuthService {},
}));
jest.mock('../../core/auth/services/token.service', () => ({
TokenService: class TokenService {},
}));
jest.mock('@docmost/db/repos/workspace/workspace.repo', () => ({
WorkspaceRepo: class WorkspaceRepo {},
}));
jest.mock('@docmost/db/repos/user/user.repo', () => ({
UserRepo: class UserRepo {},
}));
jest.mock('@docmost/db/repos/session/user-session.repo', () => ({
UserSessionRepo: class UserSessionRepo {},
}));
// mcp-auth.helpers exports runtime values the gate relies on (decideBasicGate,
// mapAuthResultToResponse, etc.). Keep the REAL helpers so the gate exercises
// real logic; only stub FailedLoginLimiter so its constructor runs without a
// real sweep timer. The module is framework-free and loads cleanly under jest
// (mcp.service.spec.ts already imports it directly), so requireActual is safe.
jest.mock('./mcp-auth.helpers', () => {
const actual = jest.requireActual('./mcp-auth.helpers');
return {
...actual,
FailedLoginLimiter: class FailedLoginLimiter {
sweep() {}
},
};
});
// Import AFTER the mocks are registered.
// eslint-disable-next-line @typescript-eslint/no-require-imports
import { McpService } from './mcp.service';
type GateCreds = { email: string; password: string };
// Build an McpService instance with stubbed constructor deps. We never call the
// auth/db collaborators from the gate, so undefined stand-ins are fine for all
// but moduleRef, which the MFA branch reads.
function makeService(opts: {
checkMfaRequirements?: jest.Mock;
}): { service: McpService; gate: (ws: unknown, creds: GateCreds) => Promise<void> } {
// Stub moduleRef.get -> returns an object whose checkMfaRequirements is the
// provided mock. The gate calls moduleRef.get(MfaModule.MfaService).
const moduleRef = {
get: jest.fn().mockReturnValue({
checkMfaRequirements:
opts.checkMfaRequirements ?? jest.fn().mockResolvedValue(undefined),
}),
};
const service = new McpService(
undefined as never, // environmentService
undefined as never, // workspaceRepo
undefined as never, // authService
undefined as never, // tokenService
undefined as never, // userRepo
undefined as never, // userSessionRepo
moduleRef as never, // moduleRef (read by the MFA branch)
);
// Stop the constructor's unref'd sweep timer leaking across tests.
service.onModuleDestroy();
// enforceBasicLoginGate is private; reach it through the instance. Calling the
// REAL method (not a stub) is the whole point of this suite.
const gate = (
service as unknown as {
enforceBasicLoginGate: (ws: unknown, creds: GateCreds) => Promise<void>;
}
).enforceBasicLoginGate.bind(service);
return { service, gate };
}
const CREDS: GateCreds = { email: 'user@example.com', password: 'pw' };
describe('McpService.enforceBasicLoginGate (REAL gate, leaf deps mocked)', () => {
beforeEach(() => {
// Reset to the community-build default (no EE module) before each test.
mfaModuleState = { bundled: false };
jest.clearAllMocks();
});
describe('SSO enforcement (validateSsoEnforcement)', () => {
it('rejects with Unauthorized when the workspace enforces SSO, before any MFA/login', async () => {
const { gate } = makeService({});
const workspace = { id: 'ws-1', enforceSso: true };
await expect(gate(workspace, CREDS)).rejects.toBeInstanceOf(
UnauthorizedException,
);
// The /mcp 401 surfaces an SSO-specific message (not a generic MCP error).
await expect(gate(workspace, CREDS)).rejects.toThrow(/enforced SSO/i);
});
it('does NOT consult the MFA module when SSO is enforced (gate short-circuits)', async () => {
// Even if the EE module WERE bundled, the SSO branch throws first, so the
// moduleRef MFA lookup must never run.
mfaModuleState = {
bundled: true,
checkMfaRequirements: jest.fn(),
};
const { service, gate } = makeService({
checkMfaRequirements: mfaModuleState.checkMfaRequirements,
});
const moduleRefGet = (
service as unknown as { moduleRef: { get: jest.Mock } }
).moduleRef.get;
await expect(
gate({ id: 'ws-1', enforceSso: true }, CREDS),
).rejects.toThrow(/enforced SSO/i);
// The SSO branch fired before the MFA require/lookup.
expect(moduleRefGet).not.toHaveBeenCalled();
expect(mfaModuleState.checkMfaRequirements).not.toHaveBeenCalled();
});
});
describe('community build: EE MFA module NOT bundled', () => {
it('passes (no throw) when SSO is not enforced and the lazy require fails (no MFA gate)', async () => {
// mfaModuleState.bundled === false -> the virtual mock factory throws,
// exactly like require() of a missing EE module on a community build.
const { service, gate } = makeService({});
const moduleRefGet = (
service as unknown as { moduleRef: { get: jest.Mock } }
).moduleRef.get;
await expect(
gate({ id: 'ws-1', enforceSso: false }, CREDS),
).resolves.toBeUndefined();
// The require() failed, so the gate returned before touching moduleRef.
expect(moduleRefGet).not.toHaveBeenCalled();
});
});
describe('EE MFA module bundled', () => {
it('rejects with a "use a Bearer token" signal when the user has MFA enabled', async () => {
const check = jest.fn().mockResolvedValue({
userHasMfa: true,
requiresMfaSetup: false,
});
mfaModuleState = { bundled: true, checkMfaRequirements: check };
const { gate } = makeService({ checkMfaRequirements: check });
const promise = gate({ id: 'ws-1', enforceSso: false }, CREDS);
await expect(promise).rejects.toBeInstanceOf(UnauthorizedException);
await expect(
gate({ id: 'ws-1', enforceSso: false }, CREDS),
).rejects.toThrow(/Bearer access token/i);
// The real requirement check was consulted with the creds + workspace.
expect(check).toHaveBeenCalledWith(
CREDS,
{ id: 'ws-1', enforceSso: false },
undefined,
);
});
it('rejects when the workspace enforces MFA (requiresMfaSetup)', async () => {
// requiresMfaSetup === true models a workspace that enforces MFA for a
// user who has not set it up yet; the Basic path cannot complete it.
const check = jest.fn().mockResolvedValue({
userHasMfa: false,
requiresMfaSetup: true,
});
mfaModuleState = { bundled: true, checkMfaRequirements: check };
const { gate } = makeService({ checkMfaRequirements: check });
await expect(
gate({ id: 'ws-1', enforceSso: false }, CREDS),
).rejects.toThrow(/Bearer access token/i);
});
it('passes when the user has no MFA and the workspace does not enforce it', async () => {
const check = jest.fn().mockResolvedValue({
userHasMfa: false,
requiresMfaSetup: false,
});
mfaModuleState = { bundled: true, checkMfaRequirements: check };
const { gate } = makeService({ checkMfaRequirements: check });
await expect(
gate({ id: 'ws-1', enforceSso: false }, CREDS),
).resolves.toBeUndefined();
// The bundled module's requirement check WAS consulted (proving we took
// the bundled branch, not the community no-op branch).
expect(check).toHaveBeenCalledTimes(1);
});
it('passes when checkMfaRequirements returns a falsy result (no requirement flags)', async () => {
// Defensive: a bundled module that returns undefined must not reject.
const check = jest.fn().mockResolvedValue(undefined);
mfaModuleState = { bundled: true, checkMfaRequirements: check };
const { gate } = makeService({ checkMfaRequirements: check });
await expect(
gate({ id: 'ws-1', enforceSso: false }, CREDS),
).resolves.toBeUndefined();
});
});
});
@@ -9,6 +9,9 @@ import {
sharedTokenMatches,
clientIp,
bindAccessJwtVerifier,
extractBearer,
decideBasicGate,
mapAuthResultToResponse,
McpAuthDeps,
} from './mcp-auth.helpers';
import { JwtType } from '../../core/auth/dto/jwt-payload';
@@ -79,6 +82,26 @@ describe('parseBasicAuth', () => {
});
});
describe('extractBearer', () => {
it('extracts the token from a "Bearer <token>" header', () => {
expect(extractBearer('Bearer abc.def.ghi')).toBe('abc.def.ghi');
});
it('is case-insensitive on the scheme (lowercase + uppercase)', () => {
// The split keeps the token as-is; only the scheme is compared lowercased.
expect(extractBearer('bearer abc')).toBe('abc');
expect(extractBearer('BEARER abc')).toBe('abc');
});
it('returns undefined for a non-Bearer scheme (e.g. Basic)', () => {
expect(extractBearer('Basic abc')).toBeUndefined();
});
it('returns undefined for an undefined header', () => {
expect(extractBearer(undefined)).toBeUndefined();
});
});
describe('isCredentialsFailure', () => {
it('is true for the credentials-mismatch UnauthorizedException', () => {
expect(
@@ -185,6 +208,43 @@ describe('FailedLoginLimiter', () => {
expect(lim.isBlocked(k, 0)).toBe(true);
expect(lim.isBlocked(k, 1000)).toBe(false);
});
describe('sweep (expired-bucket eviction, injectable clock)', () => {
// sweep() drops buckets whose windowStart is older than windowMs so
// never-revisited keys cannot accumulate forever. It takes an injectable
// `now` so the behaviour is deterministic without faking timers.
it('drops a bucket strictly older than windowMs', () => {
const lim = new FailedLoginLimiter(5, 1000);
// Seed a bucket at t=0 (windowStart=0).
lim.recordFailure('stale', 0);
// Sweep well past the window: now - windowStart = 5000 >= 1000 -> dropped.
lim.sweep(5000);
// A dropped bucket means a brand-new bucket is created on next touch, so
// the prior failure count is gone (a single fresh failure is far from 5).
lim.recordFailure('stale', 5001);
expect(lim.isBlocked('stale', 5001)).toBe(false);
});
it('drops a bucket exactly at the windowMs boundary (>= is inclusive)', () => {
const lim = new FailedLoginLimiter(1, 1000);
lim.recordFailure('boundary', 0); // windowStart=0, blocked at threshold 1
expect(lim.isBlocked('boundary', 0)).toBe(true);
// now - windowStart = 1000 == windowMs -> the >= check evicts it.
lim.sweep(1000);
// Re-touch at the same instant: a fresh bucket (count 0) is created, so the
// key is no longer blocked, proving the boundary bucket was swept.
expect(lim.isBlocked('boundary', 1000)).toBe(false);
});
it('retains a fresh bucket still within the window', () => {
const lim = new FailedLoginLimiter(1, 1000);
lim.recordFailure('fresh', 0); // windowStart=0
// now - windowStart = 999 < 1000 -> the bucket survives the sweep.
lim.sweep(999);
// Still blocked because the bucket (and its count) was retained.
expect(lim.isBlocked('fresh', 999)).toBe(true);
});
});
});
describe('verifyBearerAccess (Bearer revocation/disabled checks)', () => {
@@ -264,6 +324,31 @@ describe('verifyBearerAccess (Bearer revocation/disabled checks)', () => {
),
).rejects.toThrow('jwt expired');
});
// Item 3: bind the Bearer token to THIS instance's workspace (mirrors
// JwtStrategy). A token whose workspaceId claim differs from the instance
// workspace must be rejected; matching/absent expectedWorkspaceId is allowed.
it('rejects a token from a DIFFERENT workspace when expectedWorkspaceId is set', async () => {
await expect(
verifyBearerAccess('t', {
...bearerDeps(),
expectedWorkspaceId: 'ws-OTHER',
}),
).rejects.toThrow(UnauthorizedException);
});
it('accepts a token whose workspace matches expectedWorkspaceId', async () => {
const res = await verifyBearerAccess('t', {
...bearerDeps(),
expectedWorkspaceId: 'ws-1',
});
expect(res).toEqual({ sub: 'user-1', email: 'u@e.com' });
});
it('does NOT enforce a workspace when expectedWorkspaceId is undefined (single-workspace no-op)', async () => {
const res = await verifyBearerAccess('t', bearerDeps());
expect(res).toEqual({ sub: 'user-1', email: 'u@e.com' });
});
});
describe('resolveMcpSessionConfig', () => {
@@ -587,23 +672,48 @@ describe('resolveMcpSessionConfig', () => {
});
});
describe('isInitializeRequestBody (session-INIT detection)', () => {
it('true only for a single JSON-RPC object with method === "initialize"', () => {
expect(isInitializeRequestBody({ jsonrpc: '2.0', method: 'initialize' })).toBe(
true,
);
// A full, valid JSON-RPC InitializeRequest as the @modelcontextprotocol/sdk
// `isInitializeRequest` predicate (which isInitializeRequestBody now delegates
// to) requires: jsonrpc + id + method === 'initialize' + params.protocolVersion.
const fullInitializeRequest = {
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'test-client', version: '1.0.0' },
},
};
describe('isInitializeRequestBody (session-INIT detection, matches SDK predicate)', () => {
it('true for a FULL valid InitializeRequest (the SDK predicate signal)', () => {
expect(isInitializeRequestBody(fullInitializeRequest)).toBe(true);
});
it('false for a bare { method: "initialize" } with no id/params (item 1)', () => {
// Item 1: this previously returned true (method-only check) and let an
// authenticated client POST a params-less body with no mcp-session-id, which
// ran the side-effecting login() before http.ts 400'd it. The SDK predicate
// rejects it (no id, no params.protocolVersion), so it no longer mints a
// session / audit row.
expect(isInitializeRequestBody({ method: 'initialize' })).toBe(false);
expect(
isInitializeRequestBody({ jsonrpc: '2.0', method: 'initialize' }),
).toBe(false);
expect(
isInitializeRequestBody({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }),
).toBe(false);
});
it('false for a non-initialize method (e.g. tools/call)', () => {
expect(
isInitializeRequestBody({ jsonrpc: '2.0', method: 'tools/call' }),
isInitializeRequestBody({ ...fullInitializeRequest, method: 'tools/call' }),
).toBe(false);
});
it('false for a batch (array) body, null/undefined, or a non-object', () => {
expect(
isInitializeRequestBody([{ jsonrpc: '2.0', method: 'initialize' }]),
).toBe(false);
expect(isInitializeRequestBody([fullInitializeRequest])).toBe(false);
expect(isInitializeRequestBody(undefined)).toBe(false);
expect(isInitializeRequestBody(null)).toBe(false);
expect(isInitializeRequestBody('initialize')).toBe(false);
@@ -618,8 +728,14 @@ describe('isSessionInit decision (no mcp-session-id AND initialize body)', () =>
const decide = (sessionId: string | undefined, body: unknown): boolean =>
!sessionId && isInitializeRequestBody(body);
it('no header + initialize body -> init', () => {
expect(decide(undefined, { method: 'initialize' })).toBe(true);
it('no header + full initialize body -> init', () => {
expect(decide(undefined, fullInitializeRequest)).toBe(true);
});
it('no header + bare params-less initialize body -> NOT init (item 1)', () => {
// A header-less { method: 'initialize' } with no params is no longer treated
// as an init by the SDK predicate, so it does not mint a session via login().
expect(decide(undefined, { method: 'initialize' })).toBe(false);
});
it('no header + non-initialize body -> NOT init (verifyCredentials path)', () => {
@@ -627,7 +743,7 @@ describe('isSessionInit decision (no mcp-session-id AND initialize body)', () =>
});
it('has session-id -> never init regardless of body', () => {
expect(decide('sess-1', { method: 'initialize' })).toBe(false);
expect(decide('sess-1', fullInitializeRequest)).toBe(false);
});
});
@@ -769,3 +885,138 @@ describe('bindAccessJwtVerifier enforces JwtType.ACCESS (item 3)', () => {
expect(res).toEqual({ sub: 'user-1', email: undefined });
});
});
describe('decideBasicGate (pure SSO/MFA pre-token gate, refactor R1)', () => {
// The pure decision extracted out of McpService.enforceBasicLoginGate. It is
// tested WITHOUT ModuleRef and WITHOUT an on-disk EE MFA module: the SSO verdict
// and the MFA requirement result are passed in as plain values.
it('SSO enforced -> throws Unauthorized ("enforced SSO")', () => {
expect(() => decideBasicGate({ ssoEnforced: true })).toThrow(
UnauthorizedException,
);
expect(() => decideBasicGate({ ssoEnforced: true })).toThrow(/enforced SSO/);
// SSO takes precedence even if MFA flags are also set.
expect(() =>
decideBasicGate({ ssoEnforced: true, mfa: { userHasMfa: true } }),
).toThrow(/enforced SSO/);
});
it('no SSO + no MFA module (mfa undefined) -> resolves (Basic allowed)', () => {
// A community/fork build with no EE MFA module passes mfa: undefined and the
// gate must allow the password login (same as the controller with no MFA).
expect(() => decideBasicGate({ ssoEnforced: false })).not.toThrow();
expect(() =>
decideBasicGate({ ssoEnforced: false, mfa: undefined }),
).not.toThrow();
});
it('MFA present + userHasMfa -> rejects ("use a Bearer access token")', () => {
expect(() =>
decideBasicGate({ ssoEnforced: false, mfa: { userHasMfa: true } }),
).toThrow(/use a Bearer access token/);
expect(() =>
decideBasicGate({ ssoEnforced: false, mfa: { userHasMfa: true } }),
).toThrow(UnauthorizedException);
});
it('MFA present + requiresMfaSetup -> rejects', () => {
expect(() =>
decideBasicGate({ ssoEnforced: false, mfa: { requiresMfaSetup: true } }),
).toThrow(/use a Bearer access token/);
});
it('MFA present but none required (both flags false) -> resolves', () => {
expect(() =>
decideBasicGate({
ssoEnforced: false,
mfa: { userHasMfa: false, requiresMfaSetup: false },
}),
).not.toThrow();
});
});
describe('mapAuthResultToResponse (handle status/body mapping, refactor R2)', () => {
// The pure response decision extracted out of McpService.handle. It maps the
// pre-hijack gauntlet (shared token, enablement, auth error) to either a fixed
// JSON error response or the hijack path — never leaking the password/header.
it('wrong X-MCP-Token -> 401 {error:"Unauthorized"} and NOT the hijack path', () => {
const d = mapAuthResultToResponse({ sharedTokenOk: false, enabled: true });
expect(d).toEqual({
kind: 'respond',
status: 401,
body: { error: 'Unauthorized' },
});
});
it('workspace MCP disabled -> 403', () => {
const d = mapAuthResultToResponse({ sharedTokenOk: true, enabled: false });
expect(d.kind).toBe('respond');
if (d.kind === 'respond') {
expect(d.status).toBe(403);
expect(d.body).toEqual({ error: 'MCP is disabled for this workspace' });
}
});
it('an UnauthorizedException -> 401 with err.message; no password/header leaked', () => {
// Construct an UnauthorizedException whose message is the SPECIFIC auth reason.
const err = new UnauthorizedException('Email or password does not match');
const d = mapAuthResultToResponse({
sharedTokenOk: true,
enabled: true,
error: err,
});
expect(d).toEqual({
kind: 'respond',
status: 401,
body: { error: 'Email or password does not match' },
});
// The surfaced body is ONLY the exception message — never the raw secret.
if (d.kind === 'respond') {
const serialized = JSON.stringify(d.body);
expect(serialized).not.toContain('password=');
expect(serialized).not.toContain('Authorization');
expect(serialized).not.toContain('Basic ');
expect(serialized).not.toContain('Bearer ');
}
});
it('a non-Unauthorized error -> 500 generic (no error detail surfaced)', () => {
const err = new Error('db blew up: connection string secret');
const d = mapAuthResultToResponse({
sharedTokenOk: true,
enabled: true,
error: err,
});
expect(d).toEqual({
kind: 'respond',
status: 500,
body: { error: 'Internal server error' },
});
// The generic body must NOT echo the underlying error message.
if (d.kind === 'respond') {
expect(d.body.error).not.toContain('secret');
}
});
it('happy path (auth resolved, no error) -> hijack', () => {
const d = mapAuthResultToResponse({ sharedTokenOk: true, enabled: true });
expect(d).toEqual({ kind: 'hijack' });
});
it('shared-token failure takes precedence over disabled/error', () => {
// Even with a disabled workspace and an error, a bad shared token is the
// first gate, so the response is the uniform 401 Unauthorized.
const d = mapAuthResultToResponse({
sharedTokenOk: false,
enabled: false,
error: new UnauthorizedException('should not surface'),
});
expect(d).toEqual({
kind: 'respond',
status: 401,
body: { error: 'Unauthorized' },
});
});
});
+94 -68
View File
@@ -25,6 +25,8 @@ import {
sharedTokenMatches,
clientIp,
bindAccessJwtVerifier,
decideBasicGate,
mapAuthResultToResponse,
DocmostMcpConfig,
ResolvedMcpAuth,
} from './mcp-auth.helpers';
@@ -154,6 +156,15 @@ export class McpService implements OnModuleDestroy {
private async verifyMcpBearer(
token: string,
): Promise<{ sub?: string; email?: string }> {
// Resolve THIS instance's workspace so verifyBearerAccess can bind the
// token's `workspaceId` claim to it (mirrors JwtStrategy). The community
// build is single-workspace (findFirst), so this is the default workspace
// and the check is a no-op here; it only rejects a foreign-workspace token
// in a multi-workspace deployment. Undefined (no workspace configured) means
// no check — the credentials path would already have failed with no
// workspace, and an undefined here keeps the helper a no-op rather than
// rejecting every token.
const instanceWorkspace = await this.workspaceRepo.findFirst();
// The revocation/disabled decision logic lives in the framework-free
// verifyBearerAccess helper (unit-testable without the heavy auth graph);
// this method only wires in the concrete TokenService + repos.
@@ -163,6 +174,7 @@ export class McpService implements OnModuleDestroy {
verifyJwt: bindAccessJwtVerifier(this.tokenService) as (
t: string,
) => Promise<JwtPayload>,
expectedWorkspaceId: instanceWorkspace?.id,
findUser: (sub, workspaceId) =>
this.userRepo.findById(sub, workspaceId),
findActiveSession: (sessionId) =>
@@ -231,49 +243,54 @@ export class McpService implements OnModuleDestroy {
workspace: Workspace,
creds: { email: string; password: string },
): Promise<void> {
// 1) SSO enforcement. validateSsoEnforcement throws BadRequestException; we
// re-surface it as Unauthorized so the /mcp 401 path is consistent and a
// token is never issued.
// 1) SSO enforcement. validateSsoEnforcement throws when the workspace
// enforces SSO; we only need the boolean verdict for the pure decision.
let ssoEnforced = false;
try {
validateSsoEnforcement(workspace);
} catch {
throw new UnauthorizedException(
'This workspace has enforced SSO login. Use SSO; MCP HTTP Basic is not allowed.',
);
ssoEnforced = true;
}
// 2) MFA gate — lazy-require the EE module exactly like AuthController.login.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let MfaModule: any;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
MfaModule = require('./../../ee/mfa/services/mfa.service');
} catch {
// No EE MFA module bundled in this build: same as the controller -> no
// MFA gate. (A community/fork build has no MFA, so Basic is allowed.)
return;
// On a fork WITHOUT the EE module bundled, mfaResult stays undefined and the
// pure gate behaves exactly like the controller (no MFA module -> no MFA
// gate). We only LOAD the module + read the requirement flags here; the
// accept/reject decision lives in the framework-free decideBasicGate so the
// SSO/MFA logic is unit-testable without ModuleRef or the on-disk EE module.
let mfaResult: { userHasMfa?: boolean; requiresMfaSetup?: boolean } | undefined;
// Only consult the MFA module when SSO has not already disqualified the
// request (SSO short-circuits, and skipping the load avoids a needless
// require on the SSO-reject path).
if (!ssoEnforced) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let MfaModule: any;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
MfaModule = require('./../../ee/mfa/services/mfa.service');
} catch {
// No EE MFA module bundled in this build: same as the controller -> no
// MFA gate. (A community/fork build has no MFA, so Basic is allowed.)
MfaModule = undefined;
}
if (MfaModule) {
const mfaService = this.moduleRef.get(MfaModule.MfaService, {
strict: false,
});
// Same requirement check the controller uses. We pass NO FastifyReply
// (the controller passes `res` only to set a cookie on the no-MFA happy
// path, which we never take here): we only read the requirement flags.
mfaResult = await mfaService.checkMfaRequirements(
creds,
workspace,
undefined,
);
}
}
const mfaService = this.moduleRef.get(MfaModule.MfaService, {
strict: false,
});
// Use the same requirement check the controller uses. We pass NO FastifyReply
// (the controller passes `res` only to set a cookie on the no-MFA happy path,
// which we never take here): we only read the requirement flags. Be tolerant
// of either a (loginInput, workspace) or (loginInput, workspace, res) shape.
const mfaResult = await mfaService.checkMfaRequirements(
creds,
workspace,
undefined,
);
if (mfaResult && (mfaResult.userHasMfa || mfaResult.requiresMfaSetup)) {
throw new UnauthorizedException(
'This account requires multi-factor authentication. MCP HTTP Basic ' +
'cannot complete MFA — log in normally and use a Bearer access token ' +
'instead.',
);
}
// Pure accept/reject decision (throws UnauthorizedException on rejection).
decideBasicGate({ ssoEnforced, mfa: mfaResult });
}
// Lazily create the HTTP handler exactly once. The import is indirected so
@@ -333,52 +350,61 @@ export class McpService implements OnModuleDestroy {
// matching `X-MCP-Token` header. It now lives in its OWN header so it never
// collides with `Authorization`, which carries the per-user credentials.
const sharedToken = process.env.MCP_TOKEN;
if (sharedToken) {
const provided = req.headers['x-mcp-token'];
if (!sharedTokenMatches(sharedToken, provided)) {
res.status(401).send({ error: 'Unauthorized' });
return;
}
}
const sharedTokenOk = sharedToken
? sharedTokenMatches(sharedToken, req.headers['x-mcp-token'])
: true;
if (!(await this.isEnabled())) {
res.status(403).send({ error: 'MCP is disabled for this workspace' });
return;
}
// Short-circuit checks (shared token, enablement) that do not need the auth
// resolution. Compute them up front so the response mapping is a single pure
// decision (mapAuthResultToResponse) that cannot leak the password/header.
const enabled = sharedTokenOk ? await this.isEnabled() : false;
// Resolve + validate the per-session identity BEFORE hijacking the response
// so bad credentials surface as a clean 401 JSON (never a torn response and
// never a generic "MCP error"). The resolved config/identity is stashed on
// the raw request for the package's resolver + identify hook to read back.
let resolved: ResolvedMcpAuth;
try {
resolved = await this.resolveSessionConfig(req);
} catch (err) {
if (err instanceof UnauthorizedException) {
// Warn once if the only thing missing is the service account, to keep
// the original operator hint.
if (
!this.credsConfigured() &&
!req.headers['authorization'] &&
!this.warnedMissingCreds
) {
this.warnedMissingCreds = true;
this.logger.warn(
'MCP is enabled but received a request with no credentials and no ' +
'MCP_DOCMOST_EMAIL/MCP_DOCMOST_PASSWORD service account configured.',
);
let resolved: ResolvedMcpAuth | undefined;
let authError: unknown;
if (sharedTokenOk && enabled) {
try {
resolved = await this.resolveSessionConfig(req);
} catch (err) {
authError = err;
if (err instanceof UnauthorizedException) {
// Warn once if the only thing missing is the service account, to keep
// the original operator hint.
if (
!this.credsConfigured() &&
!req.headers['authorization'] &&
!this.warnedMissingCreds
) {
this.warnedMissingCreds = true;
this.logger.warn(
'MCP is enabled but received a request with no credentials and no ' +
'MCP_DOCMOST_EMAIL/MCP_DOCMOST_PASSWORD service account configured.',
);
}
} else {
this.logger.error('MCP auth resolution failed', err as Error);
}
res.status(401).send({ error: err.message });
return;
}
this.logger.error('MCP auth resolution failed', err as Error);
res.status(500).send({ error: 'Internal server error' });
}
// Pure status/body mapping for the whole pre-hijack gauntlet.
const decision = mapAuthResultToResponse({
sharedTokenOk,
enabled,
error: authError,
});
if (decision.kind === 'respond') {
res.status(decision.status).send(decision.body);
return;
}
// Stash the resolved auth on the raw request so the package's resolver +
// identify hook (wired in getHandler) read it back instead of re-parsing.
(req.raw as unknown as Record<symbol, unknown>)[MCP_RESOLVED] = resolved;
(req.raw as unknown as Record<symbol, unknown>)[MCP_RESOLVED] =
resolved as ResolvedMcpAuth;
// Hand the raw Node req/res to the MCP transport. hijack() tells Fastify
// to stop managing this response so the transport can write to it directly.
@@ -3,6 +3,7 @@ import { PageWsListener } from './page-ws.listener';
import { WsTreeService } from '../ws-tree.service';
import {
PageEvent,
PageMovedEvent,
TreeNodeSnapshot,
} from '../../database/listeners/page.listener';
@@ -93,3 +94,139 @@ describe('PageWsListener.onPageCreated', () => {
expect(wsTree.broadcastRefetchRoot).not.toHaveBeenCalled();
});
});
describe('PageWsListener delete/move/restore handlers', () => {
let listener: PageWsListener;
let wsTree: {
broadcastPageCreated: jest.Mock;
broadcastPageDeleted: jest.Mock;
broadcastPageMoved: jest.Mock;
broadcastRefetchRoot: jest.Mock;
};
let warnSpy: jest.SpyInstance;
const secondSnapshot: TreeNodeSnapshot = {
id: 'page-2',
slugId: 'slug-2',
title: 'World',
icon: '📁',
position: 'a2',
spaceId: 'space-1',
parentPageId: null,
};
beforeEach(async () => {
wsTree = {
broadcastPageCreated: jest.fn().mockResolvedValue(undefined),
broadcastPageDeleted: jest.fn().mockResolvedValue(undefined),
broadcastPageMoved: jest.fn().mockResolvedValue(undefined),
broadcastRefetchRoot: jest.fn().mockResolvedValue(undefined),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
PageWsListener,
{ provide: WsTreeService, useValue: wsTree },
],
}).compile();
listener = module.get<PageWsListener>(PageWsListener);
// The PAGE_RESTORED-without-spaceId branch logs a warning; silence + assert.
warnSpy = jest
.spyOn(listener['logger'], 'warn')
.mockImplementation(() => undefined);
});
afterEach(() => {
warnSpy.mockRestore();
});
// --- onPageDeleted (PAGE_SOFT_DELETED / PAGE_DELETED) ---
it('onPageDeleted with N `pages`: one broadcastPageDeleted per page', async () => {
const event: PageEvent = {
pageIds: ['page-1', 'page-2'],
workspaceId: 'ws-1',
pages: [snapshot, secondSnapshot],
};
await listener.onPageDeleted(event);
expect(wsTree.broadcastPageDeleted).toHaveBeenCalledTimes(2);
expect(wsTree.broadcastPageDeleted).toHaveBeenNthCalledWith(1, snapshot);
expect(wsTree.broadcastPageDeleted).toHaveBeenNthCalledWith(
2,
secondSnapshot,
);
});
it('onPageDeleted with an EMPTY `pages` array: no broadcast', async () => {
const event: PageEvent = {
pageIds: ['page-1'],
workspaceId: 'ws-1',
pages: [],
};
await listener.onPageDeleted(event);
expect(wsTree.broadcastPageDeleted).not.toHaveBeenCalled();
});
it('onPageDeleted with UNDEFINED `pages`: no broadcast (no crash)', async () => {
const event: PageEvent = {
pageIds: ['page-1'],
workspaceId: 'ws-1',
};
await listener.onPageDeleted(event);
expect(wsTree.broadcastPageDeleted).not.toHaveBeenCalled();
});
// --- onPageMoved (PAGE_MOVED) ---
it('onPageMoved: forwards the whole event to a single broadcastPageMoved', async () => {
const event: PageMovedEvent = {
workspaceId: 'ws-1',
oldParentId: 'old-parent',
hasChildren: false,
node: { ...snapshot, parentPageId: 'new-parent', position: 'a5' },
};
await listener.onPageMoved(event);
expect(wsTree.broadcastPageMoved).toHaveBeenCalledTimes(1);
expect(wsTree.broadcastPageMoved).toHaveBeenCalledWith(event);
});
// --- onPageRestored (PAGE_RESTORED) ---
it('onPageRestored WITHOUT spaceId: warns and does NOT refetch', async () => {
const event: PageEvent = {
pageIds: ['page-1'],
workspaceId: 'ws-1',
};
await listener.onPageRestored(event);
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('PAGE_RESTORED'),
);
expect(wsTree.broadcastRefetchRoot).not.toHaveBeenCalled();
});
it('onPageRestored WITH spaceId: one broadcastRefetchRoot scoped to the space', async () => {
const event: PageEvent = {
pageIds: ['page-1'],
workspaceId: 'ws-1',
spaceId: 'space-9',
};
await listener.onPageRestored(event);
expect(warnSpy).not.toHaveBeenCalled();
expect(wsTree.broadcastRefetchRoot).toHaveBeenCalledTimes(1);
expect(wsTree.broadcastRefetchRoot).toHaveBeenCalledWith('space-9');
});
});
+259
View File
@@ -0,0 +1,259 @@
import { Test, TestingModule } from '@nestjs/testing';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { WsService } from './ws.service';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import {
getSpaceRoomName,
WS_SPACE_RESTRICTION_CACHE_PREFIX,
WS_CACHE_TTL_MS,
} from './ws.utils';
/**
* WsService server-side unit tests (M7 item 2):
* - spaceHasRestrictions cache lifecycle (miss -> read+set with TTL; hit ->
* no re-read; documents the stale-false window).
* - broadcastToAuthorizedUsers fan-out (authorized-only delivery, multi-socket
* fan-out per user, sockets with no userId skipped).
*
* Both private methods are exercised through their public entry points:
* spaceHasRestrictions via emitTreeEvent, broadcastToAuthorizedUsers via
* emitToAuthorizedUsers. WsService is constructed with mocked cache + repo and a
* mocked socket.io server, so no live infra is needed.
*/
describe('WsService.spaceHasRestrictions (cache lifecycle, via emitTreeEvent)', () => {
let service: WsService;
let pagePermissionRepo: {
hasRestrictedPagesInSpace: jest.Mock;
hasRestrictedAncestor: jest.Mock;
getUserIdsWithPageAccess: jest.Mock;
};
let cache: { get: jest.Mock; set: jest.Mock; del: jest.Mock };
let roomEmit: jest.Mock;
beforeEach(async () => {
pagePermissionRepo = {
hasRestrictedPagesInSpace: jest.fn(),
hasRestrictedAncestor: jest.fn(),
getUserIdsWithPageAccess: jest.fn(),
};
cache = {
get: jest.fn().mockResolvedValue(null),
set: jest.fn().mockResolvedValue(undefined),
del: jest.fn().mockResolvedValue(undefined),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
WsService,
{ provide: PagePermissionRepo, useValue: pagePermissionRepo },
{ provide: CACHE_MANAGER, useValue: cache },
],
}).compile();
service = module.get<WsService>(WsService);
roomEmit = jest.fn();
const server = {
to: jest.fn().mockReturnValue({ emit: roomEmit }),
in: jest.fn().mockReturnValue({ fetchSockets: jest.fn() }),
};
service.setServer(server as never);
});
const cacheKey = (spaceId: string): string =>
`${WS_SPACE_RESTRICTION_CACHE_PREFIX}${spaceId}`;
it('first call MISSES the cache -> reads the repo and sets it with WS_CACHE_TTL_MS', async () => {
cache.get.mockResolvedValue(null); // miss
pagePermissionRepo.hasRestrictedPagesInSpace.mockResolvedValue(true);
pagePermissionRepo.hasRestrictedAncestor.mockResolvedValue(false);
await service.emitTreeEvent('space-1', 'page-1', { op: 'x' });
expect(cache.get).toHaveBeenCalledWith(cacheKey('space-1'));
expect(pagePermissionRepo.hasRestrictedPagesInSpace).toHaveBeenCalledTimes(1);
expect(pagePermissionRepo.hasRestrictedPagesInSpace).toHaveBeenCalledWith(
'space-1',
);
// The freshly-read verdict is cached with the 30s TTL.
expect(cache.set).toHaveBeenCalledWith(
cacheKey('space-1'),
true,
WS_CACHE_TTL_MS,
);
});
it('second call HITS the cache -> the repo is NOT re-read', async () => {
// Cache hit returns false (no restrictions) -> open-space fast path.
cache.get.mockResolvedValue(false);
await service.emitTreeEvent('space-1', 'page-1', { op: 'x' });
expect(cache.get).toHaveBeenCalledWith(cacheKey('space-1'));
// The whole point of the cache: no repo read on a hit.
expect(pagePermissionRepo.hasRestrictedPagesInSpace).not.toHaveBeenCalled();
expect(cache.set).not.toHaveBeenCalled();
// false verdict -> broadcast to the whole room (open-space fast path).
expect(roomEmit).toHaveBeenCalledWith('message', { op: 'x' });
});
it('a cached `false` is returned even when restrictions now exist (the stale window)', async () => {
// The cache says "no restrictions" (false) but the repo, if asked, would now
// say true. spaceHasRestrictions trusts the cached false and never re-reads —
// this documents the up-to-TTL stale window the production comment warns about
// (a payload can fan out room-wide until the cache is invalidated/expires).
cache.get.mockResolvedValue(false);
pagePermissionRepo.hasRestrictedPagesInSpace.mockResolvedValue(true);
await service.emitTreeEvent('space-1', 'page-1', { op: 'stale' });
expect(pagePermissionRepo.hasRestrictedPagesInSpace).not.toHaveBeenCalled();
// Treated as open -> the event is broadcast to the WHOLE room.
expect(roomEmit).toHaveBeenCalledWith('message', { op: 'stale' });
});
it('caches a `false` verdict too (so the next emit hits, not re-reads)', async () => {
cache.get.mockResolvedValueOnce(null); // first call: miss
pagePermissionRepo.hasRestrictedPagesInSpace.mockResolvedValue(false);
await service.emitTreeEvent('space-2', 'page-9', { op: 'y' });
expect(cache.set).toHaveBeenCalledWith(
cacheKey('space-2'),
false,
WS_CACHE_TTL_MS,
);
});
});
describe('WsService.broadcastToAuthorizedUsers fan-out (via emitToAuthorizedUsers)', () => {
let service: WsService;
let pagePermissionRepo: {
hasRestrictedPagesInSpace: jest.Mock;
hasRestrictedAncestor: jest.Mock;
getUserIdsWithPageAccess: jest.Mock;
};
let cache: { get: jest.Mock; set: jest.Mock; del: jest.Mock };
let fetchSockets: jest.Mock;
let serverIn: jest.Mock;
beforeEach(async () => {
pagePermissionRepo = {
hasRestrictedPagesInSpace: jest.fn(),
hasRestrictedAncestor: jest.fn(),
getUserIdsWithPageAccess: jest.fn(),
};
cache = {
get: jest.fn().mockResolvedValue(null),
set: jest.fn().mockResolvedValue(undefined),
del: jest.fn().mockResolvedValue(undefined),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
WsService,
{ provide: PagePermissionRepo, useValue: pagePermissionRepo },
{ provide: CACHE_MANAGER, useValue: cache },
],
}).compile();
service = module.get<WsService>(WsService);
fetchSockets = jest.fn();
serverIn = jest.fn().mockReturnValue({ fetchSockets });
const server = {
to: jest.fn().mockReturnValue({ emit: jest.fn() }),
in: serverIn,
};
service.setServer(server as never);
});
it('only sockets whose userId is in getUserIdsWithPageAccess receive the event', async () => {
pagePermissionRepo.getUserIdsWithPageAccess.mockResolvedValue(['user-ok']);
const okEmit = jest.fn();
const noEmit = jest.fn();
fetchSockets.mockResolvedValue([
{ id: 's1', data: { userId: 'user-ok' }, emit: okEmit },
{ id: 's2', data: { userId: 'user-no' }, emit: noEmit },
]);
const data = { operation: 'moveTreeNode' };
await service.emitToAuthorizedUsers('space-1', 'page-1', data);
// The authorized set is resolved from the candidate userIds present on the
// sockets (deduped), then only those users' sockets get the event.
expect(pagePermissionRepo.getUserIdsWithPageAccess).toHaveBeenCalledWith(
'page-1',
expect.arrayContaining(['user-ok', 'user-no']),
);
expect(okEmit).toHaveBeenCalledWith('message', data);
expect(noEmit).not.toHaveBeenCalled();
});
it('a user with TWO sockets receives the event on BOTH (userSocketMap fan-out)', async () => {
pagePermissionRepo.getUserIdsWithPageAccess.mockResolvedValue(['user-ok']);
const tab1 = jest.fn();
const tab2 = jest.fn();
fetchSockets.mockResolvedValue([
{ id: 's1', data: { userId: 'user-ok' }, emit: tab1 },
{ id: 's2', data: { userId: 'user-ok' }, emit: tab2 },
]);
const data = { operation: 'moveTreeNode' };
await service.emitToAuthorizedUsers('space-1', 'page-1', data);
// Both of the authorized user's sockets (e.g. two browser tabs) receive it.
expect(tab1).toHaveBeenCalledWith('message', data);
expect(tab2).toHaveBeenCalledWith('message', data);
// The candidate set is deduped to a single userId even with two sockets.
expect(pagePermissionRepo.getUserIdsWithPageAccess).toHaveBeenCalledWith(
'page-1',
['user-ok'],
);
});
it('a socket with NO userId is skipped (not a candidate, never emitted to)', async () => {
pagePermissionRepo.getUserIdsWithPageAccess.mockResolvedValue(['user-ok']);
const okEmit = jest.fn();
const anonEmit = jest.fn();
fetchSockets.mockResolvedValue([
{ id: 's1', data: { userId: 'user-ok' }, emit: okEmit },
// Unauthenticated socket: no userId -> excluded from the candidate map.
{ id: 's2', data: {}, emit: anonEmit },
]);
const data = { operation: 'moveTreeNode' };
await service.emitToAuthorizedUsers('space-1', 'page-1', data);
expect(okEmit).toHaveBeenCalledWith('message', data);
expect(anonEmit).not.toHaveBeenCalled();
// The no-userId socket is not even offered as a candidate to the repo.
expect(pagePermissionRepo.getUserIdsWithPageAccess).toHaveBeenCalledWith(
'page-1',
['user-ok'],
);
});
it('no sockets in the room -> no repo lookup, no emit', async () => {
fetchSockets.mockResolvedValue([]);
await service.emitToAuthorizedUsers('space-1', 'page-1', { op: 'x' });
expect(pagePermissionRepo.getUserIdsWithPageAccess).not.toHaveBeenCalled();
});
it('routes through the space room name', async () => {
pagePermissionRepo.getUserIdsWithPageAccess.mockResolvedValue([]);
fetchSockets.mockResolvedValue([
{ id: 's1', data: { userId: 'u' }, emit: jest.fn() },
]);
await service.emitToAuthorizedUsers('space-7', 'page-1', { op: 'x' });
expect(serverIn).toHaveBeenCalledWith(getSpaceRoomName('space-7'));
});
});
+106
View File
@@ -329,3 +329,109 @@ describe('WsService.emitTreeEvent', () => {
expect(anonEmit).toHaveBeenCalledWith('message', data);
});
});
describe('move-into-restricted disjointness contract (WsTreeService + real WsService)', () => {
// CONTRACT: a move under a restricted ancestor PARTITIONS the room. The
// authorized set (gets the moveTreeNode via emitToAuthorizedUsers) and its
// complement (gets the deleteTreeNode via emitDeleteToUnauthorized) are
// disjoint and together cover every socket — and an anonymous (no-userId)
// socket lands in the delete set. We wire a REAL WsService (only its repo,
// cache and socket server mocked) so both broadcasts run against the SAME fixed
// socket set, the way they do in production.
let treeService: WsTreeService;
let pagePermissionRepo: {
hasRestrictedPagesInSpace: jest.Mock;
hasRestrictedAncestor: jest.Mock;
getUserIdsWithPageAccess: jest.Mock;
};
// Fixed room: two authorized users (one with two sockets), one unauthorized
// user, one anonymous socket.
const moveSeen: string[] = [];
const deleteSeen: string[] = [];
const mkSocket = (id: string, userId: string | undefined) => ({
id,
data: userId ? { userId } : {},
emit: jest.fn((_event: string, payload: { operation: string }) => {
if (payload.operation === 'moveTreeNode') moveSeen.push(id);
if (payload.operation === 'deleteTreeNode') deleteSeen.push(id);
}),
});
const sockets = [
mkSocket('s-ok-1', 'user-ok'), // authorized, tab 1
mkSocket('s-ok-2', 'user-ok'), // authorized, tab 2 (fan-out)
mkSocket('s-no', 'user-no'), // unauthorized
mkSocket('s-anon', undefined), // anonymous (no userId)
];
beforeEach(async () => {
moveSeen.length = 0;
deleteSeen.length = 0;
pagePermissionRepo = {
hasRestrictedPagesInSpace: jest.fn().mockResolvedValue(true),
// The move destination IS under a restricted ancestor.
hasRestrictedAncestor: jest.fn().mockResolvedValue(true),
// Only user-ok is authorized to see the page.
getUserIdsWithPageAccess: jest.fn().mockResolvedValue(['user-ok']),
};
const cache = {
get: jest.fn().mockResolvedValue(null),
set: jest.fn().mockResolvedValue(undefined),
del: jest.fn().mockResolvedValue(undefined),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
WsTreeService,
WsService,
{ provide: PagePermissionRepo, useValue: pagePermissionRepo },
{ provide: CACHE_MANAGER, useValue: cache },
],
}).compile();
const wsService = module.get<WsService>(WsService);
const server = {
to: jest.fn().mockReturnValue({ emit: jest.fn() }),
in: jest.fn().mockReturnValue({
fetchSockets: jest.fn().mockResolvedValue(sockets),
}),
};
wsService.setServer(server as never);
treeService = module.get<WsTreeService>(WsTreeService);
});
it('authorized set (move) and complement (delete) partition the room; anon is in delete', async () => {
const event: PageMovedEvent = {
workspaceId: 'ws-1',
oldParentId: 'old-parent',
hasChildren: false,
node: { ...snapshot, parentPageId: 'restricted-parent', position: 'a5' },
};
await treeService.broadcastPageMoved(event);
const moveSet = new Set(moveSeen);
const deleteSet = new Set(deleteSeen);
// Authorized user's BOTH sockets got the move; nobody else did.
expect(moveSet).toEqual(new Set(['s-ok-1', 's-ok-2']));
// Everyone else (unauthorized + anonymous) got the delete.
expect(deleteSet).toEqual(new Set(['s-no', 's-anon']));
// DISJOINT: no socket received both a move and a delete.
const intersection = [...moveSet].filter((id) => deleteSet.has(id));
expect(intersection).toEqual([]);
// PARTITION: the two sets together cover every socket in the room exactly.
const union = new Set([...moveSet, ...deleteSet]);
expect(union).toEqual(new Set(sockets.map((s) => s.id)));
// The anonymous socket specifically lands in the DELETE set, never the move.
expect(deleteSet.has('s-anon')).toBe(true);
expect(moveSet.has('s-anon')).toBe(false);
});
});
@@ -0,0 +1,93 @@
# Отложенные тесты по фичам с коммита 053a9c0d (хвост от PR #49)
## Контекст
PR #49 («test: cover features since 053a9c0d + repair test tooling») закрыл
основную массу покрытия новых фич gitmost (+~330 тестов: server/Jest,
client/Vitest, editor-ext/Vitest, packages/mcp/node:test) и починил
тест-инструментарий (FIX-0 сломанные спеки transclusion, BUILD-0 сборка
editor-ext перед серверными тестами, INFRA-0 резолв `.tsx` email-шаблонов).
Часть тестов из принятого тест-плана **намеренно отложена** — им нужен
тестовый Postgres, реальный Redis или HTTP/e2e-харнес, которых в проекте
сейчас нет, либо инвазивный рефактор продакшн-кода. Ниже — что осталось и
почему, чтобы не потерять.
---
## 1. Интеграционные тесты против БД (нужен тестовый Postgres)
Сейчас все repo-зависимые проверки делаются на моках; SQL-уровень не
исполняется. Чтобы покрыть это честно, нужен поднимаемый в CI Postgres
(testcontainers или сервис в pipeline) + хелпер миграций.
- **`AiAgentRoleRepo` — изоляция и индексы.**
`apps/server/src/database/repos/ai-agent-roles/ai-agent-roles.repo.ts`.
Проверить против реальной БД: `findById`/`listByWorkspace` исключают
soft-deleted строки; `findById` для roleId из ЧУЖОГО workspace → undefined
(tenant-изоляция); дубль имени в одном workspace → 23505; то же имя
переиспользуемо после softDelete (partial unique index
`WHERE deleted_at IS NULL`, миграция `20260620T120000-ai-agent-roles.ts`);
одинаковое имя в разных workspace разрешено. Это «хребет» безопасности —
сейчас только предполагается unit-моками.
- **`AiChatRepo.findByCreator` — join role-badge.**
`apps/server/src/database/repos/ai-chat/ai-chat.repo.ts` (~:27-70).
Чат с enabled-ролью → roleName/roleEmoji заполнены; с soft-deleted ролью →
бейдж NULL; с DISABLED ролью → бейдж NULL (должно совпадать с
`resolveRoleForRequest`); ORDER BY квалифицирован `aiChats.*` (нет
ambiguous column после join). Не проверяемо чистым unit-ом.
- **`WorkspaceService.update` / `WorkspaceRepo.updateSetting` — jsonb-merge.**
`apps/server/src/core/workspace/services/workspace.service.ts` (~:514),
`apps/server/src/database/repos/workspace/workspace.repo.ts` (~:275).
Сейчас покрыта только форма вызова сервиса
(`workspace-html-embed.spec.ts`). Не покрыто (нужна БД): `htmlEmbed:true`
персистится через jsonb-merge **не затирая** соседние настройки (ai,
sharing). Это и есть «kill-switch пишется» — критично, что write-половина
тоггла не ломает остальной settings-namespace.
- **FK `page_template_references` onDelete('cascade').**
Миграция `20260620T131000-page-template-references.ts`. Проверить, что
удаление source/reference-страницы каскадит строки ссылок.
## 2. HTTP / e2e-харнес (его нет в apps/server)
- **Public-share ассистент: обход per-IP throttle ротацией XFF, но
per-workspace cap держит.**
Контроллер использует стоковый `@UseGuards(ThrottlerGuard)`
(`apps/server/src/core/ai-chat/public-share-chat.controller.ts`), IP берётся
из Fastify `trustProxy``X-Forwarded-For`. Единственный оправданный e2e
(named journey «аноним спамит ассистента»): ротация XFF обходит per-IP
лимит 5/min, но per-workspace cost-cap всё равно отдаёт 429. Требует
поднятого HTTP-слоя Nest + trusted-proxy конфигурации.
- **Достоверность Lua-окна cost-cap против реального Redis.**
`apps/server/src/core/ai-chat/public-share-workspace-limiter.ts`
(`SLIDING_WINDOW_LUA`). Сейчас cap тестируется против TS-реализации
`FakeRedis` в `public-share-chat.spec.ts` — баг в самой Lua-строке
(`>=` vs `>`, неверный PEXPIRE) не поймается. Нужен интеграционный тест
против реального/testcontainers Redis.
## 3. Полная интеграция `AiChatService.stream` (рефактор R1-stream)
`apps/server/src/core/ai-chat/ai-chat.service.ts`. В PR #49 извлечён и
покрыт только чистый `buildErrorAssistantRecord`. Полные интеграционные
сценарии — **запись чата, упавшего на первом ходу** (onError), жизненный
цикл external-MCP клиентов (закрытие при throw/onFinish), и
**история восстанавливается из БД, а не из `body.messages`** (анти-tamper) —
требуют сидирования SDK `streamText` (инъекция/seam колбэков `onError`/
`onFinish`/`onAbort` + `res.hijack`). Отложено, чтобы не дестабилизировать
287-строчный `stream()`; делать вместе с выносом testable turn-pipeline.
---
## Сопутствующие НЕ-тестовые находки
Вынесены в отдельные issues (всплыли во время написания тестов):
- #52 — ai-roles: нет серверной валидации модели роли + дрейф enum драйверов.
- #53 — ws: `invalidateSpaceRestrictionCache` без вызывающих (30с stale-окно).
- #54 — page-embed: серверный guard глубины/циклов раскрытия.
- #55 — transclusion: cycle-guard в `collectPageEmbedsFromPmJson`.
- #56 — test-infra: jest DI + lib0 ESM (16 падающих сьютов).
+1
View File
@@ -35,4 +35,5 @@ export * from "./lib/status";
export * from "./lib/pdf";
export * from "./lib/page-break";
export * from "./lib/resizable-nodeview";
export * from "./lib/footnote";
@@ -0,0 +1,72 @@
import { mergeAttributes, Node } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { FOOTNOTE_DEFINITION_NAME } from "./footnote-util";
export interface FootnoteDefinitionOptions {
HTMLAttributes: Record<string, any>;
view: any;
}
/**
* A single footnote definition: an editable block (paragraphs only, no nested
* footnotes) keyed by `id` to its reference. Lives only inside `footnotesList`.
*/
export const FootnoteDefinition = Node.create<FootnoteDefinitionOptions>({
name: FOOTNOTE_DEFINITION_NAME,
// paragraph+ keeps definitions simple. Note this does NOT block nested
// footnote references on its own: a footnoteReference is inline and the
// paragraphs here accept inline content, so the schema would permit one.
// Nested references are instead prevented by the setFootnote command and the
// sync plugin (which refuse to create/keep a reference inside a definition).
content: "paragraph+",
defining: true,
isolating: true,
selectable: false,
addOptions() {
return {
HTMLAttributes: {},
view: null,
};
},
addAttributes() {
return {
id: {
default: null,
parseHTML: (element) => element.getAttribute("data-id"),
renderHTML: (attributes) => {
if (!attributes.id) return {};
return { "data-id": attributes.id };
},
},
};
},
parseHTML() {
return [
{
tag: "div[data-footnote-def]",
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(
{ "data-footnote-def": "", class: "footnote-def" },
this.options.HTMLAttributes,
HTMLAttributes,
),
0,
];
},
addNodeView() {
if (!this.options.view) return null;
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view);
},
});
@@ -0,0 +1,140 @@
import { describe, it, expect } from "vitest";
import { htmlToMarkdown } from "../markdown/utils/turndown.utils";
import { markdownToHtml } from "../markdown/utils/marked.utils";
import { extractFootnoteDefinitions } from "../markdown/utils/footnote.marked";
// HTML the editor-ext nodes render (sup[data-footnote-ref], section/div).
const HTML =
`<p>Water<sup data-footnote-ref data-id="fn1"></sup> and clay<sup data-footnote-ref data-id="fn2"></sup>.</p>` +
`<section data-footnotes>` +
`<div data-footnote-def data-id="fn1"><p>First note.</p></div>` +
`<div data-footnote-def data-id="fn2"><p>Second note.</p></div>` +
`</section>`;
describe("footnote markdown round-trip", () => {
it("HTML -> Markdown produces pandoc footnote syntax", () => {
const md = htmlToMarkdown(HTML);
expect(md).toContain("[^fn1]");
expect(md).toContain("[^fn2]");
expect(md).toContain("[^fn1]: First note.");
expect(md).toContain("[^fn2]: Second note.");
});
it("Markdown -> HTML rebuilds the footnote nodes' HTML", async () => {
const md = htmlToMarkdown(HTML);
const html = await markdownToHtml(md);
expect(html).toContain('data-footnote-ref data-id="fn1"');
expect(html).toContain('data-footnote-ref data-id="fn2"');
expect(html).toContain("data-footnotes");
expect(html).toContain('data-footnote-def data-id="fn1"');
expect(html).toContain("First note.");
expect(html).toContain("Second note.");
});
it("preserves a [^id]: line shown inside a fenced code block (not a definition)", async () => {
// A document that DOCUMENTS footnote syntax inside a code fence. The
// `[^demo]: ...` line is example text, not a real definition, and must
// survive the Markdown -> HTML conversion verbatim.
const md = [
"Here is how footnotes look:",
"",
"```markdown",
"Some text[^demo]",
"",
"[^demo]: this is the definition",
"```",
"",
"End of doc.",
].join("\n");
const html = await markdownToHtml(md);
// The example definition line is kept inside the rendered code block.
expect(html).toContain("[^demo]: this is the definition");
// It did NOT get pulled out into a real footnotes section.
expect(html).not.toContain("data-footnotes");
expect(html).not.toContain("data-footnote-def");
});
it("extractFootnoteDefinitions de-duplicates colliding ids and rewrites markers", () => {
// Two definitions share id `d`, and the body has two `[^d]` markers. The
// output must keep BOTH definitions with DISTINCT ids and rewrite the second
// marker so the (reference, definition) pairing stays 1:1.
const md = [
"See here[^d] and there[^d].",
"",
"[^d]: first",
"[^d]: second",
].join("\n");
const { body, section } = extractFootnoteDefinitions(md);
// Pull out the def ids from the section in order.
const defIds = Array.from(
section.matchAll(/data-footnote-def data-id="([^"]+)"/g),
).map((m) => m[1]);
expect(defIds.length).toBe(2);
expect(new Set(defIds).size).toBe(2); // distinct
expect(defIds[0]).toBe("d"); // first definition keeps the id
// Both definition texts survive.
expect(section).toContain("first");
expect(section).toContain("second");
// The body still has two markers, now pointing at the two distinct ids.
const refIds = Array.from(body.matchAll(/\[\^([^\]\s]+)\]/g)).map(
(m) => m[1],
);
expect(refIds.length).toBe(2);
expect(refIds.sort()).toEqual(defIds.sort());
});
it("extractFootnoteDefinitions dedups DETERMINISTICALLY (same input -> same ids)", () => {
// The derived id must be a pure function of the input markdown so importing
// the same source twice (or via the editor and the MCP mirror) yields
// identical ids — never random/time-based.
const md = [
"See[^d] one[^d] two[^d].",
"",
"[^d]: first",
"[^d]: second",
"[^d]: third",
].join("\n");
const run = () => {
const { body, section } = extractFootnoteDefinitions(md);
const defIds = Array.from(
section.matchAll(/data-footnote-def data-id="([^"]+)"/g),
).map((m) => m[1]);
const refIds = Array.from(body.matchAll(/\[\^([^\]\s]+)\]/g)).map(
(m) => m[1],
);
return { defIds, refIds };
};
const a = run();
const b = run();
// Identical across runs (this is what would FAIL on the random-id version).
expect(a.defIds).toEqual(b.defIds);
expect(a.refIds).toEqual(b.refIds);
// Deterministic derived scheme: keeper "d", duplicates "d__2", "d__3".
expect(a.defIds).toEqual(["d", "d__2", "d__3"]);
expect(a.refIds.sort()).toEqual(a.defIds.sort());
});
it("markdownToHtml with duplicate ids renders two distinct footnote defs", async () => {
const md = [
"See here[^d] and there[^d].",
"",
"[^d]: first",
"[^d]: second",
].join("\n");
const html = await markdownToHtml(md);
const defIds = Array.from(
html.matchAll(/data-footnote-def data-id="([^"]+)"/g),
).map((m) => m[1]);
expect(defIds.length).toBe(2);
expect(new Set(defIds).size).toBe(2);
expect(html).toContain("first");
expect(html).toContain("second");
});
});

Some files were not shown because too many files have changed in this diff Show More