From 7c48bab1f247a1c5f39234c559ac23e13e63b1ad Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Tue, 23 Jun 2026 04:07:46 +0300 Subject: [PATCH] test: add unit tests for 10 candidates from issue #139 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds co-located unit tests for ten targets (client → vitest *.test.ts(x), server → jest *.spec.ts), plus minimal behavior-preserving extractions/exports where the issue required a pure function to test: - encode-wav: WAV header + PCM16 clamping - editor-ext embed-provider / utils (sanitizeUrl, isInternalFileUrl) / indent (export clampIndent) - label.dto @Matches regex - move-page.dto vs generateJitteredKeyBetween parity (bug locked via test.failing) - new-note-button canCreatePage (extracted to can-create-page.ts) - history-editor diff (extracted pure computeHistoryDiff into history-diff.ts) - notification getTypesForTab + repo contract (direct-tab divergence locked via test.failing) - search buildTsQuery (extracted + sanitizes operator inputs so adversarial queries no longer risk a to_tsquery 500) Co-Authored-By: Claude Opus 4.8 --- .../dictation/utils/encode-wav.test.ts | 87 +++++++++ .../home/components/can-create-page.test.ts | 42 ++++ .../home/components/can-create-page.ts | 15 ++ .../home/components/new-note-button.tsx | 11 +- .../components/history-diff.test.ts | 127 ++++++++++++ .../page-history/components/history-diff.ts | 168 ++++++++++++++++ .../components/history-editor.tsx | 148 ++------------ .../src/core/label/dto/label.dto.spec.ts | 78 ++++++++ .../notification.constants.spec.ts | 77 ++++++++ .../src/core/page/dto/move-page.dto.spec.ts | 70 +++++++ .../src/core/search/search.service.spec.ts | 58 +++++- apps/server/src/core/search/search.service.ts | 24 ++- .../editor-ext/src/lib/embed-provider.spec.ts | 184 ++++++++++++++++++ packages/editor-ext/src/lib/indent.spec.ts | 56 ++++++ packages/editor-ext/src/lib/indent.ts | 4 +- packages/editor-ext/src/lib/utils.spec.ts | 54 +++++ 16 files changed, 1053 insertions(+), 150 deletions(-) create mode 100644 apps/client/src/features/dictation/utils/encode-wav.test.ts create mode 100644 apps/client/src/features/home/components/can-create-page.test.ts create mode 100644 apps/client/src/features/home/components/can-create-page.ts create mode 100644 apps/client/src/features/page-history/components/history-diff.test.ts create mode 100644 apps/client/src/features/page-history/components/history-diff.ts create mode 100644 apps/server/src/core/label/dto/label.dto.spec.ts create mode 100644 apps/server/src/core/notification/notification.constants.spec.ts create mode 100644 apps/server/src/core/page/dto/move-page.dto.spec.ts create mode 100644 packages/editor-ext/src/lib/embed-provider.spec.ts create mode 100644 packages/editor-ext/src/lib/indent.spec.ts create mode 100644 packages/editor-ext/src/lib/utils.spec.ts diff --git a/apps/client/src/features/dictation/utils/encode-wav.test.ts b/apps/client/src/features/dictation/utils/encode-wav.test.ts new file mode 100644 index 00000000..67913588 --- /dev/null +++ b/apps/client/src/features/dictation/utils/encode-wav.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from "vitest"; +import { encodeWavPcm16 } from "./encode-wav"; + +// Contract tests for `encodeWavPcm16` (encode-wav.ts). The dictation feature +// streams microphone audio as mono 16-bit PCM WAV to the STT endpoint, which +// whitelists audio/wav. A regression in the WAV header or PCM16 clamping would +// produce audio the server cannot decode (silence / garbled transcripts), so we +// assert the canonical 44-byte header layout and the sample quantisation rails. + +// Read a DataView back out of a Blob. jsdom's Blob does not implement +// `.arrayBuffer()`, so go through FileReader.readAsArrayBuffer instead. +function readView(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(new DataView(reader.result as ArrayBuffer)); + reader.onerror = () => reject(reader.error); + reader.readAsArrayBuffer(blob); + }); +} + +function readStr(view: DataView, offset: number, length: number): string { + let s = ""; + for (let i = 0; i < length; i++) s += String.fromCharCode(view.getUint8(offset + i)); + return s; +} + +describe("encodeWavPcm16", () => { + it("writes the canonical RIFF/WAVE/fmt /data tags", async () => { + const view = await readView(encodeWavPcm16(new Float32Array(4))); + expect(readStr(view, 0, 4)).toBe("RIFF"); + expect(readStr(view, 8, 4)).toBe("WAVE"); + expect(readStr(view, 12, 4)).toBe("fmt "); + expect(readStr(view, 36, 4)).toBe("data"); + }); + + it("writes a PCM fmt chunk (size=16, format=1, mono, 16-bit)", async () => { + const samples = new Float32Array(10); + const view = await readView(encodeWavPcm16(samples)); + expect(view.getUint32(16, true)).toBe(16); // fmt chunk size + expect(view.getUint16(20, true)).toBe(1); // audioFormat = PCM + expect(view.getUint16(22, true)).toBe(1); // channels = mono + expect(view.getUint16(34, true)).toBe(16); // bits per sample + }); + + it("derives byteRate, blockAlign and dataSize from the sample rate and length", async () => { + const sampleRate = 16000; + const samples = new Float32Array(10); + const view = await readView(encodeWavPcm16(samples, sampleRate)); + expect(view.getUint32(28, true)).toBe(sampleRate * 2); // byteRate = sampleRate * 2 + expect(view.getUint16(32, true)).toBe(2); // blockAlign = 2 (mono * 16-bit) + expect(view.getUint32(40, true)).toBe(samples.length * 2); // dataSize + expect(view.getUint32(4, true)).toBe(36 + samples.length * 2); // RIFF chunk size + }); + + it("defaults the sample rate to 16000 at offset 24", async () => { + const view = await readView(encodeWavPcm16(new Float32Array(2))); + expect(view.getUint32(24, true)).toBe(16000); + }); + + it("writes the overridden sample rate at offset 24 (8000 / 48000)", async () => { + const view8 = await readView(encodeWavPcm16(new Float32Array(2), 8000)); + expect(view8.getUint32(24, true)).toBe(8000); + expect(view8.getUint32(28, true)).toBe(8000 * 2); // byteRate follows the override + + const view48 = await readView(encodeWavPcm16(new Float32Array(2), 48000)); + expect(view48.getUint32(24, true)).toBe(48000); + expect(view48.getUint32(28, true)).toBe(48000 * 2); + }); + + it("clamps and quantises PCM16 samples to the asymmetric rails", async () => { + // +1.0 -> 32767 (clamped>=0 uses *0x7fff), -1.0 -> -32768 (clamped<0 uses *0x8000), + // 0 -> 0, and out-of-range values are clamped to the rails first. + const samples = new Float32Array([1.0, -1.0, 0, 1.5, -1.5]); + const view = await readView(encodeWavPcm16(samples)); + expect(view.getInt16(44 + 0 * 2, true)).toBe(32767); // +1.0 + expect(view.getInt16(44 + 1 * 2, true)).toBe(-32768); // -1.0 + expect(view.getInt16(44 + 2 * 2, true)).toBe(0); // 0 + expect(view.getInt16(44 + 3 * 2, true)).toBe(32767); // +1.5 -> clamped to +1.0 + expect(view.getInt16(44 + 4 * 2, true)).toBe(-32768); // -1.5 -> clamped to -1.0 + }); + + it("produces a mono blob of length 44 + samples.length * 2", () => { + expect(encodeWavPcm16(new Float32Array(0)).size).toBe(44); + expect(encodeWavPcm16(new Float32Array(100)).size).toBe(44 + 100 * 2); + expect(encodeWavPcm16(new Float32Array(100)).type).toBe("audio/wav"); + }); +}); diff --git a/apps/client/src/features/home/components/can-create-page.test.ts b/apps/client/src/features/home/components/can-create-page.test.ts new file mode 100644 index 00000000..6d824231 --- /dev/null +++ b/apps/client/src/features/home/components/can-create-page.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from "vitest"; +import { canCreatePage } from "./can-create-page.ts"; +import { ISpace } from "@/features/space/types/space.types.ts"; +import { SpaceRole } from "@/lib/types.ts"; + +// Unit tests for `canCreatePage` (new-note-button.tsx). The home screen has no +// active space, so the "New note" button resolves its target from the user's +// writable spaces. This predicate mirrors the server space-ability mapping +// (ADMIN/WRITER can manage pages, READER is read-only). The /spaces list endpoint +// only returns membership.role (not CASL permissions), so a regression here would +// either hide the button for legitimate writers or offer it to read-only members. + +function spaceWithRole(role?: SpaceRole): ISpace { + // Only `membership.role` is consulted by the predicate; the rest is filler. + return { + membership: role ? ({ role } as any) : undefined, + } as ISpace; +} + +describe("canCreatePage", () => { + it("is true for ADMIN and WRITER roles", () => { + expect(canCreatePage(spaceWithRole(SpaceRole.ADMIN))).toBe(true); + expect(canCreatePage(spaceWithRole(SpaceRole.WRITER))).toBe(true); + }); + + it("is false for the READER role", () => { + expect(canCreatePage(spaceWithRole(SpaceRole.READER))).toBe(false); + }); + + it("is false when membership / role is missing", () => { + expect(canCreatePage(spaceWithRole(undefined))).toBe(false); + expect(canCreatePage({} as ISpace)).toBe(false); + }); + + it("filters an empty space list down to nothing writable", () => { + const spaces: ISpace[] = [ + spaceWithRole(SpaceRole.READER), + spaceWithRole(undefined), + ]; + expect(spaces.filter(canCreatePage)).toHaveLength(0); + }); +}); diff --git a/apps/client/src/features/home/components/can-create-page.ts b/apps/client/src/features/home/components/can-create-page.ts new file mode 100644 index 00000000..569c6128 --- /dev/null +++ b/apps/client/src/features/home/components/can-create-page.ts @@ -0,0 +1,15 @@ +import { ISpace } from "@/features/space/types/space.types.ts"; +import { SpaceRole } from "@/lib/types.ts"; + +// The /spaces list endpoint returns membership.role but NOT membership.permissions +// (only /spaces/info includes CASL rules). Mirror the server space-ability mapping: +// ADMIN and WRITER can manage pages, READER is read-only. So a space is writable +// for the current user when their role is ADMIN or WRITER. +// +// Extracted from new-note-button.tsx into this pure sibling module so it can be +// unit-tested without importing the component (whose dependency chain pulls in +// main.tsx and renders the whole app at import time). +export function canCreatePage(space: ISpace): boolean { + const role = space.membership?.role; + return role === SpaceRole.ADMIN || role === SpaceRole.WRITER; +} diff --git a/apps/client/src/features/home/components/new-note-button.tsx b/apps/client/src/features/home/components/new-note-button.tsx index 3a19cddd..ce58a604 100644 --- a/apps/client/src/features/home/components/new-note-button.tsx +++ b/apps/client/src/features/home/components/new-note-button.tsx @@ -6,18 +6,9 @@ import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts"; import { useCreatePageMutation } from "@/features/page/queries/page-query.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts"; import { ISpace } from "@/features/space/types/space.types.ts"; -import { SpaceRole } from "@/lib/types.ts"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts"; - -// The /spaces list endpoint returns membership.role but NOT membership.permissions -// (only /spaces/info includes CASL rules). Mirror the server space-ability mapping: -// ADMIN and WRITER can manage pages, READER is read-only. So a space is writable -// for the current user when their role is ADMIN or WRITER. -function canCreatePage(space: ISpace): boolean { - const role = space.membership?.role; - return role === SpaceRole.ADMIN || role === SpaceRole.WRITER; -} +import { canCreatePage } from "./can-create-page.ts"; // Prominent home-screen action to create a new note (page). Because the home // screen has no active space, the target space is resolved from the user's diff --git a/apps/client/src/features/page-history/components/history-diff.test.ts b/apps/client/src/features/page-history/components/history-diff.test.ts new file mode 100644 index 00000000..827e4119 --- /dev/null +++ b/apps/client/src/features/page-history/components/history-diff.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect } from "vitest"; +import { Schema } from "@tiptap/pm/model"; +import { computeHistoryDiff } from "./history-diff.ts"; + +// Unit tests for `computeHistoryDiff` (history-diff.ts) — the pure core extracted +// from history-editor.tsx. Given the editor schema plus old/new ProseMirror +// document JSON it produces {decorationSet, added, deleted, total}: inline +// decorations for text edits, whole-node decorations for added block nodes +// (image/table), widget "ghosts" for deleted block nodes (callout), and an empty +// diff for the first version or malformed JSON. +// +// We drive it with a hand-built ProseMirror schema rather than the real +// `mainExtensions` because importing the editor extensions pulls in the whole app +// (main.tsx) at module load. The schema below mirrors the relevant shape: a doc of +// block content, an `image` block atom and a `table` block treated as whole-node +// diffs, and a `callout` block treated as a deletable whole node. +const schema = new Schema({ + nodes: { + doc: { content: "block+" }, + paragraph: { + group: "block", + content: "inline*", + toDOM: () => ["p", 0], + }, + callout: { + group: "block", + content: "inline*", + toDOM: () => ["div", { class: "callout" }, 0], + }, + image: { + group: "block", + atom: true, + attrs: { src: { default: "" } }, + toDOM: (node) => ["img", { src: node.attrs.src }], + }, + table: { + group: "block", + content: "paragraph+", + toDOM: () => ["table", ["tbody", 0]], + }, + text: { group: "inline" }, + }, +}); + +const para = (text: string) => ({ + type: "paragraph", + content: text ? [{ type: "text", text }] : [], +}); +const docOf = (...blocks: any[]) => ({ type: "doc", content: blocks }); + +describe("computeHistoryDiff", () => { + it("returns an empty diff (counts 0) when there is no previous version", () => { + const diff = computeHistoryDiff(schema, docOf(para("hello")), undefined); + expect(diff.added).toBe(0); + expect(diff.deleted).toBe(0); + expect(diff.total).toBe(0); + expect(diff.decorationSet.find()).toHaveLength(0); + }); + + it("returns an empty diff when content is missing", () => { + const diff = computeHistoryDiff(schema, undefined, docOf(para("x"))); + expect(diff.total).toBe(0); + }); + + it("emits inline decorations and counts for a text edit", () => { + const prev = docOf(para("hello world")); + const next = docOf(para("hello brave world")); + const diff = computeHistoryDiff(schema, next, prev); + + expect(diff.added).toBeGreaterThan(0); + const decos = diff.decorationSet.find(); + expect(decos.length).toBeGreaterThan(0); + // An inline text addition is rendered with the inline-added class. + const classes = decos.map((d) => (d.spec as any)?.class ?? (d as any).type?.attrs?.class); + const hasInline = JSON.stringify(decos).includes("history-diff-added") || + classes.some((c) => c === "history-diff-added"); + expect(hasInline).toBe(true); + }); + + it("treats an added image as a whole-node addition", () => { + const prev = docOf(para("text")); + const next = docOf(para("text"), { type: "image", attrs: { src: "a.png" } }); + const diff = computeHistoryDiff(schema, next, prev); + expect(diff.added).toBeGreaterThan(0); + expect(JSON.stringify(diff.decorationSet.find())).toContain( + "history-diff-node-added", + ); + }); + + it("treats an added table as a whole-node addition", () => { + const prev = docOf(para("text")); + const next = docOf(para("text"), { + type: "table", + content: [para("cell")], + }); + const diff = computeHistoryDiff(schema, next, prev); + expect(diff.added).toBeGreaterThan(0); + expect(JSON.stringify(diff.decorationSet.find())).toContain( + "history-diff-node-added", + ); + }); + + it("renders a widget ghost for a deleted callout", () => { + const prev = docOf(para("text"), { + type: "callout", + content: [{ type: "text", text: "warning" }], + }); + const next = docOf(para("text")); + const diff = computeHistoryDiff(schema, next, prev); + expect(diff.deleted).toBeGreaterThan(0); + // The deleted whole node produces a widget decoration (toDOM callback). + const decos = diff.decorationSet.find(); + expect(decos.some((d) => (d as any).type?.toDOM || (d as any).type?.widget)).toBe( + true, + ); + }); + + it("falls back to an empty diff (no throw) on malformed version JSON", () => { + const malformed = { type: "doc", content: [{ type: "nonexistent-node" }] }; + expect(() => + computeHistoryDiff(schema, malformed, docOf(para("x"))), + ).not.toThrow(); + const diff = computeHistoryDiff(schema, malformed, docOf(para("x"))); + expect(diff.total).toBe(0); + expect(diff.decorationSet.find()).toHaveLength(0); + }); +}); diff --git a/apps/client/src/features/page-history/components/history-diff.ts b/apps/client/src/features/page-history/components/history-diff.ts new file mode 100644 index 00000000..e8392c30 --- /dev/null +++ b/apps/client/src/features/page-history/components/history-diff.ts @@ -0,0 +1,168 @@ +import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import { DOMSerializer, Node, Schema } from "@tiptap/pm/model"; +import { ChangeSet, simplifyChanges } from "@tiptap/pm/changeset"; +import { recreateTransform } from "@docmost/editor-ext"; + +export interface HistoryDiff { + decorationSet: DecorationSet; + added: number; + deleted: number; + total: number; +} + +// Block-level nodes that are diffed as a whole ("this image/table/callout was +// added/removed") instead of by inline character ranges. +const SPECIAL_NODE_TYPES = new Set([ + "image", + "attachment", + "video", + "excalidraw", + "drawio", + "mermaid", + "mathBlock", + "mathInline", + "table", + "details", + "callout", +]); + +// Pure core of the history diff (extracted from history-editor.tsx, behaviour +// preserving): given the editor schema and two ProseMirror document JSONs, return +// the decoration set plus added/deleted/total counts. The widget decorations carry +// lazy DOM-building callbacks (only run by ProseMirror at render time), so this +// function itself does no DOM work and needs no live editor instance. +// +// `previousContent` undefined -> first version, so there is nothing to diff +// (empty decorations, all counts 0). Malformed JSON that throws while building +// nodes falls back to the same empty diff so the caller can still render plain +// content without crashing. +export function computeHistoryDiff( + schema: Schema, + content: any, + previousContent?: any, +): HistoryDiff { + const empty: HistoryDiff = { + decorationSet: DecorationSet.empty, + added: 0, + deleted: 0, + total: 0, + }; + + if (!content || !previousContent) { + return empty; + } + + try { + const oldContent = Node.fromJSON(schema, previousContent); + const newContent = Node.fromJSON(schema, content); + + const tr = recreateTransform(oldContent, newContent, { + complexSteps: false, + wordDiffs: true, + simplifyDiff: true, + }); + + const changeSet = ChangeSet.create(oldContent).addSteps( + tr.doc, + tr.mapping.maps, + [], + ); + const changes = simplifyChanges(changeSet.changes, newContent); + + const decorations: Decoration[] = []; + let addedCount = 0; + let deletedCount = 0; + let changeIndex = 0; + + for (const change of changes) { + if (change.toB > change.fromB) { + changeIndex++; + const currentIndex = changeIndex; + let foundSpecialNode: { node: Node; pos: number } | null = null; + newContent.nodesBetween(change.fromB, change.toB, (node, pos) => { + if (SPECIAL_NODE_TYPES.has(node.type.name)) { + const nodeEnd = pos + node.nodeSize; + if (change.fromB <= pos && change.toB >= nodeEnd) { + foundSpecialNode = { node, pos }; + return false; + } + } + }); + + if (foundSpecialNode) { + const special = foundSpecialNode as { node: Node; pos: number }; + const nodeEnd = special.pos + special.node.nodeSize; + decorations.push( + Decoration.node(special.pos, nodeEnd, { + class: "history-diff-node-added", + "data-diff-index": String(currentIndex), + }), + ); + } else { + decorations.push( + Decoration.inline(change.fromB, change.toB, { + class: "history-diff-added", + "data-diff-index": String(currentIndex), + }), + ); + } + addedCount += 1; + } + if (change.toA > change.fromA) { + changeIndex++; + const currentIndex = changeIndex; + let foundDeletedNode: { node: Node; pos: number } | null = null; + oldContent.nodesBetween(change.fromA, change.toA, (node, pos) => { + if (SPECIAL_NODE_TYPES.has(node.type.name)) { + const nodeEnd = pos + node.nodeSize; + if (change.fromA <= pos && change.toA >= nodeEnd) { + foundDeletedNode = { node, pos }; + return false; + } + } + }); + + if (foundDeletedNode) { + const deletedNode = foundDeletedNode as { node: Node; pos: number }; + decorations.push( + Decoration.widget(change.fromB, () => { + const wrapper = document.createElement("div"); + wrapper.className = "history-diff-node-deleted"; + wrapper.setAttribute("data-diff-index", String(currentIndex)); + const serializer = DOMSerializer.fromSchema(schema); + const dom = serializer.serializeNode(deletedNode.node); + wrapper.appendChild(dom); + return wrapper; + }), + ); + } else { + const deletedText = oldContent.textBetween( + change.fromA, + change.toA, + "", + ); + if (deletedText) { + decorations.push( + Decoration.widget(change.fromB, () => { + const span = document.createElement("span"); + span.className = "history-diff-deleted"; + span.setAttribute("data-diff-index", String(currentIndex)); + span.textContent = deletedText; + return span; + }), + ); + } + } + deletedCount += 1; + } + } + + const decorationSet = DecorationSet.create(newContent, decorations); + const total = addedCount + deletedCount; + return { decorationSet, added: addedCount, deleted: deletedCount, total }; + } catch (e) { + // Malformed version JSON: fall back to a plain (no-diff) render. + console.error("History diff failed:", e); + return empty; + } +} diff --git a/apps/client/src/features/page-history/components/history-editor.tsx b/apps/client/src/features/page-history/components/history-editor.tsx index d071abc3..eba92137 100644 --- a/apps/client/src/features/page-history/components/history-editor.tsx +++ b/apps/client/src/features/page-history/components/history-editor.tsx @@ -3,11 +3,9 @@ import { useEffect } from "react"; import { EditorContent, useEditor } from "@tiptap/react"; import { mainExtensions } from "@/features/editor/extensions/extensions"; import { Title } from "@mantine/core"; -import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import { DecorationSet } from "@tiptap/pm/view"; import historyClasses from "./css/history.module.css"; -import { recreateTransform } from "@docmost/editor-ext"; -import { DOMSerializer, Node } from "@tiptap/pm/model"; -import { ChangeSet, simplifyChanges } from "@tiptap/pm/changeset"; +import { computeHistoryDiff } from "./history-diff.ts"; import { useAtom } from "jotai"; import { diffCountsAtom, @@ -36,142 +34,18 @@ export function HistoryEditor({ useEffect(() => { if (!editor || !content) return; - let decorationSet = DecorationSet.empty; - let addedCount = 0; - let deletedCount = 0; + // Pure diff computation lives in history-diff.ts; the component keeps the + // editor side-effects (rendering the new content + wiring decorations). + const { decorationSet, added, deleted, total } = computeHistoryDiff( + editor.schema, + content, + previousContent, + ); - if (previousContent) { - try { - const schema = editor.schema; - const oldContent = Node.fromJSON(schema, previousContent); - const newContent = Node.fromJSON(schema, content); + editor.commands.setContent(content); - const tr = recreateTransform(oldContent, newContent, { - complexSteps: false, - wordDiffs: true, - simplifyDiff: true, - }); - - const changeSet = ChangeSet.create(oldContent).addSteps( - tr.doc, - tr.mapping.maps, - [], - ); - const changes = simplifyChanges(changeSet.changes, newContent); - - editor.commands.setContent(content); - - const specialNodeTypes = new Set([ - "image", - "attachment", - "video", - "excalidraw", - "drawio", - "mermaid", - "mathBlock", - "mathInline", - "table", - "details", - "callout", - ]); - - const decorations: Decoration[] = []; - let changeIndex = 0; - - for (const change of changes) { - if (change.toB > change.fromB) { - changeIndex++; - const currentIndex = changeIndex; - let foundSpecialNode: { node: Node; pos: number } | null = null; - newContent.nodesBetween(change.fromB, change.toB, (node, pos) => { - if (specialNodeTypes.has(node.type.name)) { - const nodeEnd = pos + node.nodeSize; - if (change.fromB <= pos && change.toB >= nodeEnd) { - foundSpecialNode = { node, pos }; - return false; - } - } - }); - - if (foundSpecialNode) { - const nodeEnd = - foundSpecialNode.pos + foundSpecialNode.node.nodeSize; - decorations.push( - Decoration.node(foundSpecialNode.pos, nodeEnd, { - class: "history-diff-node-added", - "data-diff-index": String(currentIndex), - }), - ); - } else { - decorations.push( - Decoration.inline(change.fromB, change.toB, { - class: "history-diff-added", - "data-diff-index": String(currentIndex), - }), - ); - } - addedCount += 1; - } - if (change.toA > change.fromA) { - changeIndex++; - const currentIndex = changeIndex; - let foundDeletedNode: { node: Node; pos: number } | null = null; - oldContent.nodesBetween(change.fromA, change.toA, (node, pos) => { - if (specialNodeTypes.has(node.type.name)) { - const nodeEnd = pos + node.nodeSize; - if (change.fromA <= pos && change.toA >= nodeEnd) { - foundDeletedNode = { node, pos }; - return false; - } - } - }); - - if (foundDeletedNode) { - decorations.push( - Decoration.widget(change.fromB, () => { - const wrapper = document.createElement("div"); - wrapper.className = "history-diff-node-deleted"; - wrapper.setAttribute("data-diff-index", String(currentIndex)); - const serializer = DOMSerializer.fromSchema(schema); - const dom = serializer.serializeNode(foundDeletedNode!.node); - wrapper.appendChild(dom); - return wrapper; - }), - ); - } else { - const deletedText = oldContent.textBetween( - change.fromA, - change.toA, - "", - ); - if (deletedText) { - decorations.push( - Decoration.widget(change.fromB, () => { - const span = document.createElement("span"); - span.className = "history-diff-deleted"; - span.setAttribute("data-diff-index", String(currentIndex)); - span.textContent = deletedText; - return span; - }), - ); - } - } - deletedCount += 1; - } - } - - decorationSet = DecorationSet.create(newContent, decorations); - } catch (e) { - console.error("History diff failed:", e); - editor.commands.setContent(content); - } - } else { - editor.commands.setContent(content); - } - - const total = addedCount + deletedCount; // @ts-ignore - setDiffCounts({ added: addedCount, deleted: deletedCount, total }); + setDiffCounts({ added, deleted, total }); editor.setOptions({ editorProps: { diff --git a/apps/server/src/core/label/dto/label.dto.spec.ts b/apps/server/src/core/label/dto/label.dto.spec.ts new file mode 100644 index 00000000..b4db4773 --- /dev/null +++ b/apps/server/src/core/label/dto/label.dto.spec.ts @@ -0,0 +1,78 @@ +import 'reflect-metadata'; +import { plainToInstance } from 'class-transformer'; +import { validate, Matches } from 'class-validator'; +import { AddLabelsDto } from './label.dto'; + +// API-boundary validation for label names. `AddLabelsDto.names` applies the +// matcher /^[a-z0-9_-][a-z0-9_~-]*$/ to every element (each: true): a name must +// start with a lowercase letter, digit, hyphen or underscore (NOT a tilde) and +// then contain only those plus tildes. This guards the label storage key against +// uppercase, whitespace, accents and tilde-leading names. +// +// NOTE: the production DTO also runs `@Transform(normalizeLabelName)` BEFORE the +// matcher (trim + collapse whitespace to '-' + lowercase). `normalizeLabelName` +// itself is already covered (utils.spec.ts), so we deliberately do two things: +// 1) lock the raw @Matches regex in isolation (a mirror DTO with ONLY the same +// matcher) for the exact accept/reject set the regex must enforce; and +// 2) sanity-check the real AddLabelsDto end-to-end for inputs whose normalized +// form still exercises the matcher. + +// Mirrors ONLY the production matcher so we test the regex, not the transform. +class NameMatchProbe { + @Matches(/^[a-z0-9_-][a-z0-9_~-]*$/) + name: string; +} + +async function matcherErrors(name: string) { + const dto = plainToInstance(NameMatchProbe, { name }); + return validate(dto as object); +} + +function hasError(errors: any[], property: string, constraint?: string) { + const err = errors.find((e) => e.property === property); + if (!err) return false; + if (!constraint) return true; + return Object.keys(err.constraints ?? {}).includes(constraint); +} + +describe('label name @Matches regex', () => { + it('accepts valid names', async () => { + for (const name of ['foo', 'a~b', '1-2_3', '-lead']) { + expect(hasError(await matcherErrors(name), 'name', 'matches')).toBe(false); + } + }); + + it('rejects a tilde-leading name', async () => { + expect(hasError(await matcherErrors('~lead'), 'name', 'matches')).toBe(true); + }); + + it('rejects whitespace, accents and empty', async () => { + expect(hasError(await matcherErrors('a b'), 'name', 'matches')).toBe(true); + expect(hasError(await matcherErrors('héllo'), 'name', 'matches')).toBe(true); + expect(hasError(await matcherErrors(''), 'name', 'matches')).toBe(true); + }); +}); + +describe('AddLabelsDto.names (matcher applied per element)', () => { + async function validateNames(names: unknown) { + const dto = plainToInstance(AddLabelsDto, { pageId: 'p1', names }); + return validate(dto as object); + } + + it('accepts a list of valid names', async () => { + const errors = await validateNames(['foo', 'a~b', '1-2_3']); + expect(hasError(errors, 'names', 'matches')).toBe(false); + }); + + it('rejects a tilde-leading name even after normalization', async () => { + // normalizeLabelName lowercases/collapses whitespace but does not strip a + // leading tilde, so the matcher still fails. + const errors = await validateNames(['~lead']); + expect(hasError(errors, 'names', 'matches')).toBe(true); + }); + + it('rejects an accented name even after normalization', async () => { + const errors = await validateNames(['héllo']); + expect(hasError(errors, 'names', 'matches')).toBe(true); + }); +}); diff --git a/apps/server/src/core/notification/notification.constants.spec.ts b/apps/server/src/core/notification/notification.constants.spec.ts new file mode 100644 index 00000000..02caa096 --- /dev/null +++ b/apps/server/src/core/notification/notification.constants.spec.ts @@ -0,0 +1,77 @@ +import { + NotificationType, + DIRECT_NOTIFICATION_TYPES, + UPDATES_NOTIFICATION_TYPES, + getTypesForTab, +} from './notification.constants'; + +// Contract tests for `getTypesForTab` (notification.constants.ts), which maps a +// notification tab to the set of notification types it should contain. +// - 'direct' -> a 5-type whitelist (mentions / comments / permission grants) +// - 'updates' -> exactly [PAGE_UPDATED] +// - 'all' -> undefined (no type filter) + +describe('getTypesForTab', () => { + it("returns exactly the 5 whitelisted types for 'direct'", () => { + expect(getTypesForTab('direct')).toEqual([ + NotificationType.COMMENT_USER_MENTION, + NotificationType.COMMENT_CREATED, + NotificationType.COMMENT_RESOLVED, + NotificationType.PAGE_USER_MENTION, + NotificationType.PAGE_PERMISSION_GRANTED, + ]); + expect(getTypesForTab('direct')).toHaveLength(5); + expect(getTypesForTab('direct')).toBe(DIRECT_NOTIFICATION_TYPES); + }); + + it("returns [PAGE_UPDATED] for 'updates'", () => { + expect(getTypesForTab('updates')).toEqual([NotificationType.PAGE_UPDATED]); + expect(getTypesForTab('updates')).toBe(UPDATES_NOTIFICATION_TYPES); + }); + + it("returns undefined (no filter) for 'all'", () => { + expect(getTypesForTab('all')).toBeUndefined(); + }); +}); + +// CONTRACT vs the repository query (notification.repo.ts ~line 57): +// direct -> WHERE type != PAGE_UPDATED +// updates -> WHERE type = PAGE_UPDATED +// +// For 'updates' the whitelist and the SQL agree exactly. For 'direct' they +// DIVERGE: the whitelist is a positive 5-type allow-list, but `type != PAGE_UPDATED` +// returns EVERY non-PAGE_UPDATED type — including verification/approval types that +// are NOT in the whitelist. So the repo would surface notifications the 'direct' +// tab is not supposed to contain. We model the repo predicate and assert it should +// match the whitelist; the 'direct' case genuinely fails today, so it is locked with +// `test.failing` (suite stays green, flips red once repo + whitelist are reconciled). + +// What the repo's WHERE clause would actually return, given all known types. +const ALL_TYPES = Object.values(NotificationType); +function repoTypesForTab(tab: 'direct' | 'updates'): string[] { + if (tab === 'direct') { + return ALL_TYPES.filter((t) => t !== NotificationType.PAGE_UPDATED); + } + return ALL_TYPES.filter((t) => t === NotificationType.PAGE_UPDATED); +} + +describe('getTypesForTab vs notification.repo query', () => { + it("'updates' whitelist matches the repo's `type = PAGE_UPDATED` filter", () => { + expect(new Set(repoTypesForTab('updates'))).toEqual( + new Set(getTypesForTab('updates')), + ); + }); + + // BUG LOCK: the 'direct' whitelist (5 types) does not match what the repo's + // `type != PAGE_UPDATED` filter returns (all non-PAGE_UPDATED types). This SHOULD + // match; it currently does not. Flips green once the repo filters by the whitelist + // (e.g. `type IN (DIRECT_NOTIFICATION_TYPES)`). + test.failing( + "'direct' whitelist matches the repo's `type != PAGE_UPDATED` filter", + () => { + expect(new Set(repoTypesForTab('direct'))).toEqual( + new Set(getTypesForTab('direct')), + ); + }, + ); +}); diff --git a/apps/server/src/core/page/dto/move-page.dto.spec.ts b/apps/server/src/core/page/dto/move-page.dto.spec.ts new file mode 100644 index 00000000..7b71995a --- /dev/null +++ b/apps/server/src/core/page/dto/move-page.dto.spec.ts @@ -0,0 +1,70 @@ +import 'reflect-metadata'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +// Imported exactly as page.service.ts does, so we test the real key generator +// that feeds `position` at the API boundary. +import { generateJitteredKeyBetween } from 'fractional-indexing-jittered'; +import { MovePageDto } from './move-page.dto'; + +// PARITY BUG (Gitea #139, item 6): MovePageDto.position is bounded with +// @MinLength(5) @MaxLength(12), but the actual positions are fractional-indexing +// keys produced by `generateJitteredKeyBetween` (the same generator page.service +// uses). Those bounds do NOT match the generator's real output range: +// - a freshly generated key (null,null) is short (~5 chars) and currently +// squeaks past MinLength(5); +// - but DENSE between-inserts (repeatedly inserting between two adjacent keys) +// grow the key well past 12 chars, which MaxLength(12) would WRONGLY reject — +// a valid ordering key the server itself generated would be refused on move. +// +// The tests below assert the CORRECT contract: any key the generator can produce +// must satisfy the DTO. The genuinely-failing case is marked `test.failing` so the +// suite stays green while locking the bug; it flips red (alerting us) once the DTO +// bounds are widened to cover the generator's real range. + +function constraintErrors(position: unknown) { + const dto = plainToInstance(MovePageDto, { + pageId: 'page-1', + position, + }); + return validate(dto as object); +} + +function hasError(errors: any[], property: string) { + return errors.some((e) => e.property === property); +} + +describe('MovePageDto.position vs generateJitteredKeyBetween parity', () => { + it('accepts a freshly generated first key', async () => { + const key = generateJitteredKeyBetween(null, null); + const errors = await constraintErrors(key); + expect(hasError(errors, 'position')).toBe(false); + }); + + it('accepts a key appended after an existing key', async () => { + const first = generateJitteredKeyBetween(null, null); + const next = generateJitteredKeyBetween(first, null); + const errors = await constraintErrors(next); + expect(hasError(errors, 'position')).toBe(false); + }); + + // BUG LOCK: dense between-inserts produce keys longer than 12 chars, which + // MaxLength(12) rejects even though they are valid ordering keys. This SHOULD + // pass; it currently fails. Flips green when the DTO bound is fixed. + test.failing( + 'accepts dense between-inserted keys (currently rejected by MaxLength(12))', + async () => { + let lo = generateJitteredKeyBetween(null, null); + let hi = generateJitteredKeyBetween(lo, null); + // Repeatedly insert just above `lo`, shrinking the gap so the key grows. + let longest = lo; + for (let i = 0; i < 40; i++) { + const mid = generateJitteredKeyBetween(lo, hi); + if (mid.length > longest.length) longest = mid; + hi = mid; + } + expect(longest.length).toBeGreaterThan(12); // sanity: we produced a long key + const errors = await constraintErrors(longest); + expect(hasError(errors, 'position')).toBe(false); + }, + ); +}); diff --git a/apps/server/src/core/search/search.service.spec.ts b/apps/server/src/core/search/search.service.spec.ts index efd4d2b8..1def74c0 100644 --- a/apps/server/src/core/search/search.service.spec.ts +++ b/apps/server/src/core/search/search.service.spec.ts @@ -1,4 +1,4 @@ -import { SearchService } from './search.service'; +import { SearchService, buildTsQuery } from './search.service'; describe('SearchService', () => { it('should be defined', () => { @@ -99,3 +99,59 @@ describe('SearchService.searchSuggestions — onlyTemplates filter', () => { expect(isTemplateWhereCall(pageBuilder)).toBeUndefined(); }); }); + +// Unit tests for `buildTsQuery` (extracted from search.service.ts). It turns a raw +// user query into a prefix tsquery string fed to `to_tsquery('english', ...)`. +// +// REAL BUG (Gitea #139, item 10): the previous inline `tsquery(query.trim() + '*')` +// let to_tsquery operator characters through, so adversarial inputs could produce a +// fragment that to_tsquery rejects -> 500. The extraction sanitizes the input +// (strip everything but letters/numbers/whitespace) so these inputs degrade to a +// safe, neutral query with NO throw, while normal queries keep working. +describe('buildTsQuery', () => { + it('builds a prefix query for a normal single word', () => { + expect(buildTsQuery('hello')).toBe('hello:*'); + }); + + it('joins multiple words with AND and a trailing prefix match', () => { + expect(buildTsQuery('foo bar')).toBe('foo&bar:*'); + }); + + it('preserves accented and non-Latin words', () => { + expect(buildTsQuery('héllo café')).toBe('héllo&café:*'); + expect(buildTsQuery('日本語')).toBe('日本語:*'); + }); + + it('neutralizes to_tsquery operator inputs without throwing', () => { + // Each of these previously risked an invalid to_tsquery -> 500. They must now + // produce a safe (here empty) query and never throw. + for (const input of ['&', '!', '*', '<->', '\\']) { + expect(() => buildTsQuery(input)).not.toThrow(); + expect(buildTsQuery(input)).toBe(''); + } + }); + + it('handles stopword-only input safely', () => { + // pg-tsquery still tokenizes stopwords; to_tsquery reduces them to nothing. + // The important contract is: no throw, and a deterministic string. + expect(() => buildTsQuery('the a of')).not.toThrow(); + expect(buildTsQuery('the a of')).toBe('the&a&of:*'); + }); + + it('returns empty string for empty / whitespace-only / null-ish input', () => { + expect(buildTsQuery('')).toBe(''); + expect(buildTsQuery(' ')).toBe(''); + expect(buildTsQuery(undefined as unknown as string)).toBe(''); + }); + + it('handles a very long input without throwing', () => { + const long = 'a'.repeat(10000); + expect(() => buildTsQuery(long)).not.toThrow(); + expect(buildTsQuery(long)).toBe(`${long}:*`); + }); + + it('strips punctuation embedded in otherwise valid words', () => { + expect(buildTsQuery('c++ code')).toBe('c&code:*'); + expect(buildTsQuery('a-b-c')).toBe('a&b&c:*'); + }); +}); diff --git a/apps/server/src/core/search/search.service.ts b/apps/server/src/core/search/search.service.ts index c3807398..f844941e 100644 --- a/apps/server/src/core/search/search.service.ts +++ b/apps/server/src/core/search/search.service.ts @@ -12,6 +12,28 @@ import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo' // eslint-disable-next-line @typescript-eslint/no-require-imports const tsquery = require('pg-tsquery')(); +// Build a safe prefix tsquery string from a raw user query. +// +// The previous inline form `tsquery(query.trim() + '*')` passed user input +// (including to_tsquery operators like `&`, `|`, `!`, `<->`, `*`, backslashes) +// straight through. pg-tsquery would then emit operator fragments that +// `to_tsquery('english', ...)` can reject as a syntax error, turning a search +// into a 500. We strip everything that is not a letter, number or whitespace +// BEFORE handing the text to pg-tsquery, so adversarial input degrades to a +// neutral (possibly empty) query instead of throwing, while normal word queries +// (incl. accented / non-Latin words) are unaffected. +export function buildTsQuery(raw: string): string { + const cleaned = (raw ?? '') + .normalize('NFC') + // Keep Unicode letters/numbers and whitespace; drop everything else. + .replace(/[^\p{L}\p{N}\s]+/gu, ' ') + .replace(/\s+/g, ' ') + .trim(); + + if (!cleaned) return ''; + return tsquery(cleaned + '*'); +} + @Injectable() export class SearchService { constructor( @@ -34,7 +56,7 @@ export class SearchService { if (query.length < 1) { return { items: [] }; } - const searchQuery = tsquery(query.trim() + '*'); + const searchQuery = buildTsQuery(query); let queryResults = this.db .selectFrom('pages') diff --git a/packages/editor-ext/src/lib/embed-provider.spec.ts b/packages/editor-ext/src/lib/embed-provider.spec.ts new file mode 100644 index 00000000..266c3c7f --- /dev/null +++ b/packages/editor-ext/src/lib/embed-provider.spec.ts @@ -0,0 +1,184 @@ +import { describe, it, expect } from "vitest"; +import { + getEmbedUrlAndProvider, + getEmbedProviderById, + embedProviders, +} from "./embed-provider"; + +// Contract tests for the embed providers (embed-provider.ts). `getEmbedUrlAndProvider` +// matches a pasted URL against an ordered list of provider regexes and rewrites it +// to the provider's canonical embeddable URL; if nothing matches it falls back to a +// raw iframe. Each provider has a share-URL -> embed-URL contract plus passthrough +// for already-embedded URLs. A regression here means an embed silently renders the +// wrong thing or an unsupported provider, so we pin all 11 providers. + +describe("getEmbedProviderById", () => { + it("looks providers up case-insensitively by id", () => { + expect(getEmbedProviderById("youtube")?.name).toBe("YouTube"); + expect(getEmbedProviderById("YOUTUBE")?.name).toBe("YouTube"); + expect(getEmbedProviderById("gdrive")?.name).toBe("Google Drive"); + }); + + it("returns undefined for an unknown id", () => { + expect(getEmbedProviderById("notaprovider")).toBeUndefined(); + }); + + it("registers exactly 11 providers", () => { + expect(embedProviders).toHaveLength(11); + }); +}); + +describe("getEmbedUrlAndProvider", () => { + describe("YouTube", () => { + it("rewrites watch?v / youtu.be / m. / music. to youtube-nocookie embeds", () => { + const expected = "https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ"; + for (const url of [ + "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "https://youtu.be/dQw4w9WgXcQ", + "https://m.youtube.com/watch?v=dQw4w9WgXcQ", + "https://music.youtube.com/watch?v=dQw4w9WgXcQ", + ]) { + expect(getEmbedUrlAndProvider(url)).toEqual({ + provider: "youtube", + embedUrl: expected, + }); + } + }); + + it("passes an already-/embed/ URL through unchanged", () => { + const url = "https://www.youtube.com/embed/dQw4w9WgXcQ"; + expect(getEmbedUrlAndProvider(url)).toEqual({ + provider: "youtube", + embedUrl: url, + }); + }); + }); + + describe("Vimeo", () => { + it("extracts the numeric video id from channel/group/album/plain URLs", () => { + expect(getEmbedUrlAndProvider("https://vimeo.com/123456789").embedUrl).toBe( + "https://player.vimeo.com/video/123456789", + ); + expect( + getEmbedUrlAndProvider( + "https://vimeo.com/channels/staffpicks/123456789", + ).embedUrl, + ).toBe("https://player.vimeo.com/video/123456789"); + expect( + getEmbedUrlAndProvider("https://vimeo.com/groups/name/videos/123456789") + .embedUrl, + ).toBe("https://player.vimeo.com/video/123456789"); + expect( + getEmbedUrlAndProvider("https://vimeo.com/album/123/video/456789") + .embedUrl, + ).toBe("https://player.vimeo.com/video/456789"); + }); + }); + + describe("Loom", () => { + it("rewrites /share/ to /embed/", () => { + expect(getEmbedUrlAndProvider("https://loom.com/share/abc123")).toEqual({ + provider: "loom", + embedUrl: "https://loom.com/embed/abc123", + }); + }); + + it("passes an already-/embed/ URL through", () => { + const url = "https://loom.com/embed/abc123"; + expect(getEmbedUrlAndProvider(url).embedUrl).toBe(url); + }); + }); + + describe("Airtable", () => { + it("rewrites a share URL to an /embed/ URL", () => { + expect( + getEmbedUrlAndProvider("https://airtable.com/shrABC123/tblXYZ").embedUrl, + ).toBe("https://airtable.com/embed/shrABC123/tblXYZ"); + }); + + it("passes an already-/embed/ URL through", () => { + const url = "https://airtable.com/embed/shrABC123"; + expect(getEmbedUrlAndProvider(url).embedUrl).toBe(url); + }); + }); + + describe("Miro", () => { + it("rewrites /app/board/ to a /app/live-embed/ URL", () => { + const res = getEmbedUrlAndProvider("https://miro.com/app/board/uXjVABC="); + expect(res.provider).toBe("miro"); + expect(res.embedUrl).toContain("https://miro.com/app/live-embed/uXjVABC="); + }); + + it("passes an already-/live-embed/ URL through", () => { + const url = "https://miro.com/app/live-embed/uXjVABC=?embedMode=view_only"; + expect(getEmbedUrlAndProvider(url).embedUrl).toBe(url); + }); + }); + + describe("Figma", () => { + it("wraps the file URL in the figma embed host (id length 22..128)", () => { + const id22 = "a".repeat(22); + const id128 = "b".repeat(128); + const url22 = `https://www.figma.com/file/${id22}/Design`; + const url128 = `https://www.figma.com/design/${id128}/Design`; + expect(getEmbedUrlAndProvider(url22).embedUrl).toBe( + `https://www.figma.com/embed?url=${url22}&embed_host=docmost`, + ); + expect(getEmbedUrlAndProvider(url128).provider).toBe("figma"); + }); + + it("does NOT match a too-short id (< 22 chars) -> iframe fallback", () => { + const url = `https://www.figma.com/file/${"a".repeat(10)}/Design`; + expect(getEmbedUrlAndProvider(url).provider).toBe("iframe"); + }); + }); + + describe("Google Drive / Sheets", () => { + it("rewrites a gdrive file URL to /preview using the id (match[4])", () => { + expect( + getEmbedUrlAndProvider("https://drive.google.com/file/d/1AbC_dEf-Gh/view") + .embedUrl, + ).toBe("https://drive.google.com/file/d/1AbC_dEf-Gh/preview"); + }); + + it("passes a gsheets URL through unchanged", () => { + const url = "https://docs.google.com/spreadsheets/d/1AbC_dEf-Gh/edit"; + expect(getEmbedUrlAndProvider(url)).toEqual({ + provider: "google sheets", + embedUrl: url, + }); + }); + }); + + describe("Typeform / Framer (passthrough providers)", () => { + it("passes typeform and framer URLs through unchanged", () => { + const tf = "https://my.typeform.com/to/abc123"; + expect(getEmbedUrlAndProvider(tf)).toEqual({ + provider: "typeform", + embedUrl: tf, + }); + const framer = "https://www.framer.com/embed/foo-bar"; + expect(getEmbedUrlAndProvider(framer)).toEqual({ + provider: "framer", + embedUrl: framer, + }); + }); + }); + + describe("fallback", () => { + it("returns the raw iframe provider for an unknown URL", () => { + const url = "https://example.com/some/random/page"; + expect(getEmbedUrlAndProvider(url)).toEqual({ + provider: "iframe", + embedUrl: url, + }); + }); + + it("returns iframe for junk / non-URL input", () => { + expect(getEmbedUrlAndProvider("not a url at all")).toEqual({ + provider: "iframe", + embedUrl: "not a url at all", + }); + }); + }); +}); diff --git a/packages/editor-ext/src/lib/indent.spec.ts b/packages/editor-ext/src/lib/indent.spec.ts new file mode 100644 index 00000000..e0bc716b --- /dev/null +++ b/packages/editor-ext/src/lib/indent.spec.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { clampIndent } from "./indent"; + +// Unit tests for `clampIndent` (indent.ts) — the pure core of the indent +// extension. The extension stores an integer `indent` level on paragraphs and +// headings (default range [0, 8]); `clampIndent` keeps every code path +// (increment via Tab, outdent via Shift-Tab, and parsing junk `data-indent` +// attributes from pasted HTML) inside the configured bounds. A regression would +// let an out-of-range / NaN level reach renderHTML and produce broken padding. +// +// NOTE: the "excluded containers stay flat" behaviour (paragraphs inside list +// items / table cells / code blocks) lives in `updateIndent` / +// `appendTransaction`, which require a real ProseMirror EditorState and document +// resolution — it cannot be isolated into a pure function, so it is intentionally +// out of scope here and is exercised at the extension/editor level. + +const MIN = 0; +const MAX = 8; + +describe("clampIndent", () => { + it("leaves in-range values untouched", () => { + expect(clampIndent(0, MIN, MAX)).toBe(0); + expect(clampIndent(4, MIN, MAX)).toBe(4); + expect(clampIndent(8, MIN, MAX)).toBe(8); + }); + + it("clamps increments at the max (8)", () => { + // Tab at level 8 would compute 9 -> stays at 8. + expect(clampIndent(8 + 1, MIN, MAX)).toBe(8); + expect(clampIndent(100, MIN, MAX)).toBe(8); + }); + + it("clamps outdents at the min (0)", () => { + // Shift-Tab at level 0 would compute -1 -> stays at 0. + expect(clampIndent(0 - 1, MIN, MAX)).toBe(0); + expect(clampIndent(-100, MIN, MAX)).toBe(0); + }); + + it("treats non-finite junk (NaN / Infinity) as the min", () => { + // parseInt('abc', 10) === NaN, which must not propagate to the attribute. + expect(clampIndent(NaN, MIN, MAX)).toBe(MIN); + expect(clampIndent(Infinity, MIN, MAX)).toBe(MIN); + expect(clampIndent(-Infinity, MIN, MAX)).toBe(MIN); + }); + + it("truncates fractional values toward zero before clamping", () => { + expect(clampIndent(3.9, MIN, MAX)).toBe(3); + expect(clampIndent(-0.5, MIN, MAX)).toBe(MIN); + }); + + it("clamps junk data-indent values (negative / > max) to the rails", () => { + // Mirrors parseHTML(parseInt(data-indent, 10)) for adversarial pasted HTML. + expect(clampIndent(-3, MIN, MAX)).toBe(MIN); + expect(clampIndent(42, MIN, MAX)).toBe(MAX); + }); +}); diff --git a/packages/editor-ext/src/lib/indent.ts b/packages/editor-ext/src/lib/indent.ts index 6e4ad243..fa89dad9 100644 --- a/packages/editor-ext/src/lib/indent.ts +++ b/packages/editor-ext/src/lib/indent.ts @@ -33,7 +33,9 @@ const NON_INDENTABLE_ANCESTORS = new Set([ 'codeBlock', ]); -const clampIndent = (value: number, min: number, max: number): number => { +// Exported for unit testing: clamps a (possibly junk) indent level into the +// [min, max] range, treating any non-finite value as `min`. +export const clampIndent = (value: number, min: number, max: number): number => { if (!Number.isFinite(value)) return min; return Math.max(min, Math.min(max, Math.trunc(value))); }; diff --git a/packages/editor-ext/src/lib/utils.spec.ts b/packages/editor-ext/src/lib/utils.spec.ts new file mode 100644 index 00000000..9955fb49 --- /dev/null +++ b/packages/editor-ext/src/lib/utils.spec.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest"; +import { sanitizeUrl, isInternalFileUrl } from "./utils"; + +// Security contract tests for the editor URL helpers (utils.ts). +// `sanitizeUrl` wraps @braintree/sanitize-url and maps its "about:blank" XSS +// sentinel to "" so callers can treat empty as "blocked". `isInternalFileUrl` +// decides whether a URL points at our own file-serving routes (used to skip +// external-link affordances). A regression here is a stored-XSS or SSRF vector. + +describe("sanitizeUrl", () => { + it("blocks dangerous schemes (returns empty string)", () => { + expect(sanitizeUrl("javascript:alert(1)")).toBe(""); + expect(sanitizeUrl("data:text/html,")).toBe(""); + expect(sanitizeUrl("vbscript:msgbox(1)")).toBe(""); + // case-insensitive + leading whitespace must not bypass the filter + expect(sanitizeUrl(" JaVaScRiPt:alert(1)")).toBe(""); + }); + + it("returns empty string for empty / undefined input", () => { + expect(sanitizeUrl(undefined)).toBe(""); + expect(sanitizeUrl("")).toBe(""); + }); + + it("allows safe https, relative file and mailto URLs", () => { + // braintree normalises https URLs (may add a trailing slash); just assert + // the scheme survives and it is not blanked out. + expect(sanitizeUrl("https://example.com/page")).toMatch(/^https:\/\/example\.com\/page/); + expect(sanitizeUrl("/api/files/abc-123")).toBe("/api/files/abc-123"); + expect(sanitizeUrl("mailto:user@example.com")).toBe("mailto:user@example.com"); + }); +}); + +describe("isInternalFileUrl", () => { + it("is true only for /api/files/ and /files/ prefixes", () => { + expect(isInternalFileUrl("/api/files/abc")).toBe(true); + expect(isInternalFileUrl("/files/abc")).toBe(true); + }); + + it("trims whitespace before matching the prefix", () => { + expect(isInternalFileUrl(" /api/files/abc")).toBe(true); + expect(isInternalFileUrl("\t/files/abc")).toBe(true); + }); + + it("is false for external URLs and other paths", () => { + expect(isInternalFileUrl("https://example.com/api/files/abc")).toBe(false); + expect(isInternalFileUrl("/other/files/abc")).toBe(false); + expect(isInternalFileUrl("/apifiles/abc")).toBe(false); + }); + + it("is false for empty / undefined input", () => { + expect(isInternalFileUrl(undefined)).toBe(false); + expect(isInternalFileUrl("")).toBe(false); + }); +});