diff --git a/apps/client/src/features/notification/notification.utils.test.ts b/apps/client/src/features/notification/notification.utils.test.ts index 14d99d0e..2cfd7f55 100644 --- a/apps/client/src/features/notification/notification.utils.test.ts +++ b/apps/client/src/features/notification/notification.utils.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import i18n from "@/i18n.ts"; import { + formatRelativeTime, getTimeGroup, groupNotificationsByTime, } from "@/features/notification/notification.utils.ts"; @@ -132,3 +134,59 @@ describe("groupNotificationsByTime", () => { expect(groupNotificationsByTime([], labels)).toEqual([]); }); }); + +describe("formatRelativeTime — relative buckets and absolute-date fallback", () => { + // Distinct fixed clock for the relative formatter (uses Date.now via `new + // Date()`), so the bucket boundaries are deterministic under fake timers. + const NOW = new Date("2026-06-15T12:00:00.000Z"); + const MIN = 60_000; + + beforeEach(() => { + vi.setSystemTime(NOW); + }); + + // ISO string `ms` milliseconds before NOW. + function ago(ms: number): string { + return new Date(NOW.getTime() - ms).toISOString(); + } + + it("returns the i18n 'now' label for anything under a minute", () => { + expect(formatRelativeTime(ago(0))).toBe(i18n.t("now")); + expect(formatRelativeTime(ago(59_000))).toBe(i18n.t("now")); + }); + + it("crosses into the minutes bucket exactly at 1 minute", () => { + expect(formatRelativeTime(ago(MIN - 1000))).toBe(i18n.t("now")); + expect(formatRelativeTime(ago(MIN))).toBe("1m"); + expect(formatRelativeTime(ago(5 * MIN))).toBe("5m"); + expect(formatRelativeTime(ago(59 * MIN))).toBe("59m"); + }); + + it("crosses into the hours bucket exactly at 60 minutes", () => { + expect(formatRelativeTime(ago(60 * MIN - 1000))).toBe("59m"); + expect(formatRelativeTime(ago(HOUR))).toBe("1h"); + expect(formatRelativeTime(ago(23 * HOUR))).toBe("23h"); + }); + + it("crosses into the days bucket exactly at 24 hours", () => { + expect(formatRelativeTime(ago(24 * HOUR - 1000))).toBe("23h"); + expect(formatRelativeTime(ago(DAY))).toBe("1d"); + expect(formatRelativeTime(ago(6 * DAY))).toBe("6d"); + }); + + it("falls back to an absolute short date once >= 7 days old", () => { + // 6d -> still relative; 7d -> absolute date (no longer N[mhd], and equal to + // the localized short-date of the source timestamp). + expect(formatRelativeTime(ago(6 * DAY))).toBe("6d"); + + const sevenDaysAgo = ago(7 * DAY); + const result = formatRelativeTime(sevenDaysAgo); + expect(result).not.toMatch(/^\d+[mhd]$/); + expect(result).not.toBe(i18n.t("now")); + const expected = new Intl.DateTimeFormat(i18n.language, { + month: "short", + day: "numeric", + }).format(new Date(sevenDaysAgo)); + expect(result).toBe(expected); + }); +}); diff --git a/apps/client/src/features/page/tree/utils/find-breadcrumb-path.test.ts b/apps/client/src/features/page/tree/utils/find-breadcrumb-path.test.ts new file mode 100644 index 00000000..0dff0f79 --- /dev/null +++ b/apps/client/src/features/page/tree/utils/find-breadcrumb-path.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from "vitest"; +import { findBreadcrumbPath } from "./utils"; +import type { SpaceTreeNode } from "@/features/page/tree/types.ts"; + +// findBreadcrumbPath walks the live, SHARED sidebar tree. The high-value +// invariant: when a node has no usable name it must surface "Untitled" ONLY on +// the returned breadcrumb chain via a shallow copy — never by mutating the input +// node (which would silently rename the node in the sidebar). Also covers normal +// ancestor-chain resolution, the not-found case, and nested children. + +function node(id: string, over: Partial = {}): 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: [], + ...over, + }; +} + +describe("findBreadcrumbPath", () => { + it("does NOT mutate the input tree when a node has an empty/whitespace name", () => { + // A whitespace-only-named node nested under a blank-named root. + const target = node("target", { name: " " }); + const root = node("root", { name: "", hasChildren: true, children: [target] }); + const tree = [root]; + + const result = findBreadcrumbPath(tree, "target"); + + expect(result).not.toBeNull(); + // The RETURNED chain shows "Untitled" for both blank nodes. + expect(result!.map((n) => n.name)).toEqual(["Untitled", "Untitled"]); + // The original input nodes are untouched (still blank). + expect(root.name).toBe(""); + expect(target.name).toBe(" "); + // The renamed breadcrumb entries are fresh copies, not the input objects. + expect(result![0]).not.toBe(root); + expect(result![1]).not.toBe(target); + }); + + it("returns the SAME node reference (no copy) when the name is non-empty", () => { + // No rename needed -> the node is passed through by reference (cheap path). + const target = node("target", { name: "Real Title" }); + const result = findBreadcrumbPath([target], "target"); + expect(result![0]).toBe(target); + expect(result![0].name).toBe("Real Title"); + }); + + it("resolves the full ancestor chain ending at the target", () => { + const target = node("c"); + const mid = node("b", { hasChildren: true, children: [target] }); + const root = node("a", { hasChildren: true, children: [mid] }); + const result = findBreadcrumbPath([root], "c"); + expect(result!.map((n) => n.id)).toEqual(["a", "b", "c"]); + }); + + it("finds a target nested under a deeper sibling branch", () => { + // Two root branches; the target lives inside the second branch's child. + const target = node("deep"); + const branch2 = node("r2", { + hasChildren: true, + children: [node("x"), node("y", { hasChildren: true, children: [target] })], + }); + const branch1 = node("r1", { hasChildren: true, children: [node("z")] }); + const result = findBreadcrumbPath([branch1, branch2], "deep"); + expect(result!.map((n) => n.id)).toEqual(["r2", "y", "deep"]); + }); + + it("returns null when the page id is not present in the tree", () => { + const root = node("root", { hasChildren: true, children: [node("child")] }); + expect(findBreadcrumbPath([root], "missing")).toBeNull(); + expect(findBreadcrumbPath([], "anything")).toBeNull(); + }); +}); diff --git a/apps/client/src/features/page/tree/utils/utils.test.ts b/apps/client/src/features/page/tree/utils/utils.test.ts index 4ea181b5..0eced376 100644 --- a/apps/client/src/features/page/tree/utils/utils.test.ts +++ b/apps/client/src/features/page/tree/utils/utils.test.ts @@ -8,6 +8,8 @@ import { closeIds, mergeRootTrees, loadedOpenBranchIds, + sortPositionKeys, + pageToTreeNode, } from "./utils"; import type { IPage } from "@/features/page/types/page.types.ts"; import type { SpaceTreeNode } from "@/features/page/tree/types.ts"; @@ -60,6 +62,82 @@ function treeNode(id: string, children: SpaceTreeNode[] = []): SpaceTreeNode { }; } +describe("sortPositionKeys", () => { + it("orders items ascending by their fractional `position` string", () => { + const items = [ + { id: "c", position: "a5" }, + { id: "a", position: "a1" }, + { id: "b", position: "a3" }, + ]; + expect(sortPositionKeys(items).map((i) => i.id)).toEqual(["a", "b", "c"]); + }); + + it("is a stable sort: equal positions keep their input order", () => { + const items = [ + { id: "x", position: "a1" }, + { id: "y", position: "a1" }, + { id: "z", position: "a1" }, + ]; + expect(sortPositionKeys(items).map((i) => i.id)).toEqual(["x", "y", "z"]); + }); +}); + +describe("pageToTreeNode", () => { + function pageRow(over: Partial = {}): IPage { + return { + id: "p1", + slugId: "slug-p1", + title: "My Page", + icon: "📄", + position: "a1", + hasChildren: true, + spaceId: "space-1", + parentPageId: null as unknown as string, + ...over, + } as IPage; + } + + it("maps page.title -> node.name and copies the core fields", () => { + const node = pageToTreeNode(pageRow()); + // The non-trivial transform: a page's `title` becomes the tree node's `name`. + expect(node.name).toBe("My Page"); + expect(node.id).toBe("p1"); + expect(node.slugId).toBe("slug-p1"); + expect(node.icon).toBe("📄"); + expect(node.position).toBe("a1"); + expect(node.spaceId).toBe("space-1"); + expect(node.hasChildren).toBe(true); + // Always materialized with an empty children array. + expect(node.children).toEqual([]); + }); + + it("derives canEdit from page.permissions.canEdit when the flat field is absent", () => { + const node = pageToTreeNode( + pageRow({ canEdit: undefined, permissions: { canEdit: true } } as Partial), + ); + expect(node.canEdit).toBe(true); + }); + + it("prefers the flat page.canEdit over permissions.canEdit", () => { + const node = pageToTreeNode( + pageRow({ canEdit: false, permissions: { canEdit: true } } as Partial), + ); + expect(node.canEdit).toBe(false); + }); + + it("carries temporaryExpiresAt straight off the page", () => { + const expiresAt = "2026-06-27T21:00:00.000Z"; + expect(pageToTreeNode(pageRow({ temporaryExpiresAt: expiresAt })).temporaryExpiresAt).toBe( + expiresAt, + ); + }); + + it("applies overrides on top of the mapped fields (e.g. optimistic blank name)", () => { + const node = pageToTreeNode(pageRow(), { name: "" }); + expect(node.name).toBe(""); + }); +}); + describe("buildTree", () => { it("builds one node per unique page", () => { const tree = buildTree([page("a", "a1"), page("b", "a2")]); diff --git a/apps/client/src/features/page/tree/utils/utils.ts b/apps/client/src/features/page/tree/utils/utils.ts index 11600162..d5084b00 100644 --- a/apps/client/src/features/page/tree/utils/utils.ts +++ b/apps/client/src/features/page/tree/utils/utils.ts @@ -70,18 +70,22 @@ export function findBreadcrumbPath( path: SpaceTreeNode[] = [], ): SpaceTreeNode[] | null { for (const node of tree) { - if (!node.name || node.name.trim() === "") { - node.name = "Untitled"; - } + // Never mutate the input tree (it is the live, shared sidebar tree state). + // When a node has no usable name, surface "Untitled" via a shallow copy that + // only the returned breadcrumb chain sees — the source node stays untouched. + const displayNode: SpaceTreeNode = + !node.name || node.name.trim() === "" + ? { ...node, name: "Untitled" } + : node; if (node.id === pageId) { - return [...path, node]; + return [...path, displayNode]; } if (node.children) { const newPath = findBreadcrumbPath(node.children, pageId, [ ...path, - node, + displayNode, ]); if (newPath) { return newPath; diff --git a/apps/client/src/features/websocket/tree-socket-reducers.test.ts b/apps/client/src/features/websocket/tree-socket-reducers.test.ts index 81c62b56..ae93a714 100644 --- a/apps/client/src/features/websocket/tree-socket-reducers.test.ts +++ b/apps/client/src/features/websocket/tree-socket-reducers.test.ts @@ -3,6 +3,7 @@ import { applyAddTreeNode, applyMoveTreeNode, applyDeleteTreeNode, + applyUpdateOne, } from "./tree-socket-reducers"; import { treeModel } from "@/features/page/tree/model/tree-model"; import { SpaceTreeNode } from "@/features/page/tree/types.ts"; @@ -338,3 +339,76 @@ describe("applyAddTreeNode", () => { expect(treeModel.find(next, "temp")?.temporaryExpiresAt).toBe(expiresAt); }); }); + +describe("applyUpdateOne", () => { + // A loaded two-level tree so we can patch both a root and a nested node. + const buildTree = (): SpaceTreeNode[] => [ + node("root", { + position: "a0", + name: "Root", + icon: "📁", + hasChildren: true, + children: [node("child", { position: "a1", parentPageId: "root", name: "Child", icon: "📄" })], + }), + ]; + + // Build the UpdateEvent envelope; only `id`/`payload` matter to the reducer. + const ev = (id: string, payload: Record) => + ({ + operation: "updateOne", + spaceId: "space-1", + entity: ["pages"], + id, + payload, + }) as unknown as Parameters[1]; + + it("applies a title-only update to the node's name (icon untouched)", () => { + const tree = buildTree(); + const next = applyUpdateOne(tree, ev("child", { title: "Renamed" })); + const child = treeModel.find(next, "child"); + expect(child?.name).toBe("Renamed"); + // Icon is left as it was. + expect(child?.icon).toBe("📄"); + }); + + it("applies an icon-only update to the node's icon (name untouched)", () => { + const tree = buildTree(); + const next = applyUpdateOne(tree, ev("root", { icon: "🔥" })); + const root = treeModel.find(next, "root"); + expect(root?.icon).toBe("🔥"); + expect(root?.name).toBe("Root"); + }); + + it("applies a combined title + icon update", () => { + const tree = buildTree(); + const next = applyUpdateOne(tree, ev("child", { title: "Both", icon: "⭐" })); + const child = treeModel.find(next, "child"); + expect(child?.name).toBe("Both"); + expect(child?.icon).toBe("⭐"); + }); + + it("returns prev UNCHANGED (same reference) when the id is not loaded", () => { + const tree = buildTree(); + const next = applyUpdateOne(tree, ev("ghost", { title: "Nope" })); + expect(next).toBe(tree); + }); + + it("returns prev UNCHANGED (same reference) for a no-op payload (no title/icon)", () => { + // The node exists, but the payload carries neither title nor icon -> nothing + // to patch, so the reducer must hand back the same array reference. + const tree = buildTree(); + const next = applyUpdateOne(tree, ev("child", {})); + expect(next).toBe(tree); + }); + + it("treats an explicit null icon/title as a value to apply (undefined check, not truthiness)", () => { + // The reducer guards on `!== undefined`, so a clearing null IS applied. + const tree = buildTree(); + const next = applyUpdateOne(tree, ev("child", { title: "", icon: null })); + const child = treeModel.find(next, "child"); + expect(child?.name).toBe(""); + expect(child?.icon).toBeNull(); + // And it did change something -> a fresh reference, not prev. + expect(next).not.toBe(tree); + }); +}); diff --git a/apps/server/src/collaboration/yjs.util.spec.ts b/apps/server/src/collaboration/yjs.util.spec.ts new file mode 100644 index 00000000..29511d6d --- /dev/null +++ b/apps/server/src/collaboration/yjs.util.spec.ts @@ -0,0 +1,278 @@ +import * as Y from 'yjs'; +import { getSchema } from '@tiptap/core'; +import { + initProseMirrorDoc, + absolutePositionToRelativePosition, + prosemirrorJSONToYDoc, +} from '@tiptap/y-tiptap'; +import { tiptapExtensions } from './collaboration.util'; +import { + setYjsMark, + removeYjsMarkByAttribute, + updateYjsMarkAttribute, + type YjsSelection, +} from './yjs.util'; + +/** + * Unit tests for the server-side Yjs mark helpers used by the collaboration + * handler to set/resolve/delete comment marks directly on the shared Y.Doc + * (collaboration.handler.ts: setCommentMark / resolveCommentMark). + * + * The fragment shape mirrors production exactly: a `default` XmlFragment whose + * children are block XmlElements (paragraph) holding XmlText runs. For setYjsMark + * the selection is a pair of Yjs RelativePosition JSONs (what the client sends); + * we synthesize them from known ProseMirror absolute positions via + * absolutePositionToRelativePosition so the marked range is deterministic. + */ + +const schema = getSchema(tiptapExtensions); + +// Build a real Y.Doc from ProseMirror JSON (same path the collab handler uses +// via TiptapTransformer) and return the doc + its `default` fragment. +function buildFromPm(pmJson: unknown) { + const ydoc = prosemirrorJSONToYDoc( + schema, + pmJson as never, + 'default', + ) as unknown as Y.Doc; + const fragment = ydoc.getXmlFragment('default'); + return { ydoc, fragment }; +} + +// Make a YjsSelection (anchor/head RelativePosition JSON) for two ProseMirror +// absolute positions in `fragment`. +function selectionFor( + fragment: Y.XmlFragment, + anchorPos: number, + headPos: number, +): YjsSelection { + const { mapping } = initProseMirrorDoc(fragment, schema); + const anchor = absolutePositionToRelativePosition( + anchorPos, + fragment as never, + mapping, + ); + const head = absolutePositionToRelativePosition( + headPos, + fragment as never, + mapping, + ); + return { + anchor: Y.relativePositionToJSON(anchor), + head: Y.relativePositionToJSON(head), + }; +} + +// The XmlText run of the i-th top-level paragraph. +function paragraphText(fragment: Y.XmlFragment, index = 0): Y.XmlText { + const para = fragment.get(index) as Y.XmlElement; + return para.get(0) as Y.XmlText; +} + +// --- raw fragment builder for the remove/update tests (no schema needed) --- +// +// removeYjsMarkByAttribute / updateYjsMarkAttribute only read item.toDelta() and +// call item.format(); they never touch the ProseMirror schema. Build the runs +// directly so we control which segment carries which comment attrs. +function buildWithComments( + segments: Array<{ + text: string; + comment?: { commentId: string; resolved: boolean }; + }>, +): { fragment: Y.XmlFragment; text: Y.XmlText } { + const ydoc = new Y.Doc(); + const fragment = ydoc.getXmlFragment('default'); + const para = new Y.XmlElement('paragraph'); + fragment.insert(0, [para]); + const text = new Y.XmlText(); + para.insert(0, [text]); + let offset = 0; + for (const seg of segments) { + text.insert(offset, seg.text); + if (seg.comment) { + text.format(offset, seg.text.length, { comment: seg.comment }); + } + offset += seg.text.length; + } + return { fragment, text }; +} + +describe('setYjsMark', () => { + it('applies the mark over exactly the selected sub-range (PM pos 1..6 = "Hello")', () => { + const { ydoc, fragment } = buildFromPm({ + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'Hello world' }] }, + ], + }); + // PM pos 1 = start of the paragraph text; pos 6 = just after "Hello". + const sel = selectionFor(fragment, 1, 6); + + setYjsMark(ydoc as never, fragment, sel, 'comment', { + commentId: 'c1', + resolved: false, + }); + + // The run splits: "Hello" carries the comment mark, " world" stays clean. + expect(paragraphText(fragment).toDelta()).toEqual([ + { + insert: 'Hello', + attributes: { comment: { commentId: 'c1', resolved: false } }, + }, + { insert: ' world' }, + ]); + }); + + it('normalizes a reversed selection (head before anchor) to the same range', () => { + const { ydoc, fragment } = buildFromPm({ + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'Hello world' }] }, + ], + }); + // anchor=6, head=1 — reversed; setYjsMark takes min/max so it marks "Hello". + const sel = selectionFor(fragment, 6, 1); + + setYjsMark(ydoc as never, fragment, sel, 'comment', { + commentId: 'c2', + resolved: false, + }); + + expect(paragraphText(fragment).toDelta()).toEqual([ + { + insert: 'Hello', + attributes: { comment: { commentId: 'c2', resolved: false } }, + }, + { insert: ' world' }, + ]); + }); + + it('marks across two paragraphs (range spans an element boundary)', () => { + const { ydoc, fragment } = buildFromPm({ + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'aaa' }] }, + { type: 'paragraph', content: [{ type: 'text', text: 'bbb' }] }, + ], + }); + // PM positions: "aaa" = 1..4; the

boundary consumes pos 4 and 5, so + // "bbb" starts at pos 6 (chars at 6,7,8). Select pos 2 (inside "aaa") to pos + // 8 (after the second "b"). + const sel = selectionFor(fragment, 2, 8); + + setYjsMark(ydoc as never, fragment, sel, 'comment', { + commentId: 'c3', + resolved: false, + }); + + // First paragraph: "a" clean, "aa" marked. + expect(paragraphText(fragment, 0).toDelta()).toEqual([ + { insert: 'a' }, + { + insert: 'aa', + attributes: { comment: { commentId: 'c3', resolved: false } }, + }, + ]); + // Second paragraph: "bb" marked, "b" clean. + expect(paragraphText(fragment, 1).toDelta()).toEqual([ + { + insert: 'bb', + attributes: { comment: { commentId: 'c3', resolved: false } }, + }, + { insert: 'b' }, + ]); + }); +}); + +describe('removeYjsMarkByAttribute', () => { + it('removes only the run whose attribute value matches, leaving others', () => { + const { fragment, text } = buildWithComments([ + { text: 'AAA', comment: { commentId: 'c1', resolved: false } }, + { text: 'BBB', comment: { commentId: 'c2', resolved: false } }, + ]); + + removeYjsMarkByAttribute(fragment, 'comment', 'commentId', 'c1'); + + // c1's run loses the mark; c2's run is untouched. + expect(text.toDelta()).toEqual([ + { insert: 'AAA' }, + { + insert: 'BBB', + attributes: { comment: { commentId: 'c2', resolved: false } }, + }, + ]); + }); + + it('does nothing when no run carries the requested value (no-match branch)', () => { + const { fragment, text } = buildWithComments([ + { text: 'AAA', comment: { commentId: 'c1', resolved: false } }, + ]); + const before = text.toDelta(); + + removeYjsMarkByAttribute(fragment, 'comment', 'commentId', 'does-not-exist'); + + expect(text.toDelta()).toEqual(before); + }); + + it('leaves a different mark type alone', () => { + // A run carrying only `bold` must survive a comment removal pass. + const ydoc = new Y.Doc(); + const fragment = ydoc.getXmlFragment('default'); + const para = new Y.XmlElement('paragraph'); + fragment.insert(0, [para]); + const text = new Y.XmlText(); + para.insert(0, [text]); + text.insert(0, 'XYZ'); + text.format(0, 3, { bold: true }); + + removeYjsMarkByAttribute(fragment, 'comment', 'commentId', 'c1'); + + expect(text.toDelta()).toEqual([ + { insert: 'XYZ', attributes: { bold: true } }, + ]); + }); +}); + +describe('updateYjsMarkAttribute', () => { + it('merges new attributes into the matching run, preserving the rest', () => { + const { fragment, text } = buildWithComments([ + { text: 'AAA', comment: { commentId: 'c1', resolved: false } }, + { text: 'BBB', comment: { commentId: 'c2', resolved: false } }, + ]); + + updateYjsMarkAttribute( + fragment, + 'comment', + { name: 'commentId', value: 'c1' }, + { resolved: true }, + ); + + // c1's run flips resolved=true (commentId preserved via merge); c2 untouched. + expect(text.toDelta()).toEqual([ + { + insert: 'AAA', + attributes: { comment: { commentId: 'c1', resolved: true } }, + }, + { + insert: 'BBB', + attributes: { comment: { commentId: 'c2', resolved: false } }, + }, + ]); + }); + + it('does nothing when no run matches (no-match branch)', () => { + const { fragment, text } = buildWithComments([ + { text: 'AAA', comment: { commentId: 'c1', resolved: false } }, + ]); + const before = text.toDelta(); + + updateYjsMarkAttribute( + fragment, + 'comment', + { name: 'commentId', value: 'nope' }, + { resolved: true }, + ); + + expect(text.toDelta()).toEqual(before); + }); +}); diff --git a/apps/server/src/core/ai-chat/external-mcp/mcp-clients.service.spec.ts b/apps/server/src/core/ai-chat/external-mcp/mcp-clients.service.spec.ts new file mode 100644 index 00000000..53ad6191 --- /dev/null +++ b/apps/server/src/core/ai-chat/external-mcp/mcp-clients.service.spec.ts @@ -0,0 +1,166 @@ +import { McpClientsService } from './mcp-clients.service'; + +/** + * Unit tests for the two security-critical surfaces of McpClientsService that the + * sibling specs (ssrf-guard / validate-resolved-addresses / lease) do NOT cover: + * + * 1. `decryptHeaders` (private) — FAIL-OPEN behavior. A decrypt/parse failure + * (e.g. APP_SECRET rotated, tampered blob) must NEVER throw and must NEVER + * log the blob: it returns `undefined` so the connect proceeds WITHOUT the + * now-unreadable auth headers (which then 401s and the server is skipped), + * rather than crashing the whole turn. + * + * 2. `this.guardedFetch` (private, bound to the SSRF-pinned dispatcher) — the + * per-request DNS-rebinding guard. A blocked host (private/loopback/metadata + * IP literal, or an unparseable URL) must REJECT before any socket is opened; + * a public host is allowed through to the real `fetch` with the pinned + * dispatcher attached. + * + * No network and no DB: the repo + secretBox deps are stubbed, and global `fetch` + * is mocked for the single allow-path assertion. + */ + +// Build the service with a SecretBoxService stub whose decryptSecret is supplied +// per-test. The repo dep is unused by the methods under test. +function buildService(decryptSecret: (blob: string) => string) { + const secretBox = { decryptSecret: jest.fn(decryptSecret) }; + const service = new McpClientsService({} as never, secretBox as never); + return { service, secretBox }; +} + +describe('McpClientsService.decryptHeaders', () => { + // Reach the private method via the as-any pattern common in these NestJS specs. + const callDecrypt = ( + service: McpClientsService, + blob: string | null, + ): Record | undefined => + ( + service as unknown as { + decryptHeaders: (b: string | null) => Record | undefined; + } + ).decryptHeaders(blob); + + it('returns undefined for a null blob without decrypting', () => { + const { service, secretBox } = buildService(() => '{}'); + expect(callDecrypt(service, null)).toBeUndefined(); + expect(secretBox.decryptSecret).not.toHaveBeenCalled(); + }); + + it('decrypts a valid blob and keeps only string-valued headers', () => { + const { service } = buildService(() => + JSON.stringify({ + Authorization: 'Bearer abc', + 'X-Api-Key': 'k', + // Non-string values must be dropped, not coerced. + count: 5, + flag: true, + nested: { a: 1 }, + }), + ); + expect(callDecrypt(service, 'cipher')).toEqual({ + Authorization: 'Bearer abc', + 'X-Api-Key': 'k', + }); + }); + + it('returns undefined when the decrypted object has no string headers', () => { + const { service } = buildService(() => JSON.stringify({ count: 5 })); + // No usable headers -> undefined (connect with no auth header), not {}. + expect(callDecrypt(service, 'cipher')).toBeUndefined(); + }); + + it('FAILS OPEN: a decrypt error returns undefined instead of throwing', () => { + const { service } = buildService(() => { + throw new Error('Failed to decrypt secret — APP_SECRET may have changed'); + }); + const warnSpy = jest + .spyOn( + (service as unknown as { logger: { warn: (...a: unknown[]) => void } }) + .logger, + 'warn', + ) + .mockImplementation(() => undefined); + + let result: unknown; + expect(() => { + result = callDecrypt(service, 'tampered-blob'); + }).not.toThrow(); + expect(result).toBeUndefined(); + // It warns (so ops sees degradation) but never logs the blob itself. + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(String(warnSpy.mock.calls[0]?.[0])).not.toContain('tampered-blob'); + }); + + it('FAILS OPEN: malformed JSON (decrypts to non-JSON) returns undefined', () => { + const { service } = buildService(() => 'not-json{'); + jest + .spyOn( + (service as unknown as { logger: { warn: (...a: unknown[]) => void } }) + .logger, + 'warn', + ) + .mockImplementation(() => undefined); + expect(callDecrypt(service, 'cipher')).toBeUndefined(); + }); +}); + +describe('McpClientsService.guardedFetch (SSRF per-request guard)', () => { + // The bound guardedFetch closure lives on the instance as a private field. + const guardedFetchOf = (service: McpClientsService) => + (service as unknown as { guardedFetch: typeof fetch }).guardedFetch; + + let fetchSpy: jest.SpiedFunction; + + beforeEach(() => { + // Any reachable real fetch would be a network call; assert per-test that the + // blocked paths never reach it, and stub a Response for the allow path. + fetchSpy = jest + .spyOn(global, 'fetch') + .mockResolvedValue(new Response('ok', { status: 200 })); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + const blocked: Array<[string, string]> = [ + ['loopback IPv4', 'http://127.0.0.1/mcp'], + ['private 10/8', 'http://10.0.0.5/mcp'], + ['private 192.168/16', 'http://192.168.1.1/mcp'], + ['cloud metadata link-local', 'http://169.254.169.254/latest/meta-data/'], + ['loopback IPv6 (bracketed)', 'http://[::1]:8080/mcp'], + ]; + + it.each(blocked)( + 'rejects a request to %s without opening a socket', + async (_label, url) => { + const { service } = buildService(() => '{}'); + await expect(guardedFetchOf(service)(url)).rejects.toThrow( + /blocked request/, + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }, + ); + + it('rejects an unparseable URL as a blocked request', async () => { + const { service } = buildService(() => '{}'); + await expect( + guardedFetchOf(service)('::: not a url :::'), + ).rejects.toThrow('blocked request: invalid URL'); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('allows a public IP literal and forwards through the pinned dispatcher', async () => { + const { service } = buildService(() => '{}'); + const res = await guardedFetchOf(service)('http://8.8.8.8/mcp'); + + expect(res.status).toBe(200); + expect(fetchSpy).toHaveBeenCalledTimes(1); + // The init MUST carry the SSRF-pinned undici dispatcher (the rebinding pin); + // dropping it would let undici do a second, unchecked DNS resolution. + const init = fetchSpy.mock.calls[0][1] as RequestInit & { + dispatcher?: unknown; + }; + expect(init.dispatcher).toBeDefined(); + }); +}); diff --git a/apps/server/src/core/ai-chat/tools/shared-tool-specs.contract.spec.ts b/apps/server/src/core/ai-chat/tools/shared-tool-specs.contract.spec.ts new file mode 100644 index 00000000..31461717 --- /dev/null +++ b/apps/server/src/core/ai-chat/tools/shared-tool-specs.contract.spec.ts @@ -0,0 +1,124 @@ +import { z } from 'zod'; +import { AiChatToolsService } from './ai-chat-tools.service'; +import * as loader from './docmost-client.loader'; +import type { DocmostClientLike } from './docmost-client.loader'; +// The real zod-agnostic registry, imported from source so the contract is checked +// against exactly what the @docmost/mcp package ships (no hand-stub). +import { SHARED_TOOL_SPECS } from '../../../../../../packages/mcp/src/tool-specs'; + +/** + * CONTRACT: SHARED_TOOL_SPECS <-> in-app tool wiring parity. + * + * `packages/mcp/src/tool-specs.ts` is the single source of truth for the tools + * that are intentionally IDENTICAL across the standalone MCP server (zod v3) and + * the in-app AI-SDK service (zod v4). The in-app service builds each one via + * `sharedTool(sharedToolSpecs., execute)`, keyed by the spec's `inAppKey`. + * + * This test fails the build if a spec is added to the registry but never wired + * in-app, if an `inAppKey` is renamed without updating the service, if the + * description drifts between the registry and the exposed tool, if the + * snake_case `mcpName` <-> camelCase `inAppKey` convention is broken, or if the + * exposed tool's input-schema keys diverge from the spec's `buildShape`. + * + * It does NOT need @docmost/mcp built: the registry is imported from TS source, + * and the ESM loader is mocked so `forUser()` never dynamically imports the + * package. + */ +describe('SHARED_TOOL_SPECS contract parity', () => { + // Empty fake client: no tool is executed here — every assertion is on tool + // presence / metadata / schema, so the client methods are never called. + const fakeClient: Partial = {}; + const tokenServiceStub = { + generateAccessToken: jest.fn().mockResolvedValue('access-token'), + generateCollabToken: jest.fn().mockResolvedValue('collab-token'), + }; + + let tools: Record; + + beforeAll(async () => { + jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue({ + DocmostClient: function () { + return fakeClient as DocmostClientLike; + } as unknown as loader.DocmostClientCtor, + // Feed the service the SAME registry this test asserts against. + sharedToolSpecs: SHARED_TOOL_SPECS as unknown as Record< + string, + loader.SharedToolSpec + >, + }); + const service = new AiChatToolsService( + tokenServiceStub as never, + {} as never, + {} as never, + {} as never, + {} as never, + { asSink: () => ({ put: jest.fn(), has: jest.fn(), evict: jest.fn() }) } as never, + ); + tools = (await service.forUser( + { id: 'user-1', email: 'u@example.com', workspaceId: 'ws-1' } as never, + 'session-1', + 'ws-1', + 'chat-1', + )) as unknown as Record; + }); + + afterAll(() => jest.restoreAllMocks()); + + // camelCase -> snake_case, matching the registry's mcpName convention. + const toSnake = (s: string) => + s.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`); + + // Type as the (optional-buildShape) SharedToolSpec; the `satisfies` literal + // above otherwise narrows to a union where some members lack buildShape. + const specEntries = Object.entries(SHARED_TOOL_SPECS) as Array< + [string, loader.SharedToolSpec] + >; + + // Sanity: the registry is non-empty, so the per-spec table below is not vacuous. + it('registry is non-empty', () => { + expect(specEntries.length).toBeGreaterThan(0); + }); + + describe.each(specEntries)('spec "%s"', (registryKey, spec) => { + it('registry key equals its inAppKey', () => { + // The service indexes the registry by property name; a key != inAppKey + // would wire the wrong (or no) tool. + expect(spec.inAppKey).toBe(registryKey); + }); + + it('mcpName is the snake_case form of inAppKey', () => { + expect(spec.mcpName).toBe(toSnake(spec.inAppKey)); + }); + + it('is exposed in-app under its inAppKey', () => { + // Fails if a spec is added to the registry but never wired in forUser(). + expect(tools[spec.inAppKey]).toBeDefined(); + }); + + it("exposed tool's description matches the registry description", () => { + const tool = tools[spec.inAppKey] as { description: string }; + expect(tool.description).toBe(spec.description); + }); + + it("exposed tool's input-schema keys match buildShape (incl. required)", () => { + const tool = tools[spec.inAppKey] as { + inputSchema: { jsonSchema: { properties?: Record; required?: string[] } }; + }; + const json = tool.inputSchema.jsonSchema; + const actualKeys = Object.keys(json.properties ?? {}).sort(); + + // Derive the spec's declared shape with THIS layer's zod (v4) — the same + // call the service makes — then compare key sets and required-ness. + const shape = spec.buildShape ? spec.buildShape(z) : {}; + const expectedKeys = Object.keys(shape).sort(); + expect(actualKeys).toEqual(expectedKeys); + + // A non-.optional() field must surface as required in the advertised schema. + const expectedRequired = Object.entries(shape) + .filter(([, field]) => !(field as z.ZodTypeAny).isOptional?.()) + .map(([k]) => k) + .sort(); + expect((json.required ?? []).slice().sort()).toEqual(expectedRequired); + }); + }); +}); diff --git a/apps/server/src/integrations/storage/storage.service.spec.ts b/apps/server/src/integrations/storage/storage.service.spec.ts index 79db48c0..fc80246e 100644 --- a/apps/server/src/integrations/storage/storage.service.spec.ts +++ b/apps/server/src/integrations/storage/storage.service.spec.ts @@ -1,18 +1,110 @@ +import { Readable } from 'stream'; import { StorageService } from './storage.service'; +import type { StorageDriver } from './interfaces'; -// Direct instantiation with a stub driver. The Test.createTestingModule form -// failed to resolve the STORAGE_DRIVER_TOKEN at compile(); this smoke test only -// needs the service to construct. -describe('StorageService', () => { +/** + * StorageService is a thin facade over the injected StorageDriver: each public + * method must forward to the driver with the SAME arguments and return/await the + * driver's result unchanged (the read paths return it; the write paths await it). + * A mock driver lets us assert that delegation exactly, with no real S3/disk IO. + */ +describe('StorageService delegation', () => { + // Every driver method is a jest mock so we can assert call args + return passing. + function buildDriver(): jest.Mocked { + return { + upload: jest.fn().mockResolvedValue(undefined), + uploadStream: jest.fn().mockResolvedValue(undefined), + copy: jest.fn().mockResolvedValue(undefined), + read: jest.fn(), + readStream: jest.fn(), + readRangeStream: jest.fn(), + exists: jest.fn(), + getUrl: jest.fn(), + getSignedUrl: jest.fn(), + delete: jest.fn().mockResolvedValue(undefined), + getDriver: jest.fn(), + getDriverName: jest.fn(), + getConfig: jest.fn(), + } as unknown as jest.Mocked; + } + + let driver: jest.Mocked; let service: StorageService; beforeEach(() => { - service = new StorageService( - {} as any, // storageDriver - ); + driver = buildDriver(); + service = new StorageService(driver as unknown as StorageDriver); }); - it('should be defined', () => { - expect(service).toBeDefined(); + it('upload forwards path + content to the driver', async () => { + const buf = Buffer.from('data'); + await service.upload('a/b.png', buf); + expect(driver.upload).toHaveBeenCalledWith('a/b.png', buf); + }); + + it('uploadStream forwards path, stream and options', async () => { + const stream = Readable.from(['x']); + await service.uploadStream('a/b.bin', stream, { recreateClient: true }); + expect(driver.uploadStream).toHaveBeenCalledWith('a/b.bin', stream, { + recreateClient: true, + }); + }); + + it('copy forwards both paths', async () => { + await service.copy('from.txt', 'to.txt'); + expect(driver.copy).toHaveBeenCalledWith('from.txt', 'to.txt'); + }); + + it('read returns the driver buffer unchanged', async () => { + const buf = Buffer.from('content'); + driver.read.mockResolvedValue(buf); + await expect(service.read('f.txt')).resolves.toBe(buf); + expect(driver.read).toHaveBeenCalledWith('f.txt'); + }); + + it('readStream returns the driver stream unchanged', async () => { + const stream = Readable.from(['y']); + driver.readStream.mockResolvedValue(stream); + await expect(service.readStream('f.bin')).resolves.toBe(stream); + expect(driver.readStream).toHaveBeenCalledWith('f.bin'); + }); + + it('readRangeStream forwards the range object and returns the stream', async () => { + const stream = Readable.from(['z']); + driver.readRangeStream.mockResolvedValue(stream); + const range = { start: 0, end: 99 }; + await expect(service.readRangeStream('f.bin', range)).resolves.toBe(stream); + expect(driver.readRangeStream).toHaveBeenCalledWith('f.bin', range); + }); + + it('exists returns the driver boolean', async () => { + driver.exists.mockResolvedValue(false); + await expect(service.exists('missing')).resolves.toBe(false); + expect(driver.exists).toHaveBeenCalledWith('missing'); + }); + + it('getSignedUrl forwards path + expiry and returns the signed url', async () => { + driver.getSignedUrl.mockResolvedValue('https://signed/url'); + await expect(service.getSignedUrl('f.png', 600)).resolves.toBe( + 'https://signed/url', + ); + expect(driver.getSignedUrl).toHaveBeenCalledWith('f.png', 600); + }); + + it('getUrl returns the driver url synchronously', () => { + driver.getUrl.mockReturnValue('https://cdn/f.png'); + expect(service.getUrl('f.png')).toBe('https://cdn/f.png'); + expect(driver.getUrl).toHaveBeenCalledWith('f.png'); + }); + + it('delete forwards the path', async () => { + await service.delete('old.txt'); + expect(driver.delete).toHaveBeenCalledWith('old.txt'); + }); + + it('getDriverName returns the driver name', () => { + driver.getDriverName.mockReturnValue('s3'); + expect(service.getDriverName()).toBe('s3'); + expect(driver.getDriverName).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/editor-ext/src/lib/recreate-transform/recreateTransform.test.ts b/packages/editor-ext/src/lib/recreate-transform/recreateTransform.test.ts new file mode 100644 index 00000000..a30dc3d2 --- /dev/null +++ b/packages/editor-ext/src/lib/recreate-transform/recreateTransform.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect } from "vitest"; +import { schema } from "@tiptap/pm/schema-basic"; +import type { Node as PMNode } from "@tiptap/pm/model"; +import { Transform } from "@tiptap/pm/transform"; +import { recreateTransform } from "./recreateTransform"; + +/** + * recreateTransform diffs two documents and produces ProseMirror steps that turn + * `fromDoc` into `toDoc`. It is the backbone of collaborative/version diffing, so + * THE invariant that matters is: replaying the produced steps on `fromDoc` must + * reproduce `toDoc` exactly. Every test below re-applies the steps onto a fresh + * Transform seeded from `fromDoc` (not just trusting `tr.doc`) and asserts node + * equality with `.eq()`. If a regression makes any step wrong, the round-trip + * breaks and the test fails. + */ + +// Real ProseMirror schema (the standard basic schema) with paragraph/heading + +// strong/em marks — the same primitives the editor diffs in production. +const doc = (...c: PMNode[]) => schema.node("doc", null, c); +const p = (...c: PMNode[]) => + schema.node("paragraph", null, c.length ? c : undefined); +const h = (level: number, ...c: PMNode[]) => + schema.node("heading", { level }, c); +const t = (text: string, ...marks: any[]) => + schema.text(text, marks.length ? marks : undefined); +const strong = schema.marks.strong.create(); +const em = schema.marks.em.create(); + +// Replay the diff's steps onto a fresh Transform built from `fromDoc`. This is +// the faithful "apply(diff) == target" check — it exercises the actual Step +// objects rather than the transform's internal accumulated doc. +function applyDiff(fromDoc: PMNode, toDoc: PMNode, options?: any): PMNode { + const tr = recreateTransform(fromDoc, toDoc, options); + const replay = new Transform(fromDoc); + tr.steps.forEach((s) => { + const result = replay.maybeStep(s); + if (result.failed) throw new Error(`step failed: ${result.failed}`); + }); + return replay.doc; +} + +describe("recreateTransform round-trip (apply(diff) == target)", () => { + it("reconstructs the target on plain text insertion", () => { + // Inserting " world" must yield exactly the target paragraph. + const from = doc(p(t("hello"))); + const to = doc(p(t("hello world"))); + expect(applyDiff(from, to).eq(to)).toBe(true); + }); + + it("reconstructs the target on text deletion", () => { + // Deleting a trailing word is the inverse of insertion and must round-trip. + const from = doc(p(t("hello world"))); + const to = doc(p(t("hello"))); + expect(applyDiff(from, to).eq(to)).toBe(true); + }); + + it("reconstructs the target when a word is replaced mid-string", () => { + // A char-level replace in the middle must not corrupt the surrounding text. + const from = doc(p(t("the quick brown fox"))); + const to = doc(p(t("the slow brown fox"))); + expect(applyDiff(from, to).eq(to)).toBe(true); + }); + + it("reconstructs the target when a mark is added (complexSteps path)", () => { + // Mark-only changes are diffed in a separate pass; the bolded run must match. + const from = doc(p(t("hello"))); + const to = doc(p(t("hello", strong))); + const out = applyDiff(from, to); + expect(out.eq(to)).toBe(true); + // Sanity: the produced doc actually carries the strong mark. + expect(out.firstChild!.firstChild!.marks.length).toBe(1); + }); + + it("reconstructs the target when a mark is removed", () => { + // Removing the only mark must leave the same text with no marks. + const from = doc(p(t("hello", strong))); + const to = doc(p(t("hello"))); + const out = applyDiff(from, to); + expect(out.eq(to)).toBe(true); + expect(out.firstChild!.firstChild!.marks.length).toBe(0); + }); + + it("reconstructs the target on a paragraph split into two blocks", () => { + // Structural change (one block -> two) must replay as valid replace steps. + const from = doc(p(t("hello world"))); + const to = doc(p(t("hello")), p(t("world"))); + const out = applyDiff(from, to); + expect(out.eq(to)).toBe(true); + expect(out.childCount).toBe(2); + }); + + it("reconstructs the target on a node-type change (paragraph -> heading)", () => { + // Type/attrs changes drive the setNodeMarkup branch; the node must become a + // heading while keeping its text. + const from = doc(p(t("hello"))); + const to = doc(h(1, t("hello"))); + const out = applyDiff(from, to); + expect(out.eq(to)).toBe(true); + expect(out.firstChild!.type.name).toBe("heading"); + }); + + it("reconstructs a combined structural + mark change", () => { + // Several diff kinds at once (new block + italic run) still round-trips. + const from = doc(p(t("alpha"))); + const to = doc(p(t("alpha")), p(t("beta", em))); + const out = applyDiff(from, to); + expect(out.eq(to)).toBe(true); + }); + + it("produces an empty step list for identical documents", () => { + // No diff => no work; spurious steps would mean wasted/incorrect history. + const from = doc(p(t("same"))); + const to = doc(p(t("same"))); + const tr = recreateTransform(from, to); + expect(tr.steps.length).toBe(0); + expect(tr.doc.eq(to)).toBe(true); + }); + + it("round-trips with complexSteps:false (marks diffed as replaces)", () => { + // With complexSteps off, mark changes are folded into replace steps rather + // than dedicated mark steps — the result must still equal the target. + const from = doc(p(t("hello"))); + const to = doc(p(t("hello", strong))); + expect(applyDiff(from, to, { complexSteps: false }).eq(to)).toBe(true); + }); + + it("round-trips with wordDiffs:true (whole-word text diffing)", () => { + // wordDiffs changes the granularity of the text diff, not the outcome. + const from = doc(p(t("the quick brown fox"))); + const to = doc(p(t("the quick red fox"))); + expect(applyDiff(from, to, { wordDiffs: true }).eq(to)).toBe(true); + }); +}); diff --git a/packages/editor-ext/src/lib/table/utils/get-selection-range-in-column.test.ts b/packages/editor-ext/src/lib/table/utils/get-selection-range-in-column.test.ts new file mode 100644 index 00000000..7e623c60 --- /dev/null +++ b/packages/editor-ext/src/lib/table/utils/get-selection-range-in-column.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from "vitest"; +import { Schema } from "@tiptap/pm/model"; +import type { Node as PMNode } from "@tiptap/pm/model"; +import { tableNodes } from "@tiptap/pm/tables"; +import { EditorState, Selection } from "@tiptap/pm/state"; +import { getSelectionRangeInColumn } from "./get-selection-range-in-column"; + +/** + * getSelectionRangeInColumn computes the rectangular column range (the set of + * column indexes, plus anchor/head cell positions) that a drag-reorder or + * column-select operation should act on, accounting for merged (colspan) cells. + * It keys off the table found from the current selection, so we drive it with a + * real EditorState whose selection sits inside the table. + */ + +// Real ProseMirror table schema (same primitives the editor uses) so TableMap / +// cellsInRect behave exactly as in production. +const tNodes = tableNodes({ + tableGroup: "block", + cellContent: "inline*", + cellAttributes: {}, +}); +const schema = new Schema({ + nodes: { + doc: { content: "block+" }, + paragraph: { group: "block", content: "inline*", toDOM: () => ["p", 0] }, + text: { group: "inline" }, + ...tNodes, + }, + marks: {}, +}); +const cell = (txt: string, attrs?: Record): PMNode => + schema.nodes.table_cell.createChecked(attrs ?? null, schema.text(txt)); +const row = (...cells: PMNode[]): PMNode => + schema.nodes.table_row.createChecked(null, cells); +const table = (...rows: PMNode[]): PMNode => + schema.nodes.table.createChecked(null, rows); +const doc = (...content: PMNode[]): PMNode => + schema.nodes.doc.createChecked(null, content); + +// Build a transaction whose selection is inside the table (the function locates +// the table via `tr.selection.$from`). +const trFor = (d: PMNode) => + EditorState.create({ doc: d, selection: Selection.atStart(d) }).tr; + +// A 2-row x 3-col grid; each column is identifiable by its top-row letter. +const grid3x2 = () => + doc( + table( + row(cell("a"), cell("b"), cell("c")), + row(cell("d"), cell("e"), cell("f")), + ), + ); + +describe("getSelectionRangeInColumn", () => { + it("returns a single-column range for a single index", () => { + // Asking for column 1 yields exactly indexes [1]. + const tr = trFor(grid3x2()); + const range = getSelectionRangeInColumn(tr, 1); + expect(range).toBeTruthy(); + expect(range!.indexes).toEqual([1]); + }); + + it("anchor/head resolve to the top and bottom cells OF the requested column", () => { + // $head must point at the column's first (top) cell and $anchor at its last + // (bottom) cell — pinning that the returned positions belong to column 1, + // not some other column. + const tr = trFor(grid3x2()); + const range = getSelectionRangeInColumn(tr, 1)!; + expect(tr.doc.nodeAt(range.$head.pos)?.textContent).toBe("b"); // top of col 1 + expect(tr.doc.nodeAt(range.$anchor.pos)?.textContent).toBe("e"); // bottom of col 1 + }); + + it("returns the inclusive span of columns for a multi-column request", () => { + // A 0..2 request must enumerate every covered column, in order. + const tr = trFor(grid3x2()); + const range = getSelectionRangeInColumn(tr, 0, 2); + expect(range!.indexes).toEqual([0, 1, 2]); + }); + + it("returns a two-column span for an adjacent pair", () => { + const tr = trFor(grid3x2()); + const range = getSelectionRangeInColumn(tr, 1, 2); + expect(range!.indexes).toEqual([1, 2]); + }); + + it("expands the range to cover a horizontally merged (colspan) cell", () => { + // Row 0 col 0 spans 2 columns. Requesting just column 0 must pull column 1 + // into the range because they are merged together in the top row. + const d = doc( + table( + row(cell("ab", { colspan: 2 }), cell("c")), + row(cell("d"), cell("e"), cell("f")), + ), + ); + const tr = trFor(d); + const range = getSelectionRangeInColumn(tr, 0); + expect(range!.indexes).toEqual([0, 1]); + }); + + it("throws when the requested column is entirely out of range", () => { + // No cells exist at column 5 of a 3-wide table, so the function cannot pick + // an anchor cell and dereferences undefined — pin this as the current + // (caller-guarded) contract so a silent behavior change is caught. + const tr = trFor(grid3x2()); + expect(() => getSelectionRangeInColumn(tr, 5)).toThrow(); + }); +}); diff --git a/packages/editor-ext/src/lib/table/utils/move-column.test.ts b/packages/editor-ext/src/lib/table/utils/move-column.test.ts new file mode 100644 index 00000000..5b2d60e5 --- /dev/null +++ b/packages/editor-ext/src/lib/table/utils/move-column.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect } from "vitest"; +import { Schema } from "@tiptap/pm/model"; +import type { Node as PMNode } from "@tiptap/pm/model"; +import { tableNodes, CellSelection } from "@tiptap/pm/tables"; +import { EditorState, Selection } from "@tiptap/pm/state"; +import { moveColumn } from "./move-column"; +import { convertTableNodeToArrayOfRows } from "./convert-table-node-to-array-of-rows"; +import { findTable } from "./query"; + +/** + * moveColumn reorders whole columns of a real ProseMirror table by mutating a + * Transaction (transpose -> move row -> transpose back -> replace). The invariant + * is that after the call each column appears at its new position with every + * cell's content preserved and nothing dropped or duplicated. + */ + +const tNodes = tableNodes({ + tableGroup: "block", + cellContent: "inline*", + cellAttributes: {}, +}); +const schema = new Schema({ + nodes: { + doc: { content: "block+" }, + paragraph: { group: "block", content: "inline*", toDOM: () => ["p", 0] }, + text: { group: "inline" }, + ...tNodes, + }, + marks: {}, +}); +const cell = (txt: string): PMNode => + schema.nodes.table_cell.createChecked(null, schema.text(txt)); +const row = (...cells: PMNode[]): PMNode => + schema.nodes.table_row.createChecked(null, cells); +const table = (...rows: PMNode[]): PMNode => + schema.nodes.table.createChecked(null, rows); +const doc = (...content: PMNode[]): PMNode => + schema.nodes.doc.createChecked(null, content); + +const grid = (tr: any): string[][] => { + const t = findTable(tr.doc.resolve(tr.selection.from))!; + return convertTableNodeToArrayOfRows(t.node).map((r) => + r.map((c) => (c ? c.textContent : "")), + ); +}; + +// 2-row x 3-col table; column k is (rowX-col-k). Columns: 0=(a,d) 1=(b,e) 2=(c,f). +const grid3x2 = () => + doc( + table( + row(cell("a"), cell("b"), cell("c")), + row(cell("d"), cell("e"), cell("f")), + ), + ); + +const stateFor = (d: PMNode) => + EditorState.create({ doc: d, selection: Selection.atStart(d) }); + +describe("moveColumn", () => { + it("moves the first column to the last index, preserving column content", () => { + // origin 0 -> target 2 sends column (a,d) to the right: cols become 1,2,0. + const state = stateFor(grid3x2()); + const tr = state.tr; + const ok = moveColumn({ + tr, + originIndex: 0, + targetIndex: 2, + select: false, + pos: state.selection.from, + }); + expect(ok).toBe(true); + expect(grid(tr)).toEqual([ + ["b", "c", "a"], + ["e", "f", "d"], + ]); + }); + + it("moves a later column to the first index", () => { + // origin 2 -> target 0 pulls column (c,f) to the front: cols become 2,0,1. + const state = stateFor(grid3x2()); + const tr = state.tr; + const ok = moveColumn({ + tr, + originIndex: 2, + targetIndex: 0, + select: false, + pos: state.selection.from, + }); + expect(ok).toBe(true); + expect(grid(tr)).toEqual([ + ["c", "a", "b"], + ["f", "d", "e"], + ]); + }); + + it("never drops or duplicates cells when reordering columns", () => { + const state = stateFor(grid3x2()); + const tr = state.tr; + moveColumn({ + tr, + originIndex: 1, + targetIndex: 2, + select: false, + pos: state.selection.from, + }); + expect(grid(tr).flat().sort()).toEqual( + ["a", "b", "c", "d", "e", "f"].sort(), + ); + expect(grid(tr)[0].length).toBe(3); + }); + + it("returns false (no-op) when target equals origin", () => { + const state = stateFor(grid3x2()); + const tr = state.tr; + const before = grid(tr); + const ok = moveColumn({ + tr, + originIndex: 1, + targetIndex: 1, + select: false, + pos: state.selection.from, + }); + expect(ok).toBe(false); + expect(grid(tr)).toEqual(before); + }); + + it("returns false when pos is not inside a table", () => { + const d = doc( + schema.nodes.paragraph.createChecked(null, schema.text("plain")), + ); + const state = stateFor(d); + const tr = state.tr; + const ok = moveColumn({ + tr, + originIndex: 0, + targetIndex: 1, + select: false, + pos: state.selection.from, + }); + expect(ok).toBe(false); + }); + + it("installs a CellSelection on the moved column when select is true", () => { + const state = stateFor(grid3x2()); + const tr = state.tr; + const ok = moveColumn({ + tr, + originIndex: 0, + targetIndex: 2, + select: true, + pos: state.selection.from, + }); + expect(ok).toBe(true); + expect(tr.selection instanceof CellSelection).toBe(true); + }); +}); diff --git a/packages/editor-ext/src/lib/table/utils/move-row.test.ts b/packages/editor-ext/src/lib/table/utils/move-row.test.ts new file mode 100644 index 00000000..3a0c481a --- /dev/null +++ b/packages/editor-ext/src/lib/table/utils/move-row.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect } from "vitest"; +import { Schema } from "@tiptap/pm/model"; +import type { Node as PMNode } from "@tiptap/pm/model"; +import { tableNodes, CellSelection } from "@tiptap/pm/tables"; +import { EditorState, Selection } from "@tiptap/pm/state"; +import { moveRow } from "./move-row"; +import { convertTableNodeToArrayOfRows } from "./convert-table-node-to-array-of-rows"; +import { findTable } from "./query"; + +/** + * moveRow reorders whole rows of a real ProseMirror table by mutating a + * Transaction: it locates the table, computes origin/target row ranges, rebuilds + * the table with rows reordered, and replaces it in the doc. The invariant is + * that after the call the table's rows appear in the new order with every cell's + * content preserved, and no rows are dropped or duplicated. + */ + +const tNodes = tableNodes({ + tableGroup: "block", + cellContent: "inline*", + cellAttributes: {}, +}); +const schema = new Schema({ + nodes: { + doc: { content: "block+" }, + paragraph: { group: "block", content: "inline*", toDOM: () => ["p", 0] }, + text: { group: "inline" }, + ...tNodes, + }, + marks: {}, +}); +const cell = (txt: string): PMNode => + schema.nodes.table_cell.createChecked(null, schema.text(txt)); +const row = (...cells: PMNode[]): PMNode => + schema.nodes.table_row.createChecked(null, cells); +const table = (...rows: PMNode[]): PMNode => + schema.nodes.table.createChecked(null, rows); +const doc = (...content: PMNode[]): PMNode => + schema.nodes.doc.createChecked(null, content); + +// Read the table's content as a grid of cell texts (rows x cols) from whatever +// table currently lives in `tr.doc`. +const grid = (tr: any): string[][] => { + const t = findTable(tr.doc.resolve(tr.selection.from))!; + return convertTableNodeToArrayOfRows(t.node).map((r) => + r.map((c) => (c ? c.textContent : "")), + ); +}; + +// 3-row x 2-col table; each row identifiable by its cells. +const grid2x3 = () => + doc( + table( + row(cell("r0a"), cell("r0b")), + row(cell("r1a"), cell("r1b")), + row(cell("r2a"), cell("r2b")), + ), + ); + +const stateFor = (d: PMNode) => + EditorState.create({ doc: d, selection: Selection.atStart(d) }); + +describe("moveRow", () => { + it("moves the first row down to the last index, preserving content", () => { + // origin 0 -> target 2 makes row 0 land after the other rows: [r1, r2, r0]. + const state = stateFor(grid2x3()); + const tr = state.tr; + const ok = moveRow({ + tr, + originIndex: 0, + targetIndex: 2, + select: false, + pos: state.selection.from, + }); + expect(ok).toBe(true); + expect(grid(tr)).toEqual([ + ["r1a", "r1b"], + ["r2a", "r2b"], + ["r0a", "r0b"], + ]); + }); + + it("moves a lower row up to an earlier index", () => { + // origin 2 -> target 0 lifts the last row above the rest: [r2, r0, r1]. + const state = stateFor(grid2x3()); + const tr = state.tr; + const ok = moveRow({ + tr, + originIndex: 2, + targetIndex: 0, + select: false, + pos: state.selection.from, + }); + expect(ok).toBe(true); + expect(grid(tr)).toEqual([ + ["r2a", "r2b"], + ["r0a", "r0b"], + ["r1a", "r1b"], + ]); + }); + + it("never drops or duplicates rows when reordering", () => { + // The full multiset of cell texts is invariant under any valid move. + const state = stateFor(grid2x3()); + const tr = state.tr; + moveRow({ + tr, + originIndex: 1, + targetIndex: 2, + select: false, + pos: state.selection.from, + }); + const flat = grid(tr).flat().sort(); + expect(flat).toEqual( + ["r0a", "r0b", "r1a", "r1b", "r2a", "r2b"].sort(), + ); + expect(grid(tr).length).toBe(3); + }); + + it("returns false (no-op) when target equals origin", () => { + // Moving a row onto itself is rejected and leaves the table unchanged. + const state = stateFor(grid2x3()); + const tr = state.tr; + const before = grid(tr); + const ok = moveRow({ + tr, + originIndex: 1, + targetIndex: 1, + select: false, + pos: state.selection.from, + }); + expect(ok).toBe(false); + expect(grid(tr)).toEqual(before); + }); + + it("returns false when pos is not inside a table", () => { + // Without a table at `pos`, the function bails out instead of throwing. + const d = doc( + schema.nodes.paragraph.createChecked(null, schema.text("plain")), + ); + const state = stateFor(d); + const tr = state.tr; + const ok = moveRow({ + tr, + originIndex: 0, + targetIndex: 1, + select: false, + pos: state.selection.from, + }); + expect(ok).toBe(false); + }); + + it("installs a CellSelection on the moved row when select is true", () => { + // With select:true the moved row at the target index is selected. + const state = stateFor(grid2x3()); + const tr = state.tr; + const ok = moveRow({ + tr, + originIndex: 0, + targetIndex: 2, + select: true, + pos: state.selection.from, + }); + expect(ok).toBe(true); + expect(tr.selection instanceof CellSelection).toBe(true); + }); +}); diff --git a/packages/editor-ext/src/lib/unique-id/unique-id.util.test.ts b/packages/editor-ext/src/lib/unique-id/unique-id.util.test.ts index 24d30408..64603b5b 100644 --- a/packages/editor-ext/src/lib/unique-id/unique-id.util.test.ts +++ b/packages/editor-ext/src/lib/unique-id/unique-id.util.test.ts @@ -100,4 +100,51 @@ describe("addUniqueIdsToDoc", () => { const [id] = ids(out); expect(id).toBeTruthy(); }); + + it("only assigns ids to configured node types, not to others", () => { + // `types` is ["heading","paragraph"]; a codeBlock is NOT addressed, so it + // must come back without an id while the sibling paragraph is filled. (The + // UniqueID attribute only exists on configured types in the schema.) + const doc = { + type: "doc", + content: [ + { type: "codeBlock", content: [{ type: "text", text: "x = 1" }] }, + para(undefined, "after"), + ], + }; + const out = addUniqueIdsToDoc(doc, extensions); + const [codeId, paraId] = ids(out); + expect(codeId).toBeUndefined(); + expect(paraId).toBeTruthy(); + }); + + it("assigns ids to target nodes nested inside non-target containers", () => { + // findChildren walks the whole tree: a paragraph inside a blockquote still + // gets an id, while the (non-target) blockquote wrapper does not. + const doc = { + type: "doc", + content: [ + { type: "blockquote", content: [para(undefined, "quoted")] }, + ], + }; + const out = addUniqueIdsToDoc(doc, extensions) as any; + const blockquote = out.content[0]; + const nestedPara = blockquote.content[0]; + expect(blockquote.attrs?.id).toBeUndefined(); + expect(nestedPara.attrs.id).toBeTruthy(); + }); + + it("is idempotent: a second pass keeps every already-unique id unchanged", () => { + // Once ids are assigned and unique, re-running must be a fixed point — no + // churn that would invalidate stored MCP anchors on every save. + const doc = { + type: "doc", + content: [para(undefined, "a"), para(undefined, "b"), para(undefined, "c")], + }; + const once = addUniqueIdsToDoc(doc, extensions); + const twice = addUniqueIdsToDoc(once, extensions); + expect(ids(twice)).toEqual(ids(once)); + // And all three are distinct, so the second pass had real ids to preserve. + expect(new Set(ids(once)).size).toBe(3); + }); }); diff --git a/packages/mcp/build/lib/auth-utils.js b/packages/mcp/build/lib/auth-utils.js index cc61481c..39b91d9d 100644 --- a/packages/mcp/build/lib/auth-utils.js +++ b/packages/mcp/build/lib/auth-utils.js @@ -29,6 +29,41 @@ export async function getCollabToken(baseUrl, apiToken) { throw error; } } +/** + * Pure cookie-parsing helper extracted from `performLogin` so the parsing logic + * can be unit-tested without performing the login network request. Given the + * raw `Set-Cookie` header array from the login response, return the `authToken` + * cookie's value. + * + * Behavior (kept identical to the original inline logic): + * - throws if there is no Set-Cookie header at all; + * - matches the cookie NAME exactly (`authToken`), so a future + * `authTokenRefresh=...` cookie is NOT picked up (a `startsWith` would be); + * - returns everything after the FIRST `=` up to the first `;`, so a base64 + * value containing `=` padding is preserved (a naive `split("=")` would + * truncate it); + * - cookie attributes after the first `;` (Path, HttpOnly, Expires, …) are + * ignored; + * - throws if no `authToken` cookie is present. + */ +export function extractAuthTokenFromSetCookie(cookies) { + if (!cookies) { + throw new Error("No Set-Cookie header found in login response"); + } + // Match the cookie name exactly to avoid matching a future + // authTokenRefresh cookie (startsWith would catch it). + const authCookie = cookies.find((c) => { + const kv = c.split(";")[0]; + return kv.slice(0, kv.indexOf("=")) === "authToken"; + }); + if (!authCookie) { + throw new Error("No authToken cookie found in login response"); + } + // Take everything after the FIRST "=" up to the first ";". + // Splitting on "=" would truncate base64 values containing "=" padding. + const kv = authCookie.split(";")[0]; + return kv.slice(kv.indexOf("=") + 1); +} export async function performLogin(baseUrl, email, password) { try { const response = await axios.post(`${baseUrl}/auth/login`, { @@ -36,24 +71,7 @@ export async function performLogin(baseUrl, email, password) { password, }); // Extract token from Set-Cookie header - const cookies = response.headers["set-cookie"]; - if (!cookies) { - throw new Error("No Set-Cookie header found in login response"); - } - // Match the cookie name exactly to avoid matching a future - // authTokenRefresh cookie (startsWith would catch it). - const authCookie = cookies.find((c) => { - const kv = c.split(";")[0]; - return kv.slice(0, kv.indexOf("=")) === "authToken"; - }); - if (!authCookie) { - throw new Error("No authToken cookie found in login response"); - } - // Take everything after the FIRST "=" up to the first ";". - // Splitting on "=" would truncate base64 values containing "=" padding. - const kv = authCookie.split(";")[0]; - const token = kv.slice(kv.indexOf("=") + 1); - return token; + return extractAuthTokenFromSetCookie(response.headers["set-cookie"]); } catch (error) { // Avoid leaking the full server response body by default; log only the diff --git a/packages/mcp/src/lib/auth-utils.ts b/packages/mcp/src/lib/auth-utils.ts index d677be25..a3a3b652 100644 --- a/packages/mcp/src/lib/auth-utils.ts +++ b/packages/mcp/src/lib/auth-utils.ts @@ -38,6 +38,45 @@ export async function getCollabToken( } } +/** + * Pure cookie-parsing helper extracted from `performLogin` so the parsing logic + * can be unit-tested without performing the login network request. Given the + * raw `Set-Cookie` header array from the login response, return the `authToken` + * cookie's value. + * + * Behavior (kept identical to the original inline logic): + * - throws if there is no Set-Cookie header at all; + * - matches the cookie NAME exactly (`authToken`), so a future + * `authTokenRefresh=...` cookie is NOT picked up (a `startsWith` would be); + * - returns everything after the FIRST `=` up to the first `;`, so a base64 + * value containing `=` padding is preserved (a naive `split("=")` would + * truncate it); + * - cookie attributes after the first `;` (Path, HttpOnly, Expires, …) are + * ignored; + * - throws if no `authToken` cookie is present. + */ +export function extractAuthTokenFromSetCookie( + cookies: string[] | undefined, +): string { + if (!cookies) { + throw new Error("No Set-Cookie header found in login response"); + } + // Match the cookie name exactly to avoid matching a future + // authTokenRefresh cookie (startsWith would catch it). + const authCookie = cookies.find((c: string) => { + const kv = c.split(";")[0]; + return kv.slice(0, kv.indexOf("=")) === "authToken"; + }); + if (!authCookie) { + throw new Error("No authToken cookie found in login response"); + } + + // Take everything after the FIRST "=" up to the first ";". + // Splitting on "=" would truncate base64 values containing "=" padding. + const kv = authCookie.split(";")[0]; + return kv.slice(kv.indexOf("=") + 1); +} + export async function performLogin( baseUrl: string, email: string, @@ -50,25 +89,7 @@ export async function performLogin( }); // Extract token from Set-Cookie header - const cookies = response.headers["set-cookie"]; - if (!cookies) { - throw new Error("No Set-Cookie header found in login response"); - } - // Match the cookie name exactly to avoid matching a future - // authTokenRefresh cookie (startsWith would catch it). - const authCookie = cookies.find((c: string) => { - const kv = c.split(";")[0]; - return kv.slice(0, kv.indexOf("=")) === "authToken"; - }); - if (!authCookie) { - throw new Error("No authToken cookie found in login response"); - } - - // Take everything after the FIRST "=" up to the first ";". - // Splitting on "=" would truncate base64 values containing "=" padding. - const kv = authCookie.split(";")[0]; - const token = kv.slice(kv.indexOf("=") + 1); - return token; + return extractAuthTokenFromSetCookie(response.headers["set-cookie"]); } catch (error: any) { // Avoid leaking the full server response body by default; log only the // HTTP status. Log the verbose body only when DEBUG is set. diff --git a/packages/mcp/test/unit/auth-cookie.test.mjs b/packages/mcp/test/unit/auth-cookie.test.mjs new file mode 100644 index 00000000..92206cf4 --- /dev/null +++ b/packages/mcp/test/unit/auth-cookie.test.mjs @@ -0,0 +1,93 @@ +// Cookie parsing for the login flow. +// +// `performLogin` in auth-utils.ts does a real network POST and then extracts the +// auth token from the response's Set-Cookie header. The cookie-parsing logic was +// extracted into the pure, exported helper `extractAuthTokenFromSetCookie` so it +// can be tested without network I/O; `performLogin` now delegates to it, so these +// tests cover the exact parsing path the login uses. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { extractAuthTokenFromSetCookie } from "../../build/lib/auth-utils.js"; + +// --------------------------------------------------------------------------- +// Happy path: a single authToken cookie with attributes. +// --------------------------------------------------------------------------- +test("extracts the authToken value, ignoring trailing attributes", () => { + const cookies = [ + "authToken=abc123; Path=/; HttpOnly; Secure; SameSite=Lax", + ]; + assert.equal(extractAuthTokenFromSetCookie(cookies), "abc123"); +}); + +// --------------------------------------------------------------------------- +// A base64/JWT value containing "=" padding must NOT be truncated: only the +// FIRST "=" separates name from value. +// --------------------------------------------------------------------------- +test("preserves an '=' inside the value (base64 padding is not truncated)", () => { + const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0="; + const cookies = [`authToken=${jwt}; Path=/`]; + assert.equal(extractAuthTokenFromSetCookie(cookies), jwt); +}); + +// --------------------------------------------------------------------------- +// Exact-name match: a different cookie whose name merely STARTS WITH "authToken" +// (e.g. authTokenRefresh) must not be picked up; the real authToken wins. +// --------------------------------------------------------------------------- +test("matches the cookie name exactly, not by prefix (authTokenRefresh ignored)", () => { + const cookies = [ + "authTokenRefresh=refreshvalue; Path=/; HttpOnly", + "authToken=realtoken; Path=/; HttpOnly", + ]; + assert.equal(extractAuthTokenFromSetCookie(cookies), "realtoken"); +}); + +// --------------------------------------------------------------------------- +// Picks the authToken out of several unrelated cookies regardless of order. +// --------------------------------------------------------------------------- +test("selects authToken among multiple unrelated cookies", () => { + const cookies = [ + "session=xyz; Path=/", + "authToken=tok-7; Path=/; HttpOnly", + "theme=dark", + ]; + assert.equal(extractAuthTokenFromSetCookie(cookies), "tok-7"); +}); + +// --------------------------------------------------------------------------- +// An empty value is valid and returns "". +// --------------------------------------------------------------------------- +test("returns an empty string when authToken has an empty value", () => { + assert.equal(extractAuthTokenFromSetCookie(["authToken=; Path=/"]), ""); +}); + +// --------------------------------------------------------------------------- +// Missing Set-Cookie header -> documented error. +// --------------------------------------------------------------------------- +test("throws when there is no Set-Cookie header", () => { + assert.throws( + () => extractAuthTokenFromSetCookie(undefined), + /No Set-Cookie header/, + ); +}); + +// --------------------------------------------------------------------------- +// Set-Cookie present but no authToken cookie -> documented error. +// --------------------------------------------------------------------------- +test("throws when no authToken cookie is present", () => { + assert.throws( + () => extractAuthTokenFromSetCookie(["session=xyz; Path=/", "theme=dark"]), + /No authToken cookie/, + ); +}); + +// --------------------------------------------------------------------------- +// An empty cookie array also yields the "no authToken" error (header exists but +// is empty), distinct from the "no Set-Cookie header" case above. +// --------------------------------------------------------------------------- +test("throws 'no authToken' (not 'no header') for an empty cookie array", () => { + assert.throws( + () => extractAuthTokenFromSetCookie([]), + /No authToken cookie/, + ); +}); diff --git a/packages/mcp/test/unit/comment-anchor-apply.test.mjs b/packages/mcp/test/unit/comment-anchor-apply.test.mjs new file mode 100644 index 00000000..46049c02 --- /dev/null +++ b/packages/mcp/test/unit/comment-anchor-apply.test.mjs @@ -0,0 +1,111 @@ +// applyAnchorInDoc — first-match / ambiguity / boundary behavior. +// +// comment-anchor.test.mjs already covers the core apply paths (single-node +// match, spanning adjacent text nodes, code/italic boundary mark preservation, +// smart-quote normalization, no-match-no-mutation, pre-existing comment mark +// replacement, nested-list DFS). This file focuses on the SELECTION/RESOLUTION +// behavior those tests don't pin down: which occurrence/block wins when a +// selection appears more than once, sub-word ranges, and the run boundary +// created by a non-text inline node. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { applyAnchorInDoc, canAnchorInDoc } from "../../build/lib/comment-anchor.js"; + +const commentMark = (node) => + (Array.isArray(node.marks) ? node.marks : []).find((m) => m && m.type === "comment") || null; +const paragraphDoc = (content) => ({ type: "doc", content: [{ type: "paragraph", content }] }); + +// --------------------------------------------------------------------------- +// Document order: when two separate blocks both contain the selection, only the +// FIRST block (DFS document order) is anchored; the second is left untouched. +// --------------------------------------------------------------------------- +test("anchors only the FIRST block when the selection occurs in two blocks", () => { + const doc = { + type: "doc", + content: [ + { type: "paragraph", content: [{ type: "text", text: "first target here" }] }, + { type: "paragraph", content: [{ type: "text", text: "second target here" }] }, + ], + }; + assert.equal(applyAnchorInDoc(doc, "target", "C"), true); + + const marked0 = doc.content[0].content.filter((p) => commentMark(p)); + const marked1 = doc.content[1].content.filter((p) => commentMark(p)); + assert.equal(marked0.length, 1, "first block is anchored"); + assert.equal(marked0[0].text, "target"); + assert.equal(marked1.length, 0, "second block is left untouched"); +}); + +// --------------------------------------------------------------------------- +// Ambiguity within one block: indexOf finds the FIRST occurrence, so only the +// first "ab" is marked; the later occurrences stay in one unmarked fragment. +// --------------------------------------------------------------------------- +test("anchors only the FIRST occurrence within a block (ambiguous selection)", () => { + const doc = paragraphDoc([{ type: "text", text: "ab ab ab" }]); + assert.equal(applyAnchorInDoc(doc, "ab", "C"), true); + + const parts = doc.content[0].content; + assert.equal(parts.length, 2, "split into [marked, rest]"); + assert.equal(parts[0].text, "ab"); + assert.ok(commentMark(parts[0]), "first occurrence is marked"); + assert.equal(parts[1].text, " ab ab"); + assert.equal(commentMark(parts[1]), null, "later occurrences are not marked"); +}); + +// --------------------------------------------------------------------------- +// Sub-word range: a selection that is a substring inside a single text node is +// spliced into before / marked / after, marking exactly the matched characters. +// --------------------------------------------------------------------------- +test("anchors a sub-word range inside a single text node", () => { + const doc = paragraphDoc([{ type: "text", text: "Hello" }]); + assert.equal(applyAnchorInDoc(doc, "ell", "C"), true); + + const parts = doc.content[0].content; + assert.deepEqual(parts.map((p) => p.text), ["H", "ell", "o"]); + assert.equal(commentMark(parts[0]), null); + assert.ok(commentMark(parts[1]), "only the matched substring is marked"); + assert.equal(commentMark(parts[2]), null); +}); + +// --------------------------------------------------------------------------- +// A non-text inline node (hardBreak) breaks the matching run: a selection that +// would span the break cannot match, but one wholly inside a run still does. +// --------------------------------------------------------------------------- +test("a non-text inline node breaks the run: cross-break selection does not match", () => { + const make = () => + paragraphDoc([ + { type: "text", text: "foo" }, + { type: "hardBreak" }, + { type: "text", text: "bar" }, + ]); + + // "foobar" straddles the hardBreak -> no match, no mutation. + const docA = make(); + const before = JSON.stringify(docA); + assert.equal(canAnchorInDoc(docA, "foobar"), false); + assert.equal(applyAnchorInDoc(docA, "foobar", "C"), false); + assert.equal(JSON.stringify(docA), before, "failed match must not mutate"); + + // "foo" lives entirely in the first run -> matches and is marked; the + // hardBreak node is preserved untouched. + const docB = make(); + assert.equal(applyAnchorInDoc(docB, "foo", "C"), true); + const parts = docB.content[0].content; + assert.equal(parts[0].text, "foo"); + assert.ok(commentMark(parts[0])); + assert.equal(parts[1].type, "hardBreak", "the inline atom is preserved"); + assert.equal(parts[2].text, "bar"); + assert.equal(commentMark(parts[2]), null); +}); + +// --------------------------------------------------------------------------- +// A whitespace-only selection normalizes to empty and never anchors. +// --------------------------------------------------------------------------- +test("a whitespace-only selection does not anchor and does not mutate", () => { + const doc = paragraphDoc([{ type: "text", text: "hello world" }]); + const before = JSON.stringify(doc); + assert.equal(canAnchorInDoc(doc, " "), false); + assert.equal(applyAnchorInDoc(doc, " ", "C"), false); + assert.equal(JSON.stringify(doc), before); +}); diff --git a/packages/mcp/test/unit/media-roundtrip-attrs.test.mjs b/packages/mcp/test/unit/media-roundtrip-attrs.test.mjs new file mode 100644 index 00000000..37668fe0 --- /dev/null +++ b/packages/mcp/test/unit/media-roundtrip-attrs.test.mjs @@ -0,0 +1,135 @@ +// Extra media round-trip coverage (issue #244), complementing +// media-roundtrip.test.mjs. +// +// The existing media-roundtrip.test.mjs already asserts that video, youtube, +// embed, excalidraw, audio and pdf SURVIVE a PM -> markdown -> PM round-trip and +// keeps their identifying src / provider / name / attachmentId. It does NOT, +// however, exercise: +// * the `drawio` node (a distinct schema node that shares the excalidraw +// converter case) — not covered at all; +// * the dimension / layout attributes (width, height, align) that ride in +// data-* attributes — exactly where a converter<->schema mismatch silently +// drops a value while the node itself survives; +// * attribute escaping for a src containing `"` (escapeAttr) — a malformed +// value here would either break the round-trip or inject HTML. +// +// These are the gaps this file locks down. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { convertProseMirrorToMarkdown } from "../../build/lib/markdown-converter.js"; +import { markdownToProseMirror } from "../../build/lib/collaboration.js"; + +const doc = (...content) => ({ type: "doc", content }); + +const findAll = (node, type, acc = []) => { + if (!node || typeof node !== "object") return acc; + if (node.type === type) acc.push(node); + for (const c of node.content || []) findAll(c, type, acc); + return acc; +}; + +// PM node -> markdown -> PM; return both the markdown and the matching nodes. +const roundtrip = async (node, type) => { + const md = convertProseMirrorToMarkdown(doc(node)); + const pm = await markdownToProseMirror(md); + return { md, found: findAll(pm, type) }; +}; + +// --------------------------------------------------------------------------- +// drawio: a separate schema node sharing the excalidraw converter case. Not +// covered by the existing file at all, so guard its full round-trip here. +// --------------------------------------------------------------------------- +test("round-trip: drawio diagram survives with src, title, dimensions, align, attachmentId", async () => { + const { md, found } = await roundtrip( + { + type: "drawio", + attrs: { + src: "/api/files/d.drawio", + title: "Flow", + width: 400, + height: 300, + align: "left", + attachmentId: "dz1", + }, + }, + "drawio", + ); + // The converter must emit the schema-matching div[data-type="drawio"]. + assert.match(md, /data-type="drawio"/); + assert.equal(found.length, 1, "drawio node must survive the round-trip"); + const a = found[0].attrs; + assert.equal(a.src, "/api/files/d.drawio"); + assert.equal(a.title, "Flow"); + assert.equal(a.attachmentId, "dz1"); + assert.equal(a.align, "left"); + // Numeric dimensions come back as strings via the schema parseHTML. + assert.equal(String(a.width), "400"); + assert.equal(String(a.height), "300"); +}); + +// --------------------------------------------------------------------------- +// Dimension + align attrs ride in data-* (or width/height) attributes. The +// existing file checks only src/provider/name/attachmentId, so a dropped +// width/height/align would pass there but fail here. +// --------------------------------------------------------------------------- +test("round-trip: youtube preserves width/height/align (data-* attrs)", async () => { + const { found } = await roundtrip( + { type: "youtube", attrs: { src: "https://youtube.com/watch?v=x", width: 560, height: 315, align: "left" } }, + "youtube", + ); + assert.equal(found.length, 1); + const a = found[0].attrs; + assert.equal(String(a.width), "560"); + assert.equal(String(a.height), "315"); + assert.equal(a.align, "left"); +}); + +test("round-trip: embed preserves provider, width/height and align", async () => { + const { found } = await roundtrip( + { type: "embed", attrs: { src: "https://e.com/x", provider: "iframe", width: 600, height: 480, align: "right" } }, + "embed", + ); + assert.equal(found.length, 1); + const a = found[0].attrs; + assert.equal(a.provider, "iframe"); + assert.equal(String(a.width), "600"); + assert.equal(String(a.height), "480"); + assert.equal(a.align, "right"); +}); + +test("round-trip: video preserves width/height and align (data-align)", async () => { + const { found } = await roundtrip( + { type: "video", attrs: { src: "/api/files/v.mp4", attachmentId: "att1", width: 640, height: 360, align: "right" } }, + "video", + ); + assert.equal(found.length, 1); + const a = found[0].attrs; + assert.equal(String(a.width), "640"); + assert.equal(String(a.height), "360"); + assert.equal(a.align, "right"); +}); + +test("round-trip: pdf preserves width/height (standard attrs) plus name", async () => { + const { found } = await roundtrip( + { type: "pdf", attrs: { src: "/api/files/x.pdf", name: "x.pdf", attachmentId: "a4", width: 700, height: 900 } }, + "pdf", + ); + assert.equal(found.length, 1); + const a = found[0].attrs; + assert.equal(a.name, "x.pdf"); + assert.equal(String(a.width), "700"); + assert.equal(String(a.height), "900"); +}); + +// --------------------------------------------------------------------------- +// Escaping: a src containing a double quote must survive the attribute-quoted +// HTML emission (escapeAttr) and re-parse to the exact original value, with no +// node loss and no HTML injection. +// --------------------------------------------------------------------------- +test("round-trip: a src containing a double quote is escaped and recovered intact", async () => { + const tricky = 'https://e.com/x?a="b"&c=1'; + const { found } = await roundtrip({ type: "youtube", attrs: { src: tricky } }, "youtube"); + assert.equal(found.length, 1, "node must survive a quote-bearing src"); + assert.equal(found[0].attrs.src, tricky, "the exact src is recovered"); +}); diff --git a/packages/mcp/test/unit/recreate-transform-drift.test.mjs b/packages/mcp/test/unit/recreate-transform-drift.test.mjs new file mode 100644 index 00000000..b950cdaf --- /dev/null +++ b/packages/mcp/test/unit/recreate-transform-drift.test.mjs @@ -0,0 +1,139 @@ +// CONTRACT / DRIFT GUARD: mcp diff vs the vendored editor-ext recreate-transform. +// +// packages/mcp/src/lib/diff.ts computes its document diff with +// `recreateTransform` from the published @fellow/prosemirror-recreate-transform +// package. Docmost's in-app history editor computes the SAME diff with its own +// vendored copy at +// packages/editor-ext/src/lib/recreate-transform/recreateTransform.ts. +// diff.ts's header comment claims the two are "identical" — if they ever drift, +// the headless mcp diff would stop matching what a user sees in the app. +// +// This test guards that claim two ways, on representative doc pairs, using the +// EXACT options diff.ts passes (complexSteps:false, wordDiffs:true, +// simplifyDiff:true): +// 1. invariant: each implementation's transform reproduces the target doc +// (apply(diff) == target); +// 2. cross-copy parity: both implementations emit the SAME step sequence, so a +// behavioral divergence between the two copies fails this test. +// +// The vendored copy is TypeScript, so it is transpiled to CommonJS at test time +// and required directly — the test runs the ACTUAL vendored source, not a stand-in. +import { test, before } from "node:test"; +import assert from "node:assert/strict"; +import ts from "typescript"; +import fs from "node:fs"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; + +import { recreateTransform as fellowRecreate } from "@fellow/prosemirror-recreate-transform"; +import { Node } from "@tiptap/pm/model"; +import { docmostSchema } from "../../build/lib/docmost-schema.js"; + +const require = createRequire(import.meta.url); +const HERE = path.dirname(fileURLToPath(import.meta.url)); +// .../packages/mcp/test/unit -> repo packages root. +const PACKAGES = path.resolve(HERE, "..", "..", ".."); +const VENDOR_SRC = path.join( + PACKAGES, + "editor-ext", + "src", + "lib", + "recreate-transform", +); +// Emit transpiled CJS under mcp/build so Node resolves the hoisted deps +// (@tiptap/pm, rfc6902, diff) up the directory tree exactly as diff.js does. +const VENDOR_OUT = path.resolve(HERE, "..", "..", "build", "_vendored_editor_ext"); + +// The exact options the mcp diff pipeline uses (diff.ts). +const DIFF_OPTS = { complexSteps: false, wordDiffs: true, simplifyDiff: true }; + +let vendoredRecreate; + +before(() => { + assert.ok( + fs.existsSync(VENDOR_SRC), + `vendored recreate-transform sources missing at ${VENDOR_SRC}`, + ); + fs.rmSync(VENDOR_OUT, { recursive: true, force: true }); + fs.mkdirSync(VENDOR_OUT, { recursive: true }); + // Mark the output as CommonJS so relative `require("./x")` resolves to x.js. + fs.writeFileSync( + path.join(VENDOR_OUT, "package.json"), + JSON.stringify({ type: "commonjs" }), + ); + for (const f of fs.readdirSync(VENDOR_SRC)) { + if (!f.endsWith(".ts")) continue; + const code = fs.readFileSync(path.join(VENDOR_SRC, f), "utf8"); + const out = ts.transpileModule(code, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + }, + }); + fs.writeFileSync(path.join(VENDOR_OUT, f.replace(/\.ts$/, ".js")), out.outputText); + } + vendoredRecreate = require(path.join(VENDOR_OUT, "index.js")).recreateTransform; + assert.equal(typeof vendoredRecreate, "function", "vendored recreateTransform loaded"); +}); + +// --------------------------------------------------------------------------- +// Builders + representative doc pairs covering the diff shapes diff.ts handles. +// --------------------------------------------------------------------------- +const t = (text, marks) => (marks ? { type: "text", text, marks } : { type: "text", text }); +const para = (...c) => ({ type: "paragraph", content: c }); +const doc = (...c) => ({ type: "doc", content: c }); + +const PAIRS = [ + // word inserted mid-sentence + ["insert word", doc(para(t("Hello world"))), doc(para(t("Hello brave world")))], + // whole block deleted + ["delete block", doc(para(t("keep this")), para(t("remove this"))), doc(para(t("keep this")))], + // word removed mid-sentence + ["delete word", doc(para(t("one two three"))), doc(para(t("one three")))], + // pure mark addition (complexSteps:false treats it as a content step) + ["add mark", doc(para(t("plain"))), doc(para(t("plain", [{ type: "bold" }])))], + // two blocks swapped (reorder) + ["reorder blocks", doc(para(t("a")), para(t("b"))), doc(para(t("b")), para(t("a")))], + // structural insert: an image node appears + [ + "insert image", + doc(para(t("caption"))), + doc(para(t("caption")), { type: "image", attrs: { src: "/api/files/a.png", attachmentId: "i1" } }), + ], +]; + +const stepsJSON = (tr) => JSON.stringify(tr.steps.map((s) => s.toJSON())); + +for (const [label, fromJSON, toJSON] of PAIRS) { + test(`invariant: @fellow recreateTransform reproduces the target (${label})`, () => { + const from = Node.fromJSON(docmostSchema, fromJSON); + const to = Node.fromJSON(docmostSchema, toJSON); + const tr = fellowRecreate(from, to, DIFF_OPTS); + // apply(diff) == target, comparing schema-normalized JSON on both sides. + assert.equal(JSON.stringify(tr.doc.toJSON()), JSON.stringify(to.toJSON())); + }); + + test(`drift: @fellow and vendored editor-ext emit identical steps (${label})`, () => { + const mk = () => [ + Node.fromJSON(docmostSchema, fromJSON), + Node.fromJSON(docmostSchema, toJSON), + ]; + const [fA, tA] = mk(); + const [fB, tB] = mk(); + const trFellow = fellowRecreate(fA, tA, DIFF_OPTS); + const trVendor = vendoredRecreate(fB, tB, DIFF_OPTS); + + // Both must reach the same target... + const target = JSON.stringify(tA.toJSON()); + assert.equal(JSON.stringify(trFellow.doc.toJSON()), target, "fellow reaches target"); + assert.equal(JSON.stringify(trVendor.doc.toJSON()), target, "vendored reaches target"); + // ...and, critically, via the SAME step sequence. A divergence in the two + // recreate-transform copies' algorithm would change the steps and fail here. + assert.equal( + stepsJSON(trVendor), + stepsJSON(trFellow), + `vendored editor-ext drifted from @fellow on "${label}"`, + ); + }); +}