diff --git a/CHANGELOG.md b/CHANGELOG.md index 296c253..6041f6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ## [Unreleased] +### Added +- `frontend/src/utils/scenePreparation.ts` with scene-preparation utilities (`expandLabelsToNative`, `prepareElementsForScene`, `convertElementsPreservingImageProps`) +- Backend tests: `tests/backend/db-unit.test.ts`, `tests/backend/mcp-contract.test.ts`, `tests/backend/mcp-sanitization.test.ts`, `tests/backend/security-unit.test.ts`, `tests/backend/smoke-ws.test.ts`, `tests/backend/tenant-authz-behavior.test.ts` +- Frontend test: `tests/frontend/scene-preparation.test.ts` +- E2E regression suite: `tests/e2e/phase2-regressions.spec.ts` + +### Changed +- `computeElementHash` is now order-stable for equivalent element sets +- `frontend/src/App.tsx` now uses centralized scene-preparation utilities for label expansion and native-vs-converted routing +- Documentation test counts updated to `443` + +### Fixed +- MCP unknown tool calls now return JSON-RPC `MethodNotFound` (`-32601`) +- `import_scene` now enforces dangerous-key checks on parsed scene payloads before processing + ## [1.0.1] - 2026-03-29 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 301b50e..44c18fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,7 +38,7 @@ node dist/server.js curl http://localhost:3000/health ``` -369 tests across unit, API, WebSocket, and regression suites. Run `npm test` or `pnpm test`. CI runs `type-check` then `build` then `test` across Node 18/20/22. +443 tests across unit, API, WebSocket, and regression suites. Run `npm test` or `pnpm test`. CI runs `type-check` then `build` then `test` across Node 18/20/22. ## Architecture @@ -112,12 +112,12 @@ Two Dockerfiles: `Dockerfile` (MCP server only), `Dockerfile.canvas` (canvas wit ### Security posture (as of 1.6.3) - `src/security.ts`: helmet, CORS allowlist, timing-safe API key auth, prototype pollution guard, 3-tier rate limiting, WS challenge-response auth, Mermaid input size cap -- 369/369 tests passing; 4 regression tests cover previously crash-able sync paths +- 443/443 tests passing; 4 regression tests cover previously crash-able sync paths - Docker: non-root user, resource limits, hardened `.dockerignore` ### Before running `npm publish` -- [ ] Bump `version` in `package.json` to match `CHANGELOG.md` entry (currently `1.0.0`) -- [ ] Run `npm test` — must be 369/369 +- [ ] Bump `version` in `package.json` to match `CHANGELOG.md` entry (currently `1.0.1`) +- [ ] Run `npm test` — must be 443/443 - [ ] Run `npm run build` — must be zero TS errors - [ ] Run `shipguard scan .` — must be 0 CRITICAL findings - [ ] Verify `CHANGELOG.md` has an entry for the version being published diff --git a/README.md b/README.md index 738bbd4..50c4a73 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Run a live Excalidraw canvas and control it from any AI agent. This repo provide Forked from [celstnblacc/excalidraw-mcp-sentinel](https://github.com/celstnblacc/excalidraw-mcp-sentinel) (itself a fork of [yctimlin/mcp_excalidraw](https://github.com/yctimlin/mcp_excalidraw)) with production hardening: -- **369 tests** (upstream has none) — unit, API, WebSocket, and regression +- **443 tests** (upstream has none) — unit, API, WebSocket, and regression - **Security middleware** (`src/security.ts`): helmet, CORS allowlist, timing-safe API key auth, prototype pollution guard, input sanitization - **3-tier rate limiting**: general, destructive, and write-burst ceilings - **WebSocket challenge-response authentication** @@ -66,6 +66,7 @@ Click the workspace badge to switch between isolated canvases — each workspace - [Troubleshooting](#troubleshooting) - [Known Issues / TODO](#known-issues--todo) - [Development](#development) +- [Similar Project Scan](#similar-project-scan) - [Credits](#credits) ## Prerequisites @@ -689,6 +690,33 @@ npm run build npm run dev ``` +### Similar Project Scan + +Use the built-in scanner to look for repositories that are architecturally similar to this project. The scan is capability-based, not fork-based: it looks for Excalidraw plus MCP, backend sync, persistence, security, and self-hosting signals. + +Basic run: + +```bash +npm run scan:similar-projects +``` + +Broader competitor scan excluding this repo's direct lineage: + +```bash +npm run scan:similar-projects -- \ + --exclude-repo yctimlin/mcp_excalidraw \ + --exclude-repo sanjibdevnathlabs/mcp-excalidraw-local \ + --exclude-repo celstnblacc/excalidraw-mcp-sentinel +``` + +Outputs are written to `docs/generated/` as both JSON and Markdown reports. For higher GitHub API limits, set `GITHUB_TOKEN` before running the scan. + +> Note: rerun the scan after significant product or architecture changes. The ranking is based on the current shape of this repo, so active work on MCP features, persistence, security, or backend topology can materially change which repos are the closest matches. + +See also: +- [Top repo comparison](docs/COMPARISON-excalidraw-top-repos.md) +- [Search strategy](docs/GUIDE-excalidraw-similar-project-search.md) + ### Database SQLite database: `~/.excalidraw-mcp/excalidraw.db` diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3655648..9813472 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,14 +11,11 @@ import type { ExcalidrawElement, NonDeleted, NonDeletedExcalidrawElement } from import { convertMermaidToExcalidraw, DEFAULT_MERMAID_CONFIG } from './utils/mermaidConverter' import type { MermaidConfig } from '@excalidraw/mermaid-to-excalidraw' import { - cleanElementForExcalidraw, - validateAndFixBindings, computeElementHash, - isImageElement, - normalizeImageElement, - restoreBindings + cleanElementForExcalidraw, } from './utils/elementHelpers' import type { ServerElement } from './utils/elementHelpers' +import { convertElementsPreservingImageProps, prepareElementsForScene } from './utils/scenePreparation' type ExcalidrawAPIRefValue = ExcalidrawImperativeAPI; @@ -298,62 +295,6 @@ function App(): JSX.Element { }, DEBOUNCE_MS) } - const convertElementsPreservingImageProps = ( - cleanedElements: any[] - ): any[] => { - const imageElements = cleanedElements.filter(isImageElement) - const nonImageElements = cleanedElements.filter(el => !isImageElement(el)) - - let convertedNonImage: any[] = [] - if (nonImageElements.length > 0) { - convertedNonImage = convertToExcalidrawElements(nonImageElements, { regenerateIds: false }) as any[] - convertedNonImage = restoreBindings(convertedNonImage, nonImageElements) - } - - const normalizedImages = imageElements.map(normalizeImageElement) - - return [...convertedNonImage, ...normalizedImages] - } - - // Expand server-format label.text into native Excalidraw bound text elements. - // Without this, labels stored as label.text on containers vanish on page reload - // because convertToExcalidrawElements silently drops them. - const expandLabelsToNative = (elements: any[]): any[] => { - const expanded: any[] = [] - const LABEL_TYPES = new Set(['rectangle', 'ellipse', 'diamond', 'arrow']) - for (const el of elements) { - if (el.label?.text && LABEL_TYPES.has(el.type)) { - const boundTextId = `${el.id}_label` - const { label, ...rest } = el - // Container: add text binding, preserve existing non-text bindings - const existingBindings = (rest.boundElements || []).filter((b: any) => b.type !== 'text') - expanded.push({ - ...rest, - boundElements: [...existingBindings, { id: boundTextId, type: 'text' }] - }) - // Bound text element positioned at container center - expanded.push({ - id: boundTextId, type: 'text', containerId: el.id, - x: (el.x ?? 0) + ((el.width ?? 100) / 2) - 20, - y: (el.y ?? 0) + ((el.height ?? 40) / 2) - 10, - width: el.width ?? 100, height: 25, angle: 0, - text: label.text, originalText: label.text, - fontSize: el.fontSize ?? 20, fontFamily: el.fontFamily ?? 5, - textAlign: 'center', verticalAlign: 'middle', - strokeColor: el.strokeColor ?? '#1e1e1e', - backgroundColor: 'transparent', fillStyle: 'solid', - strokeWidth: 1, strokeStyle: 'solid', - roughness: el.roughness ?? 1, opacity: el.opacity ?? 100, - groupIds: [], roundness: null, isDeleted: false, - autoResize: true, lineHeight: 1.25, - }) - } else { - expanded.push(el) - } - } - return expanded - } - const loadExistingElements = async (): Promise => { try { const response = await fetch('/api/elements', { headers: tenantHeaders() }) @@ -365,34 +306,13 @@ function App(): JSX.Element { lastSyncedElementsRef.current = new Map() return } - const cleanedElements = result.elements.map(cleanElementForExcalidraw) - // Save original geometry for all DB elements — convertToExcalidrawElements - // recalculates metrics and shifts positions for text, containers, and arrows - const originalGeometry = new Map() - for (const el of cleanedElements) { - if (el.x != null && el.y != null) { - originalGeometry.set(el.id, { x: el.x, y: el.y, width: (el as any).width ?? 0, height: (el as any).height ?? 0 }) - } - } - // Expand server-format label.text into native Excalidraw bound text - // elements so labels survive round-trips through the DB. - const expandedElements = expandLabelsToNative(cleanedElements) - // Convert through Excalidraw to get proper element objects - // (with seed, version, versionNonce, etc.) - const convertedElements = convertElementsPreservingImageProps(expandedElements) - // Restore original geometry for elements that existed in the DB - // (skip synthetic elements created by expandLabelsToNative) - const finalElements = convertedElements.map((el: any) => { - const orig = originalGeometry.get(el.id) - if (orig) { - return { ...el, x: orig.x, y: orig.y, width: orig.width, height: orig.height } - } - return el - }) + + const finalElements = prepareElementsForScene(result.elements, convertToExcalidrawElements as any) + // Seed known containers BEFORE updateScene so onChange doesn't re-inject titles for (const el of finalElements) { - if (CONTAINER_TYPES.has((el as any).type)) { - knownContainerIdsRef.current.add((el as any).id) + if (CONTAINER_TYPES.has(el.type)) { + knownContainerIdsRef.current.add(el.id) } } @@ -615,17 +535,15 @@ function App(): JSX.Element { if (!api) return if (Array.isArray(data.elements) && data.elements.length > 0) { - const cleanedElements = data.elements.map(cleanElementForExcalidraw) - const validatedElements = validateAndFixBindings(cleanedElements) - const convertedElements = convertElementsPreservingImageProps(validatedElements) + const finalElements = prepareElementsForScene(data.elements, convertToExcalidrawElements as any) // Seed known containers before updateScene - for (const el of convertedElements) { - if (CONTAINER_TYPES.has((el as any).type)) { - knownContainerIdsRef.current.add((el as any).id) + for (const el of finalElements) { + if (CONTAINER_TYPES.has(el.type)) { + knownContainerIdsRef.current.add(el.id) } } api.updateScene({ - elements: convertedElements, + elements: finalElements, captureUpdate: CaptureUpdateAction.NEVER }) const helloBaseline = new Map() @@ -668,16 +586,14 @@ function App(): JSX.Element { switch (data.type) { case 'initial_elements': if (data.elements && data.elements.length > 0) { - const cleanedElements = data.elements.map(cleanElementForExcalidraw) - const validatedElements = validateAndFixBindings(cleanedElements) - const convertedElements = convertElementsPreservingImageProps(validatedElements) - for (const el of convertedElements) { - if (CONTAINER_TYPES.has((el as any).type)) { - knownContainerIdsRef.current.add((el as any).id) + const initFinalElements = prepareElementsForScene(data.elements, convertToExcalidrawElements as any) + for (const el of initFinalElements) { + if (CONTAINER_TYPES.has(el.type)) { + knownContainerIdsRef.current.add(el.id) } } api.updateScene({ - elements: convertedElements, + elements: initFinalElements, captureUpdate: CaptureUpdateAction.NEVER }) // Update sync baseline for deletion detection @@ -803,13 +719,13 @@ function App(): JSX.Element { const hasBoundArrows = cleanedBatchElements.some((el: any) => el.start || el.end) if (hasBoundArrows) { const allElements = [...currentElements, ...cleanedBatchElements] as any[] - const convertedAll = convertElementsPreservingImageProps(allElements) + const convertedAll = convertElementsPreservingImageProps(allElements, convertToExcalidrawElements as any) api.updateScene({ elements: convertedAll, captureUpdate: CaptureUpdateAction.NEVER }) } else { - const batchElements = convertElementsPreservingImageProps(cleanedBatchElements) + const batchElements = convertElementsPreservingImageProps(cleanedBatchElements, convertToExcalidrawElements as any) const updatedElementsAfterBatch = [...currentElements, ...batchElements] api.updateScene({ elements: updatedElementsAfterBatch, @@ -1190,15 +1106,13 @@ function App(): JSX.Element { }) const result: ApiResponse = await elemRes.json() if (result.success && result.elements && result.elements.length > 0) { - const cleanedElements = result.elements.map(cleanElementForExcalidraw) - const hasNativeFormat = cleanedElements.some((el: any) => el.containerId) - if (hasNativeFormat) { - const validated = validateAndFixBindings(cleanedElements) - excalidrawAPI?.updateScene({ elements: validated as any }) - } else { - const convertedElements = convertToExcalidrawElements(cleanedElements, { regenerateIds: false }) - excalidrawAPI?.updateScene({ elements: convertedElements }) + const switchedElements = prepareElementsForScene(result.elements, convertToExcalidrawElements as any) + for (const el of switchedElements) { + if (CONTAINER_TYPES.has(el.type)) { + knownContainerIdsRef.current.add(el.id) + } } + excalidrawAPI?.updateScene({ elements: switchedElements }) } showToast('Workspace switched') diff --git a/frontend/src/utils/elementHelpers.ts b/frontend/src/utils/elementHelpers.ts index 5d0b67c..618ab1b 100644 --- a/frontend/src/utils/elementHelpers.ts +++ b/frontend/src/utils/elementHelpers.ts @@ -42,7 +42,9 @@ export const cleanElementForExcalidraw = (element: ServerElement): Partial { let h = String(elements.length); - for (let i = 0; i < elements.length; i++) { - h += elements[i]!.id; - h += elements[i]!.version; + const pairs = elements + .map((element) => `${element.id}:${element.version}`) + .sort(); + + for (let i = 0; i < pairs.length; i++) { + h += pairs[i]!; } return h; }; diff --git a/frontend/src/utils/scenePreparation.ts b/frontend/src/utils/scenePreparation.ts new file mode 100644 index 0000000..4af1cba --- /dev/null +++ b/frontend/src/utils/scenePreparation.ts @@ -0,0 +1,97 @@ +import type { ExcalidrawElement } from '@excalidraw/excalidraw/types/element/types'; +import { + cleanElementForExcalidraw, + isImageElement, + normalizeImageElement, + restoreBindings, + validateAndFixBindings, +} from './elementHelpers'; +import type { ServerElement } from './elementHelpers'; + +type SceneConverter = ( + elements: readonly any[], + options?: { regenerateIds?: boolean } +) => Partial[]; + +const LABEL_TYPES = new Set(['rectangle', 'ellipse', 'diamond', 'arrow']); + +export function convertElementsPreservingImageProps( + cleanedElements: any[], + converter: SceneConverter +): any[] { + const imageElements = cleanedElements.filter(isImageElement); + const nonImageElements = cleanedElements.filter(el => !isImageElement(el)); + + let convertedNonImage: any[] = []; + if (nonImageElements.length > 0) { + convertedNonImage = converter(nonImageElements, { regenerateIds: false }) as any[]; + convertedNonImage = restoreBindings(convertedNonImage, nonImageElements); + } + + const normalizedImages = imageElements.map(normalizeImageElement); + return [...convertedNonImage, ...normalizedImages]; +} + +// Expand server-format label.text into native Excalidraw bound text elements. +// Without this, labels stored as label.text on containers vanish on page reload +// because convertToExcalidrawElements silently drops them. +export function expandLabelsToNative(elements: any[]): any[] { + const expanded: any[] = []; + for (const el of elements) { + if (el.label?.text && LABEL_TYPES.has(el.type)) { + const boundTextId = `${el.id}_label`; + const { label, ...rest } = el; + const existingBindings = (rest.boundElements || []).filter((b: any) => b.type !== 'text'); + expanded.push({ + ...rest, + boundElements: [...existingBindings, { id: boundTextId, type: 'text' }] + }); + expanded.push({ + id: boundTextId, type: 'text', containerId: el.id, + x: (el.x ?? 0) + ((el.width ?? 100) / 2) - 20, + y: (el.y ?? 0) + ((el.height ?? 40) / 2) - 10, + width: el.width ?? 100, height: 25, angle: 0, + text: label.text, originalText: label.text, + fontSize: el.fontSize ?? 20, fontFamily: el.fontFamily ?? 5, + textAlign: 'center', verticalAlign: 'middle', + strokeColor: el.strokeColor ?? '#1e1e1e', + backgroundColor: 'transparent', fillStyle: 'solid', + strokeWidth: 1, strokeStyle: 'solid', + roughness: el.roughness ?? 1, opacity: el.opacity ?? 100, + groupIds: [], roundness: null, isDeleted: false, + autoResize: true, lineHeight: 1.25, + }); + } else { + expanded.push(el); + } + } + return expanded; +} + +// Prepare DB elements for the Excalidraw scene. +// Browser-synced elements (have seed + versionNonce) load as-is — no metric +// recalculation, no position drift. MCP-created stubs (no internals) are +// expanded from label.text and converted to get proper Excalidraw internals. +export function prepareElementsForScene( + rawElements: ServerElement[], + converter: SceneConverter +): any[] { + const cleaned = rawElements.map(cleanElementForExcalidraw); + const expanded = expandLabelsToNative(cleaned); + const validated = validateAndFixBindings(expanded as any[]); + + const nativeReady: any[] = []; + const needsConversion: any[] = []; + for (const el of validated) { + if ((el as any).seed !== undefined && (el as any).versionNonce !== undefined) { + nativeReady.push(el); + } else { + needsConversion.push(el); + } + } + + const converted = needsConversion.length > 0 + ? convertElementsPreservingImageProps(needsConversion, converter) + : []; + return [...nativeReady, ...converted]; +} diff --git a/package.json b/package.json index 0ca1571..87af5aa 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "test:api": "vitest run tests/backend/api.test.ts", "test:ws": "vitest run tests/backend/ws.test.ts", "test:e2e": "npx playwright test", - "test:coverage": "vitest run --coverage" + "test:coverage": "vitest run --coverage", + "scan:similar-projects": "node scripts/scan-excalidraw-similar-projects.mjs" }, "dependencies": { "@excalidraw/excalidraw": "^0.18.0", diff --git a/scripts/scan-excalidraw-similar-projects.mjs b/scripts/scan-excalidraw-similar-projects.mjs new file mode 100644 index 0000000..2d0cd4e --- /dev/null +++ b/scripts/scan-excalidraw-similar-projects.mjs @@ -0,0 +1,501 @@ +#!/usr/bin/env node + +import fs from "fs"; +import path from "path"; + +const API_BASE = "https://api.github.com"; +const DEFAULT_OUT_DIR = "docs/generated"; +const DEFAULT_TOP = 10; +const DEFAULT_CANDIDATE_LIMIT = 40; +const DEFAULT_FORK_PAGES = 1; + +const SEARCH_QUERIES = [ + 'excalidraw mcp in:name,description,readme', + '"model context protocol" excalidraw in:name,description,readme', + '"self-hosted excalidraw" websocket in:name,description,readme', + 'excalidraw sqlite in:name,description,readme', + 'excalidraw collaboration self-hosted in:name,description,readme', + 'excalidraw-mcp in:name,description,readme', + 'mcp_excalidraw in:name,description,readme', +]; + +const SEED_REPOS = [ + "excalidraw/excalidraw", + "yctimlin/mcp_excalidraw", + "sanjibdevnathlabs/mcp-excalidraw-local", + "celstnblacc/excalidraw-mcp-sentinel", + "i-tozer/excalidraw-mcp", + "alswl/excalidraw-collaboration", +]; + +const SIGNALS = { + mcp: [ + "@modelcontextprotocol/sdk", + "model context protocol", + "mcp server", + "mcp", + ], + liveBackend: [ + "websocket", + "socket.io", + " ws ", + "canvas server", + "backend", + "live canvas", + "real-time", + "realtime", + "collaboration", + "sync", + "express", + ], + persistence: [ + "better-sqlite3", + "sqlite", + "postgres", + "mongodb", + "storage", + "filesystem", + "s3", + "backup", + "versioning", + "drizzle", + "prisma", + ], + security: [ + "helmet", + "rate limit", + "rate-limit", + "apikey", + "api key", + "auth", + "oauth", + "oidc", + "encryption", + "secure", + "security", + ], + workspaceIsolation: [ + "multi-tenant", + "multi tenant", + "workspace", + "tenant", + "project", + "organizer", + ], + selfHosted: [ + "self-hosted", + "self hosted", + "docker-compose", + "docker compose", + "docker", + "localhost", + "single binary", + ], + excalidraw: [ + "@excalidraw/excalidraw", + "excalidraw", + ], +}; + +function parseArgs(argv) { + const options = { + top: DEFAULT_TOP, + candidateLimit: DEFAULT_CANDIDATE_LIMIT, + forkPages: DEFAULT_FORK_PAGES, + outDir: DEFAULT_OUT_DIR, + excludeRepos: new Set(), + verbose: false, + help: false, + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--help" || arg === "-h") { + options.help = true; + continue; + } + if (arg === "--verbose") { + options.verbose = true; + continue; + } + if (arg === "--top") { + options.top = parsePositiveInt(argv[++i], "--top"); + continue; + } + if (arg === "--candidate-limit") { + options.candidateLimit = parsePositiveInt(argv[++i], "--candidate-limit"); + continue; + } + if (arg === "--fork-pages") { + options.forkPages = parsePositiveInt(argv[++i], "--fork-pages"); + continue; + } + if (arg === "--out-dir") { + options.outDir = argv[++i]; + if (!options.outDir) { + throw new Error("--out-dir requires a value"); + } + continue; + } + if (arg === "--exclude-repo") { + const repoName = argv[++i]; + if (!repoName || !repoName.includes("/")) { + throw new Error("--exclude-repo requires a value like owner/name"); + } + options.excludeRepos.add(repoName); + continue; + } + throw new Error(`Unknown argument: ${arg}`); + } + + return options; +} + +function parsePositiveInt(value, flagName) { + const parsed = Number.parseInt(value ?? "", 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`${flagName} requires a positive integer`); + } + return parsed; +} + +function printHelp() { + console.log(`Usage: node scripts/scan-excalidraw-similar-projects.mjs [options] + +Options: + --top Number of ranked results to keep (default: ${DEFAULT_TOP}) + --candidate-limit Max unique candidates to inspect (default: ${DEFAULT_CANDIDATE_LIMIT}) + --fork-pages Number of GitHub fork pages to inspect per seed (default: ${DEFAULT_FORK_PAGES}) + --out-dir Output directory for JSON and Markdown reports (default: ${DEFAULT_OUT_DIR}) + --exclude-repo Exclude a repo by full name; repeatable + --verbose Print progress while scanning + -h, --help Show this help + +Environment: + GITHUB_TOKEN Optional but recommended. Raises GitHub API rate limits. +`); +} + +function slugify(value) { + return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); +} + +function formatDateUtc(date = new Date()) { + return date.toISOString().slice(0, 10); +} + +function log(options, message) { + if (options.verbose) { + console.error(message); + } +} + +async function githubRequest(apiPath, options, query = {}) { + const url = new URL(`${API_BASE}${apiPath}`); + for (const [key, value] of Object.entries(query)) { + url.searchParams.set(key, String(value)); + } + + const headers = { + Accept: "application/vnd.github+json", + "User-Agent": "excalidraw-similar-project-scan", + }; + if (process.env.GITHUB_TOKEN) { + headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`; + } + + const response = await fetch(url, { headers }); + if (response.status === 404) { + return null; + } + if (!response.ok) { + const body = await response.text(); + throw new Error(`GitHub API ${response.status} for ${url}: ${body.slice(0, 200)}`); + } + return response.json(); +} + +async function searchRepositories(query, options) { + log(options, `search: ${query}`); + const payload = await githubRequest("/search/repositories", options, { + q: query, + per_page: 20, + sort: "stars", + order: "desc", + }); + return payload?.items ?? []; +} + +async function listForks(fullName, options, pages) { + const [owner, repo] = fullName.split("/"); + const results = []; + for (let page = 1; page <= pages; page += 1) { + log(options, `forks: ${fullName} page ${page}`); + const payload = await githubRequest(`/repos/${owner}/${repo}/forks`, options, { + per_page: 100, + page, + sort: "newest", + }); + if (!Array.isArray(payload) || payload.length === 0) { + break; + } + results.push(...payload); + } + return results; +} + +async function getRepoDetails(fullName, options) { + const [owner, repo] = fullName.split("/"); + return githubRequest(`/repos/${owner}/${repo}`, options); +} + +async function getReadme(fullName, options) { + const [owner, repo] = fullName.split("/"); + const payload = await githubRequest(`/repos/${owner}/${repo}/readme`, options); + if (!payload?.content) { + return ""; + } + return decodeGitHubContent(payload.content); +} + +async function getPackageJson(fullName, options) { + const [owner, repo] = fullName.split("/"); + const payload = await githubRequest(`/repos/${owner}/${repo}/contents/package.json`, options); + if (!payload?.content) { + return null; + } + try { + return JSON.parse(decodeGitHubContent(payload.content)); + } catch { + return null; + } +} + +function decodeGitHubContent(content) { + return Buffer.from(content.replace(/\n/g, ""), "base64").toString("utf8"); +} + +function dedupeRepos(repos) { + const map = new Map(); + for (const repo of repos) { + if (!repo?.full_name) { + continue; + } + if (!map.has(repo.full_name)) { + map.set(repo.full_name, repo); + } + } + return [...map.values()]; +} + +function rankSeedPriority(fullName) { + const index = SEED_REPOS.indexOf(fullName); + return index === -1 ? 999 : index; +} + +function sortCandidates(repos) { + return [...repos].sort((a, b) => { + const seedDelta = rankSeedPriority(a.full_name) - rankSeedPriority(b.full_name); + if (seedDelta !== 0) { + return seedDelta; + } + const starsA = a.stargazers_count ?? 0; + const starsB = b.stargazers_count ?? 0; + if (starsA !== starsB) { + return starsB - starsA; + } + return a.full_name.localeCompare(b.full_name); + }); +} + +function buildRepoText(repo, readmeText, packageJson) { + const topics = Array.isArray(repo.topics) ? repo.topics.join(" ") : ""; + const dependencies = Object.keys({ + ...(packageJson?.dependencies ?? {}), + ...(packageJson?.devDependencies ?? {}), + }).join(" "); + return [ + repo.full_name, + repo.description ?? "", + topics, + readmeText, + dependencies, + ].join(" ").toLowerCase(); +} + +function includesAny(text, needles) { + return needles.some((needle) => text.includes(needle)); +} + +function scoreRepo(repo, readmeText, packageJson) { + const text = buildRepoText(repo, readmeText, packageJson); + const signals = { + excalidraw: includesAny(text, SIGNALS.excalidraw), + mcp: includesAny(text, SIGNALS.mcp), + liveBackend: includesAny(text, SIGNALS.liveBackend), + persistence: includesAny(text, SIGNALS.persistence), + security: includesAny(text, SIGNALS.security), + workspaceIsolation: includesAny(text, SIGNALS.workspaceIsolation), + selfHosted: includesAny(text, SIGNALS.selfHosted), + }; + + const score = + (signals.mcp ? 5 : 0) + + (signals.liveBackend ? 4 : 0) + + (signals.persistence ? 3 : 0) + + (signals.security ? 3 : 0) + + (signals.workspaceIsolation ? 3 : 0) + + (signals.selfHosted ? 2 : 0); + + let classification = "NOT_REALLY"; + if (signals.mcp && signals.liveBackend && score >= 10) { + classification = "SAME"; + } else if (score >= 6) { + classification = "ADJACENT"; + } + + const reasons = []; + if (signals.mcp) reasons.push("MCP"); + if (signals.liveBackend) reasons.push("live backend"); + if (signals.persistence) reasons.push("persistence"); + if (signals.security) reasons.push("security"); + if (signals.workspaceIsolation) reasons.push("workspace isolation"); + if (signals.selfHosted) reasons.push("self-hosted"); + + return { + score, + classification, + signals, + reason: reasons.join(", ") || "weak match", + closestToThisRepo: signals.mcp && signals.liveBackend && (signals.persistence || signals.security), + }; +} + +function trimReadme(readmeText) { + return readmeText.length > 24000 ? readmeText.slice(0, 24000) : readmeText; +} + +function renderMarkdown(report) { + const lines = []; + lines.push("# Excalidraw Similar Project Scan"); + lines.push(""); + lines.push(`Generated: ${report.generatedAt}`); + lines.push(""); + lines.push("Scoring weights: `MCP=5`, `live backend=4`, `persistence=3`, `security=3`, `workspace isolation=3`, `self-hosted=2`."); + lines.push(""); + lines.push("| Repo | Score | Class | Excalidraw fork? | Why it matched |"); + lines.push("|---|---:|---|---|---|"); + for (const result of report.results) { + lines.push( + `| [${result.fullName}](${result.htmlUrl}) | ${result.score}/20 | ${result.classification} | ${result.directExcalidrawFork ? "Yes" : "No"} | ${result.reason} |` + ); + } + lines.push(""); + lines.push("## Notes"); + lines.push(""); + lines.push("- This scan uses capability matching, not only fork ancestry."); + lines.push("- `SAME` requires strong evidence of both `MCP` and a live backend/canvas layer."); + lines.push("- Results are heuristic and based on public repo metadata, README content, and `package.json` when present."); + return `${lines.join("\n")}\n`; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + return; + } + + const candidates = []; + + for (const seed of SEED_REPOS) { + const details = await getRepoDetails(seed, options); + if (details) { + candidates.push(details); + } + } + + for (const query of SEARCH_QUERIES) { + const repos = await searchRepositories(query, options); + candidates.push(...repos); + } + + for (const seed of SEED_REPOS) { + const forks = await listForks(seed, options, options.forkPages); + candidates.push(...forks); + } + + const uniqueCandidates = sortCandidates( + dedupeRepos(candidates).filter((repo) => !repo.archived && !options.excludeRepos.has(repo.full_name)) + ).slice(0, options.candidateLimit); + + const scored = []; + for (const repo of uniqueCandidates) { + const [readmeText, packageJson] = await Promise.all([ + getReadme(repo.full_name, options).catch(() => ""), + getPackageJson(repo.full_name, options).catch(() => null), + ]); + + const evaluation = scoreRepo(repo, trimReadme(readmeText), packageJson); + if (!evaluation.signals.excalidraw) { + continue; + } + scored.push({ + fullName: repo.full_name, + htmlUrl: repo.html_url, + description: repo.description ?? "", + score: evaluation.score, + classification: evaluation.classification, + reason: evaluation.reason, + signals: evaluation.signals, + closestToThisRepo: evaluation.closestToThisRepo, + stars: repo.stargazers_count ?? 0, + fork: !!repo.fork, + }); + } + + scored.sort((a, b) => { + if (a.score !== b.score) return b.score - a.score; + if (a.closestToThisRepo !== b.closestToThisRepo) return Number(b.closestToThisRepo) - Number(a.closestToThisRepo); + if (a.stars !== b.stars) return b.stars - a.stars; + return a.fullName.localeCompare(b.fullName); + }); + + const topResults = scored.slice(0, options.top); + + for (const result of topResults) { + const details = await getRepoDetails(result.fullName, options).catch(() => null); + result.directExcalidrawFork = details?.parent?.full_name === "excalidraw/excalidraw"; + result.parentFullName = details?.parent?.full_name ?? null; + } + + const report = { + generatedAt: new Date().toISOString(), + config: { + top: options.top, + candidateLimit: options.candidateLimit, + forkPages: options.forkPages, + excludeRepos: [...options.excludeRepos], + searchQueries: SEARCH_QUERIES, + seedRepos: SEED_REPOS, + }, + results: topResults, + }; + + fs.mkdirSync(options.outDir, { recursive: true }); + const stamp = formatDateUtc(); + const baseName = `${stamp}-${slugify("excalidraw-similar-project-scan")}`; + const jsonPath = path.join(options.outDir, `${baseName}.json`); + const markdownPath = path.join(options.outDir, `${baseName}.md`); + + fs.writeFileSync(jsonPath, JSON.stringify(report, null, 2)); + fs.writeFileSync(markdownPath, renderMarkdown(report)); + + console.log(`Wrote ${jsonPath}`); + console.log(`Wrote ${markdownPath}`); +} + +main().catch((error) => { + console.error(error.message); + process.exitCode = 1; +}); diff --git a/src/db.ts b/src/db.ts index 3d8731c..4a727ce 100644 --- a/src/db.ts +++ b/src/db.ts @@ -526,17 +526,19 @@ export function listTenants(): Tenant[] { // ── Projects ── -export function createProject(name: string, description?: string): Project { +export function createProject(name: string, description?: string, tenantId?: string): Project { + const tid = tenantId ?? activeTenantId; const id = generateId(); const now = new Date().toISOString(); db.prepare( 'INSERT INTO projects (id, name, description, tenant_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)' - ).run(id, name, description || null, activeTenantId, now, now); - return { id, name, description: description || null, tenant_id: activeTenantId, created_at: now, updated_at: now }; + ).run(id, name, description || null, tid, now, now); + return { id, name, description: description || null, tenant_id: tid, created_at: now, updated_at: now }; } -export function listProjects(): Project[] { - return db.prepare('SELECT * FROM projects WHERE tenant_id = ? ORDER BY updated_at DESC').all(activeTenantId) as Project[]; +export function listProjects(tenantId?: string): Project[] { + const tid = tenantId ?? activeTenantId; + return db.prepare('SELECT * FROM projects WHERE tenant_id = ? ORDER BY updated_at DESC').all(tid) as Project[]; } export function getProjectForTenant(projectId: string, tenantId: string): Project | undefined { diff --git a/src/index.ts b/src/index.ts index 6e126b1..880921c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,7 +13,9 @@ import { CallToolRequestSchema, ListToolsRequestSchema, CallToolRequest, - Tool + Tool, + McpError, + ErrorCode } from '@modelcontextprotocol/sdk/types.js'; import { z } from 'zod'; import dotenv from 'dotenv'; @@ -41,8 +43,10 @@ import { getElementHistory as dbGetElementHistory, getProjectHistory as dbGetProjectHistory, ensureTenant as dbEnsureTenant, setActiveTenant as dbSetActiveTenant, getActiveTenant as dbGetActiveTenant, getActiveTenantId as dbGetActiveTenantId, + getActiveProjectId as dbGetActiveProjectId, listTenants as dbListTenants } from './db.js'; +import { assertNoDangerousKeys } from './security.js'; // Load environment variables dotenv.config(); @@ -1890,8 +1894,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) const safeImportPath = sanitizeFilePath(params.filePath); const fileContent = fs.readFileSync(safeImportPath, 'utf-8'); sceneData = JSON.parse(fileContent); + assertNoDangerousKeys(sceneData, 'import_scene filePath'); } else if (params.data) { sceneData = JSON.parse(params.data); + assertNoDangerousKeys(sceneData, 'import_scene data'); } else { throw new Error('Either filePath or data must be provided'); } @@ -2708,7 +2714,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) const params = z.object({ query: z.string() }).parse(args); logger.info('Searching elements via MCP', { query: params.query }); - const results = dbSearchElements(params.query); + const results = dbSearchElements(params.query, dbGetActiveProjectId()); return { content: [{ type: 'text', @@ -2721,7 +2727,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) case 'list_projects': { logger.info('Listing projects via MCP'); - const projects = dbListProjects(); + const projects = dbListProjects(dbGetActiveTenantId()); const active = dbGetActiveProject(); return { content: [{ @@ -2739,7 +2745,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) }).parse(args || {}); if (params.createName) { - const newProject = dbCreateProject(params.createName, params.createDescription); + const newProject = dbCreateProject(params.createName, params.createDescription, dbGetActiveTenantId()); dbSetActiveProject(newProject.id); logger.info('Created and switched to new project', { project: newProject }); return { @@ -2834,9 +2840,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) } default: - throw new Error(`Unknown tool: ${name}`); + throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`); } } catch (error) { + if (error instanceof McpError) { + throw error; + } logger.error(`Error handling tool call: ${(error as Error).message}`, { error }); return { content: [{ type: 'text', text: `Error: ${(error as Error).message}` }], @@ -3021,4 +3030,5 @@ if (isMainModule()) { } } -export default runServer; \ No newline at end of file +export default runServer; +export { server, tools }; diff --git a/src/security.ts b/src/security.ts index b1f31a6..bbed7e0 100644 --- a/src/security.ts +++ b/src/security.ts @@ -111,6 +111,12 @@ export function sanitizeBody(req: Request, res: Response, next: NextFunction): v next(); } +export function assertNoDangerousKeys(obj: unknown, context = 'input'): void { + if (hasDangerousKey(obj)) { + throw new Error(`${context} contains disallowed keys (__proto__, constructor, prototype)`); + } +} + // ── Mermaid Input Validation ────────────────────────────────────────────────── const MAX_MERMAID_LENGTH = 50 * 1024; // 50 KB const MAX_MERMAID_CONFIG_KEYS = 10; @@ -205,7 +211,7 @@ export function sanitizeSearchQuery(query: string): string { if (/\b(?:AND|OR|NOT|NEAR(?:\/\d+)?)\b/i.test(trimmed)) { throw new InvalidSearchQueryError(); } - if (/[*(){}^]/.test(trimmed)) { + if (/[*(){}^:]/.test(trimmed)) { throw new InvalidSearchQueryError(); } diff --git a/tests/backend/db-unit.test.ts b/tests/backend/db-unit.test.ts new file mode 100644 index 0000000..15fe417 --- /dev/null +++ b/tests/backend/db-unit.test.ts @@ -0,0 +1,396 @@ +/** + * Unit tests for src/db.ts + * + * Covers: migrations, tenant isolation, FTS search, snapshots, + * element_versions tracking, generateId uniqueness, global state race. + * All tests use a real SQLite database in a tmpdir. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + initDb, + closeDb, + ensureTenant, + setActiveTenant, + getActiveTenantId, + getActiveProjectId, + setElement, + getElement, + getAllElements, + deleteElement, + searchElements, + saveSnapshot, + getSnapshot, + getElementHistory, + createProject, + getDefaultProjectForTenant, + getCurrentSyncVersion, + incrementSyncVersion, +} from '../../src/db.js'; +import path from 'path'; +import os from 'os'; +import fs from 'fs'; + +function tmpDb(label: string): string { + return path.join( + os.tmpdir(), + `excalidraw-db-unit-${label}-${Date.now()}-${Math.random().toString(36).slice(2)}.db` + ); +} + +function cleanupDb(dbPath: string): void { + closeDb(); + for (const suffix of ['', '-wal', '-shm']) { + try { fs.unlinkSync(dbPath + suffix); } catch { /* ignore */ } + } +} + +function makeEl(id: string, overrides: Record = {}) { + return { id, type: 'rectangle', x: 0, y: 0, width: 100, height: 50, ...overrides }; +} + +// ── WAL mode ────────────────────────────────────────────────────────────────── + +describe('SQLite configuration', () => { + let dbPath: string; + + beforeEach(() => { + dbPath = tmpDb('config'); + initDb(dbPath); + setActiveTenant('default'); + }); + + afterEach(() => cleanupDb(dbPath)); + + it('enables WAL journal mode', () => { + // After initDb the WAL file should be created alongside the DB + // (or journal_mode pragma returns 'wal'). + // We verify indirectly: the -wal sidecar file exists after a write. + setElement('el-wal', makeEl('el-wal')); + const walPath = dbPath + '-wal'; + // WAL file may or may not exist depending on checkpoint state, but + // the DB must at least have been created without error. + expect(fs.existsSync(dbPath)).toBe(true); + }); +}); + +// ── Migrations ──────────────────────────────────────────────────────────────── + +describe('Migrations', () => { + let dbPath: string; + + beforeEach(() => { + dbPath = tmpDb('migrations'); + }); + + afterEach(() => cleanupDb(dbPath)); + + it('runs successfully on a fresh database', () => { + expect(() => { + initDb(dbPath); + setActiveTenant('default'); + }).not.toThrow(); + }); + + it('is idempotent — calling initDb twice with the same path does not error', () => { + initDb(dbPath); + setActiveTenant('default'); + // initDb guards with `if (db) return`, so calling again is a no-op + expect(() => initDb(dbPath)).not.toThrow(); + }); +}); + +// ── Element CRUD & tenant isolation ────────────────────────────────────────── + +describe('Tenant isolation', () => { + let dbPath: string; + + beforeEach(() => { + dbPath = tmpDb('isolation'); + initDb(dbPath); + setActiveTenant('default'); + }); + + afterEach(() => cleanupDb(dbPath)); + + it('elements created in tenant A are not visible in tenant B', () => { + // Create tenant A + project, write an element + ensureTenant('tenant-a', 'Tenant A', '/ws/a'); + setActiveTenant('tenant-a'); + const projA = getDefaultProjectForTenant('tenant-a'); + setElement('el-a', makeEl('el-a'), projA); + + // Create tenant B + project, write a different element + ensureTenant('tenant-b', 'Tenant B', '/ws/b'); + setActiveTenant('tenant-b'); + const projB = getDefaultProjectForTenant('tenant-b'); + setElement('el-b', makeEl('el-b'), projB); + + // Tenant A's project sees only el-a + const elemsA = getAllElements(projA); + expect(elemsA.map(e => e.id)).toContain('el-a'); + expect(elemsA.map(e => e.id)).not.toContain('el-b'); + + // Tenant B's project sees only el-b + const elemsB = getAllElements(projB); + expect(elemsB.map(e => e.id)).toContain('el-b'); + expect(elemsB.map(e => e.id)).not.toContain('el-a'); + }); + + it('getElement with explicit projectId enforces project scope', () => { + ensureTenant('tenant-c', 'Tenant C', '/ws/c'); + const projC = getDefaultProjectForTenant('tenant-c'); + setElement('el-c', makeEl('el-c'), projC); + + // The default project should NOT see el-c + const found = getElement('el-c', 'default'); + expect(found).toBeUndefined(); + + // The correct project SHOULD see el-c + const foundCorrect = getElement('el-c', projC); + expect(foundCorrect).toBeDefined(); + expect(foundCorrect!.id).toBe('el-c'); + }); + + // DESIGN NOTE: setActiveTenant() mutates module-level `activeTenantId` and + // `activeProjectId`. Any code path that calls db functions WITHOUT an explicit + // projectId override uses the current global value. If two logical "sessions" + // call setActiveTenant() in an interleaved order, the later call wins. + // The test below demonstrates this using explicit projectId overrides (the safe + // API), contrasted with the module-global fallback. + it('DESIGN GAP: global activeTenantId is shared across all callers without explicit projectId', () => { + ensureTenant('tenant-x', 'X', '/ws/x'); + ensureTenant('tenant-y', 'Y', '/ws/y'); + const projX = getDefaultProjectForTenant('tenant-x'); + const projY = getDefaultProjectForTenant('tenant-y'); + + // Session 1 sets active tenant to X and writes an element via global state + setActiveTenant('tenant-x'); + expect(getActiveTenantId()).toBe('tenant-x'); + // Simulate session 2 switching tenant before session 1 does its DB work + setActiveTenant('tenant-y'); + // Now session 1's db call (no explicit projectId) will use Y's project + setElement('el-contaminated', makeEl('el-contaminated')); // uses activeProjectId = projY + + // The element landed in Y's project, not X's + expect(getElement('el-contaminated', projY)).toBeDefined(); + expect(getElement('el-contaminated', projX)).toBeUndefined(); + }); +}); + +// ── FTS Search ──────────────────────────────────────────────────────────────── + +describe('FTS search', () => { + let dbPath: string; + + beforeEach(() => { + dbPath = tmpDb('fts'); + initDb(dbPath); + setActiveTenant('default'); + }); + + afterEach(() => cleanupDb(dbPath)); + + it('finds elements by label text', () => { + setElement('el-fts1', makeEl('el-fts1', { label: { text: 'Excalidraw Canvas' } })); + setElement('el-fts2', makeEl('el-fts2', { label: { text: 'Something Else' } })); + + const results = searchElements('Excalidraw', 'default'); + expect(results.map(e => e.id)).toContain('el-fts1'); + expect(results.map(e => e.id)).not.toContain('el-fts2'); + }); + + it('finds elements by type', () => { + setElement('el-rect', { id: 'el-rect', type: 'rectangle', x: 0, y: 0, width: 50, height: 50 }); + setElement('el-dia', { id: 'el-dia', type: 'diamond', x: 0, y: 0, width: 50, height: 50 }); + + const results = searchElements('diamond', 'default'); + expect(results.map(e => e.id)).toContain('el-dia'); + expect(results.map(e => e.id)).not.toContain('el-rect'); + }); + + it('does not return deleted elements', () => { + setElement('el-del', makeEl('el-del', { label: { text: 'FindMe' } })); + deleteElement('el-del', 'default'); + + const results = searchElements('FindMe', 'default'); + expect(results.map(e => e.id)).not.toContain('el-del'); + }); +}); + +// ── Soft delete ─────────────────────────────────────────────────────────────── + +describe('Soft delete', () => { + let dbPath: string; + + beforeEach(() => { + dbPath = tmpDb('softdelete'); + initDb(dbPath); + setActiveTenant('default'); + }); + + afterEach(() => cleanupDb(dbPath)); + + it('deleted element is not returned by getAllElements', () => { + setElement('el-to-delete', makeEl('el-to-delete')); + deleteElement('el-to-delete', 'default'); + + const all = getAllElements('default'); + expect(all.map(e => e.id)).not.toContain('el-to-delete'); + }); + + it('deleted element is not returned by getElement', () => { + setElement('el-gone', makeEl('el-gone')); + deleteElement('el-gone', 'default'); + + expect(getElement('el-gone', 'default')).toBeUndefined(); + }); + + it('re-inserting a deleted element revives it', () => { + setElement('el-revive', makeEl('el-revive')); + deleteElement('el-revive', 'default'); + setElement('el-revive', makeEl('el-revive', { x: 99 })); + + const el = getElement('el-revive', 'default'); + expect(el).toBeDefined(); + expect(el!.x).toBe(99); + }); +}); + +// ── element_versions ───────────────────────────────────────────────────────── + +describe('element_versions history', () => { + let dbPath: string; + + beforeEach(() => { + dbPath = tmpDb('versions'); + initDb(dbPath); + setActiveTenant('default'); + }); + + afterEach(() => cleanupDb(dbPath)); + + it('records a create operation', () => { + setElement('el-hist', makeEl('el-hist')); + const history = getElementHistory('el-hist', 50, 'default'); + expect(history.length).toBeGreaterThanOrEqual(1); + expect(history.some(h => h.operation === 'create')).toBe(true); + }); + + it('records an update operation after second setElement', () => { + setElement('el-hist2', makeEl('el-hist2')); + setElement('el-hist2', makeEl('el-hist2', { x: 42 })); + const history = getElementHistory('el-hist2', 50, 'default'); + expect(history.some(h => h.operation === 'update')).toBe(true); + }); + + it('records a delete operation', () => { + setElement('el-hist3', makeEl('el-hist3')); + deleteElement('el-hist3', 'default'); + const history = getElementHistory('el-hist3', 50, 'default'); + expect(history.some(h => h.operation === 'delete')).toBe(true); + }); +}); + +// ── Snapshot round-trip ─────────────────────────────────────────────────────── + +describe('Snapshot save / restore', () => { + let dbPath: string; + + beforeEach(() => { + dbPath = tmpDb('snapshot'); + initDb(dbPath); + setActiveTenant('default'); + }); + + afterEach(() => cleanupDb(dbPath)); + + it('saves and retrieves a named snapshot', () => { + const elements = [makeEl('snap-el-1'), makeEl('snap-el-2')]; + saveSnapshot('my-snap', elements, 'default'); + + const snap = getSnapshot('my-snap', 'default'); + expect(snap).toBeDefined(); + expect(snap!.name).toBe('my-snap'); + expect(snap!.elements).toHaveLength(2); + expect(snap!.elements.map((e: any) => e.id)).toContain('snap-el-1'); + }); + + it('snapshot content is independent of subsequent mutations', () => { + setElement('snap-live', makeEl('snap-live', { x: 10 })); + saveSnapshot('before-move', [makeEl('snap-live', { x: 10 })], 'default'); + + // Mutate the live element + setElement('snap-live', makeEl('snap-live', { x: 999 })); + + // Snapshot still has the original coordinates + const snap = getSnapshot('before-move', 'default'); + expect(snap!.elements[0].x).toBe(10); + }); + + it('returns undefined for non-existent snapshot name', () => { + expect(getSnapshot('does-not-exist', 'default')).toBeUndefined(); + }); +}); + +// ── generateId uniqueness ───────────────────────────────────────────────────── + +describe('createProject — generateId uniqueness', () => { + let dbPath: string; + + beforeEach(() => { + dbPath = tmpDb('genid'); + initDb(dbPath); + setActiveTenant('default'); + }); + + afterEach(() => cleanupDb(dbPath)); + + it('generates unique project IDs across 200 rapid sequential calls', () => { + const ids = new Set(); + for (let i = 0; i < 200; i++) { + const project = createProject(`proj-${i}`); + ids.add(project.id); + } + // All IDs must be unique + expect(ids.size).toBe(200); + }); +}); + +// ── Sync version ────────────────────────────────────────────────────────────── + +describe('sync version monotonicity', () => { + let dbPath: string; + + beforeEach(() => { + dbPath = tmpDb('syncver'); + initDb(dbPath); + setActiveTenant('default'); + }); + + afterEach(() => cleanupDb(dbPath)); + + it('sync version increases monotonically across setElement calls', () => { + const v0 = getCurrentSyncVersion('default'); + setElement('sv-el1', makeEl('sv-el1')); + const v1 = getCurrentSyncVersion('default'); + setElement('sv-el2', makeEl('sv-el2')); + const v2 = getCurrentSyncVersion('default'); + + expect(v1).toBeGreaterThan(v0); + expect(v2).toBeGreaterThan(v1); + }); + + it('sync version is isolated per project (explicit projectId)', () => { + ensureTenant('sv-tenant', 'SV Tenant', '/ws/sv'); + const projSv = getDefaultProjectForTenant('sv-tenant'); + + const defaultV0 = getCurrentSyncVersion('default'); + const svV0 = getCurrentSyncVersion(projSv); + + setElement('sv-isolated', makeEl('sv-isolated'), projSv); + + // Only the sv project's version should increment + expect(getCurrentSyncVersion(projSv)).toBeGreaterThan(svV0); + expect(getCurrentSyncVersion('default')).toBe(defaultV0); + }); +}); diff --git a/tests/backend/mcp-contract.test.ts b/tests/backend/mcp-contract.test.ts new file mode 100644 index 0000000..b9bf716 --- /dev/null +++ b/tests/backend/mcp-contract.test.ts @@ -0,0 +1,39 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { ErrorCode } from '@modelcontextprotocol/sdk/types.js'; +import { server, tools } from '../../src/index.js'; + +let client: Client; + +beforeAll(async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + client = new Client({ name: 'mcp-contract-test-client', version: '1.0.0' }); + await server.connect(serverTransport); + await client.connect(clientTransport); +}); + +afterAll(async () => { + await client.close(); + await server.close(); +}); + +describe('MCP contract', () => { + it('tools/list returns all declared tools', async () => { + const listed = await client.listTools(); + expect(Array.isArray(listed.tools)).toBe(true); + expect(listed.tools.length).toBe(32); + expect(listed.tools.length).toBe(tools.length); + }); + + it('tools/call unknown tool returns MethodNotFound (-32601)', async () => { + await expect( + client.callTool({ + name: '__unknown_tool__', + arguments: {}, + }) + ).rejects.toMatchObject({ + code: ErrorCode.MethodNotFound, + }); + }); +}); diff --git a/tests/backend/mcp-sanitization.test.ts b/tests/backend/mcp-sanitization.test.ts new file mode 100644 index 0000000..a0fef83 --- /dev/null +++ b/tests/backend/mcp-sanitization.test.ts @@ -0,0 +1,222 @@ +/** + * Tests for security gaps on MCP-adjacent paths. + * + * CONTEXT: Express middleware (sanitizeBody, apiKeyAuth, rate limiting) only + * runs on HTTP requests. MCP tool calls arrive over stdio and call db functions + * directly — bypassing all Express middleware. + * + * These tests: + * 1. Confirm sanitizeBody WORKS on REST paths (baseline proof it's applied). + * 2. Document the adversarial JSON parsing scenarios that the MCP import_scene + * handler faces without any Express-layer protection. + * 3. Test path traversal blocking on export endpoints (shared logic with MCP). + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import request from 'supertest'; +import { + initDb, + closeDb, + setActiveTenant, +} from '../../src/db.js'; +import path from 'path'; +import os from 'os'; +import fs from 'fs'; + +let dbPath: string; +let app: any; + +beforeEach(async () => { + dbPath = path.join( + os.tmpdir(), + `excalidraw-mcp-sanit-${Date.now()}-${Math.random().toString(36).slice(2)}.db` + ); + initDb(dbPath); + setActiveTenant('default'); + const mod = await import('../../src/server.js'); + app = mod.default; +}); + +afterEach(() => { + closeDb(); + for (const suffix of ['', '-wal', '-shm']) { + try { fs.unlinkSync(dbPath + suffix); } catch { /* ignore */ } + } +}); + +// ── Prototype pollution guard — REST layer ──────────────────────────────────── + +describe('sanitizeBody middleware — REST path coverage', () => { + it('rejects POST body containing __proto__ key → 400', async () => { + const res = await request(app) + .post('/api/elements') + .set('Content-Type', 'application/json') + .send('{"__proto__": {"isAdmin": true}, "type": "rectangle", "x": 0, "y": 0}'); + + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/disallowed keys/i); + }); + + it('rejects POST body containing constructor key → 400', async () => { + const res = await request(app) + .post('/api/elements') + .set('Content-Type', 'application/json') + .send('{"constructor": {"name": "evil"}, "type": "rectangle", "x": 0, "y": 0}'); + + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/disallowed keys/i); + }); + + it('rejects POST body containing nested __proto__ → 400', async () => { + const res = await request(app) + .post('/api/elements') + .set('Content-Type', 'application/json') + .send('{"element": {"__proto__": {"evil": true}}, "type": "rectangle", "x": 0, "y": 0}'); + + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/disallowed keys/i); + }); + + it('accepts clean POST body → not 400', async () => { + const res = await request(app) + .post('/api/elements') + .send({ type: 'rectangle', x: 0, y: 0, width: 100, height: 50 }); + + expect(res.status).not.toBe(400); + }); +}); + +// ── MCP import_scene adversarial JSON — documented gap ─────────────────────── +// +// MCP tool calls reach `import_scene` via stdio → index.ts. +// The handler does: `sceneData = JSON.parse(params.data)` with no sanitization. +// +// SAFETY NOTE: In modern Node.js (V8 ≥ 8.x), JSON.parse does NOT pollute +// Object.prototype when encountering `{"__proto__": ...}` — it creates a plain +// key named "__proto__" on the result object without calling [[Set]] on the +// prototype chain. However, downstream code that uses Object.assign() or +// spread {...sceneData} can re-trigger pollution if the key is spread into +// an object whose prototype is Object.prototype. +// +// The tests below are NOT executable without a running MCP stdio process. +// They are represented as unit assertions on the JSON.parse behaviour itself +// to document the exact risk surface. + +describe('MCP import_scene — JSON.parse prototype behaviour (gap documentation)', () => { + it('JSON.parse with __proto__ key does NOT pollute Object.prototype in modern Node', () => { + // This is the safety net we rely on. If this test ever fails, the MCP path + // is directly exploitable for prototype pollution. + const parsed = JSON.parse('{"__proto__": {"isAdmin": true}}'); + + // The key exists as a plain own property, not as a prototype mutation + expect(Object.prototype.hasOwnProperty.call(parsed, '__proto__')).toBe(true); + expect((Object.prototype as any).isAdmin).toBeUndefined(); + }); + + it('Object.assign with a JSON-parsed __proto__ key mutates the spread target prototype chain', () => { + // CONFIRMED REAL BEHAVIOUR: Object.assign({}, parsed) where parsed has a + // "__proto__" own key (from JSON.parse) triggers the __proto__ setter on + // Object.prototype, which changes the *target* object's prototype to the + // value. This means `cloned.injected` resolves via prototype lookup. + // + // This does NOT pollute Object.prototype itself — only the cloned object's + // prototype chain. But any code in index.ts that does `{ ...sceneData }` or + // `Object.assign({}, sceneData)` after JSON.parse on MCP input is affected. + const parsed = JSON.parse('{"__proto__": {"injected": true}}') as any; + + // Verify parsed has __proto__ as an own property (not prototype pollution) + expect(Object.prototype.hasOwnProperty.call(parsed, '__proto__')).toBe(true); + expect((Object.prototype as any).injected).toBeUndefined(); // Object.prototype is clean + + // Spreading/assigning DOES change the target's prototype: + const cloned = Object.assign({}, parsed); + expect((cloned as any).injected).toBe(true); // inherited from mutated prototype + + // Object.prototype is still clean after the spread + expect((Object.prototype as any).injected).toBeUndefined(); + }); + + it('deeply nested JSON (depth 1000) does not cause stack overflow during JSON.parse', () => { + // MCP import_scene does JSON.parse on user-supplied data with no depth limit. + // Node.js JSON.parse handles deep nesting iteratively — verify it does not + // blow the call stack at practical depths. + const depth = 1000; + const nested = '['.repeat(depth) + '1' + ']'.repeat(depth); + + expect(() => JSON.parse(nested)).not.toThrow(); + }); + + it('HYPOTHESIS: extremely deep nesting (depth 100_000) may throw in some runtimes', () => { + // Document the practical limit. If this throws a RangeError (stack overflow), + // the MCP import_scene handler is vulnerable to DoS via deeply nested payloads. + const depth = 100_000; + const nested = '['.repeat(depth) + '1' + ']'.repeat(depth); + + // We only assert "does not silently succeed with wrong data" — either it + // parses correctly or throws a catchable error (not a process crash). + let threw = false; + try { + JSON.parse(nested); + } catch { + threw = true; + } + // Either outcome is acceptable — the key assertion is that the process survives + expect(true).toBe(true); + }); +}); + +// ── Path traversal — export endpoint (shared sanitizeFilePath logic) ────────── + +describe('Path traversal on export endpoints', () => { + it('POST /api/export/image with path traversal in filePath → error (not 200)', async () => { + const res = await request(app) + .post('/api/export/image') + .send({ + filePath: '../../../../etc/passwd', + format: 'png' + }); + + // Should not be 200 — either 400 (validation) or 500 (server error before write) + expect(res.status).not.toBe(200); + }); + + it('POST /api/export/image with absolute path outside cwd → error (not 200)', async () => { + const outsidePath = '/tmp/traversal-test-excalidraw.png'; + const res = await request(app) + .post('/api/export/image') + .send({ + filePath: outsidePath, + format: 'png' + }); + + expect(res.status).not.toBe(200); + }); +}); + +// ── Null-byte injection ─────────────────────────────────────────────────────── + +describe('Null byte and encoding edge cases', () => { + it('POST /api/elements with null byte in type field does not crash server', async () => { + const res = await request(app) + .post('/api/elements') + .send({ type: 'rectangle\x00', x: 0, y: 0, width: 100, height: 50 }); + + // Must return a 4xx — not 200 and not an unhandled 500 + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); + }); + + it('POST /api/elements/batch with oversized element text does not hang', async () => { + // Verify server responds within reasonable time even with a large text field + // (this is a regression guard — a 413 or 400 is both acceptable) + const res = await request(app) + .post('/api/elements') + .set('Content-Type', 'application/json') + .send(JSON.stringify({ + type: 'text', + x: 0, y: 0, width: 100, height: 50, + text: 'A'.repeat(200 * 1024) // 200 KB — over the 100 KB limit + })); + + expect(res.status).toBe(413); + }); +}); diff --git a/tests/backend/security-unit.test.ts b/tests/backend/security-unit.test.ts new file mode 100644 index 0000000..3dbc0c8 --- /dev/null +++ b/tests/backend/security-unit.test.ts @@ -0,0 +1,160 @@ +/** + * Unit tests for src/security.ts + * + * Covers: validateApiKey, sanitizeSearchQuery, sanitizeBody behaviour. + * No server or DB required — pure function tests. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + validateApiKey, + sanitizeSearchQuery, + InvalidSearchQueryError, + isAuthEnabled, +} from '../../src/security.js'; + +// ── validateApiKey ──────────────────────────────────────────────────────────── + +describe('validateApiKey — auth disabled', () => { + beforeEach(() => { delete process.env.EXCALIDRAW_API_KEY; }); + + it('returns true for any value when no API key env var is set', () => { + expect(validateApiKey('anything')).toBe(true); + expect(validateApiKey(undefined)).toBe(true); + expect(validateApiKey('')).toBe(true); + }); + + it('isAuthEnabled returns false when env var is unset', () => { + expect(isAuthEnabled()).toBe(false); + }); +}); + +describe('validateApiKey — auth enabled', () => { + const CORRECT_KEY = 'super-secret-key-32chars!!!!!!!!'; + + beforeEach(() => { process.env.EXCALIDRAW_API_KEY = CORRECT_KEY; }); + afterEach(() => { delete process.env.EXCALIDRAW_API_KEY; }); + + it('returns true for exact match', () => { + expect(validateApiKey(CORRECT_KEY)).toBe(true); + }); + + it('returns false for undefined', () => { + expect(validateApiKey(undefined)).toBe(false); + }); + + it('returns false for empty string', () => { + expect(validateApiKey('')).toBe(false); + }); + + it('returns false for array (non-string type guard)', () => { + expect(validateApiKey(['correct'] as any)).toBe(false); + }); + + it('returns false for a wrong key of the SAME length — timingSafeEqual path', () => { + // Same length forces the timingSafeEqual code path (not the early-exit). + // timingSafeEqual must not throw when buffers are the same length. + const sameLen = 'X'.repeat(CORRECT_KEY.length); + expect(() => validateApiKey(sameLen)).not.toThrow(); + expect(validateApiKey(sameLen)).toBe(false); + }); + + it('returns false for correct key with one extra char (different length)', () => { + // DESIGN NOTE: the current implementation returns false early when lengths + // differ, without calling timingSafeEqual. This means an attacker probing + // keys of length 1..N can infer the correct key length via response-time + // differences. Documented here as a known design decision. + expect(validateApiKey(CORRECT_KEY + 'x')).toBe(false); + }); + + it('returns false for correct key with one char missing', () => { + expect(validateApiKey(CORRECT_KEY.slice(0, -1))).toBe(false); + }); + + it('returns false for key that differs only in one character', () => { + // Replace last char with something definitely different from the original + const lastChar = CORRECT_KEY[CORRECT_KEY.length - 1]!; + const differentChar = lastChar === 'Z' ? 'A' : 'Z'; + const almostRight = CORRECT_KEY.slice(0, -1) + differentChar; + expect(validateApiKey(almostRight)).toBe(false); + }); + + it('isAuthEnabled returns true when env var is set', () => { + expect(isAuthEnabled()).toBe(true); + }); +}); + +// ── sanitizeSearchQuery ─────────────────────────────────────────────────────── + +describe('sanitizeSearchQuery — valid inputs', () => { + it('trims whitespace and returns clean query', () => { + expect(sanitizeSearchQuery(' hello world ')).toBe('hello world'); + }); + + it('returns empty string for whitespace-only input', () => { + expect(sanitizeSearchQuery(' ')).toBe(''); + }); + + it('allows plain alphanumeric query', () => { + expect(sanitizeSearchQuery('rectangle')).toBe('rectangle'); + }); + + it('allows hyphenated terms', () => { + expect(sanitizeSearchQuery('my-diagram')).toBe('my-diagram'); + }); + + it('allows numbers', () => { + expect(sanitizeSearchQuery('123')).toBe('123'); + }); +}); + +describe('sanitizeSearchQuery — FTS operator injection', () => { + it('throws on double-quote character', () => { + expect(() => sanitizeSearchQuery('"quoted phrase"')).toThrow(InvalidSearchQueryError); + }); + + it('throws on AND operator (uppercase)', () => { + expect(() => sanitizeSearchQuery('foo AND bar')).toThrow(InvalidSearchQueryError); + }); + + it('throws on AND operator (lowercase)', () => { + expect(() => sanitizeSearchQuery('foo and bar')).toThrow(InvalidSearchQueryError); + }); + + it('throws on OR operator', () => { + expect(() => sanitizeSearchQuery('foo OR bar')).toThrow(InvalidSearchQueryError); + }); + + it('throws on NOT operator', () => { + expect(() => sanitizeSearchQuery('NOT secret')).toThrow(InvalidSearchQueryError); + }); + + it('throws on NEAR operator', () => { + expect(() => sanitizeSearchQuery('foo NEAR bar')).toThrow(InvalidSearchQueryError); + }); + + it('throws on NEAR/N distance syntax', () => { + expect(() => sanitizeSearchQuery('foo NEAR/5 bar')).toThrow(InvalidSearchQueryError); + }); + + it('throws on glob wildcard *', () => { + expect(() => sanitizeSearchQuery('pass*')).toThrow(InvalidSearchQueryError); + }); + + it('throws on parentheses (grouping)', () => { + expect(() => sanitizeSearchQuery('(foo bar)')).toThrow(InvalidSearchQueryError); + }); + + it('throws on curly braces', () => { + expect(() => sanitizeSearchQuery('{foo}')).toThrow(InvalidSearchQueryError); + }); + + it('throws on caret prefix-weight operator', () => { + expect(() => sanitizeSearchQuery('^important')).toThrow(InvalidSearchQueryError); + }); + + it('throws on colon column-filter syntax (FTS5 column filter)', () => { + // "label_text:secret" would scope the search to a single FTS column. + // Fixed: colon is now a blocked character. + expect(() => sanitizeSearchQuery('label_text:secret')).toThrow(InvalidSearchQueryError); + }); +}); diff --git a/tests/backend/smoke-ws.test.ts b/tests/backend/smoke-ws.test.ts new file mode 100644 index 0000000..9d26de5 --- /dev/null +++ b/tests/backend/smoke-ws.test.ts @@ -0,0 +1,73 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import WebSocket from 'ws'; +import path from 'path'; +import os from 'os'; +import fs from 'fs'; +import { closeDb, initDb } from '../../src/db.js'; + +let port: number; +let dbPath: string; +let startCanvasServer: (() => Promise) | undefined; +let stopCanvasServer: (() => Promise) | undefined; + +function waitForOpen(ws: WebSocket, timeoutMs = 5000): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('WS open timeout')), timeoutMs); + ws.once('open', () => { + clearTimeout(timer); + resolve(); + }); + ws.once('error', (err) => { + clearTimeout(timer); + reject(err); + }); + }); +} + +beforeAll(async () => { + port = 3600 + Math.floor(Math.random() * 200); + dbPath = path.join(os.tmpdir(), `excalidraw-smoke-ws-${Date.now()}.db`); + + process.env.CANVAS_PORT = String(port); + process.env.HOST = 'localhost'; + delete process.env.EXCALIDRAW_API_KEY; + process.env.EXCALIDRAW_DB_PATH = dbPath; + + initDb(dbPath); + const serverMod = await import('../../src/server.js'); + startCanvasServer = serverMod.startCanvasServer; + stopCanvasServer = serverMod.stopCanvasServer; + await startCanvasServer(); +}); + +afterAll(async () => { + if (stopCanvasServer) { + await stopCanvasServer(); + } + closeDb(); + delete process.env.CANVAS_PORT; + delete process.env.HOST; + delete process.env.EXCALIDRAW_DB_PATH; + for (const suffix of ['', '-wal', '-shm']) { + try { fs.unlinkSync(dbPath + suffix); } catch {} + } +}); + +describe('Smoke WS + persistence checks', () => { + it('creates SQLite database file', () => { + expect(fs.existsSync(dbPath)).toBe(true); + }); + + it('accepts WebSocket connection and reports websocket_clients in /health', async () => { + const ws = new WebSocket(`ws://localhost:${port}`); + await waitForOpen(ws); + + const healthRes = await fetch(`http://localhost:${port}/health`); + expect(healthRes.ok).toBe(true); + const healthBody = await healthRes.json() as { websocket_clients: number; status: string }; + expect(healthBody.status).toBe('healthy'); + expect(healthBody.websocket_clients).toBeGreaterThanOrEqual(1); + + ws.close(); + }); +}); diff --git a/tests/backend/smoke.test.ts b/tests/backend/smoke.test.ts index 0332116..bc1dfeb 100644 --- a/tests/backend/smoke.test.ts +++ b/tests/backend/smoke.test.ts @@ -9,8 +9,12 @@ let dbPath: string; let app: any; const frontendDir = path.join(process.cwd(), 'dist/frontend'); const frontendHtmlPath = path.join(frontendDir, 'index.html'); +const frontendAssetsDir = path.join(frontendDir, 'assets'); +const frontendSmokeAssetPath = path.join(frontendAssetsDir, 'smoke.js'); let originalFrontendHtml: string | null = null; let hadFrontendHtml = false; +let hadSmokeAsset = false; +let originalSmokeAsset: string | null = null; beforeEach(async () => { dbPath = path.join(os.tmpdir(), `excalidraw-smoke-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`); @@ -18,8 +22,12 @@ beforeEach(async () => { setActiveTenant('default'); hadFrontendHtml = fs.existsSync(frontendHtmlPath); originalFrontendHtml = hadFrontendHtml ? fs.readFileSync(frontendHtmlPath, 'utf8') : null; + hadSmokeAsset = fs.existsSync(frontendSmokeAssetPath); + originalSmokeAsset = hadSmokeAsset ? fs.readFileSync(frontendSmokeAssetPath, 'utf8') : null; fs.mkdirSync(frontendDir, { recursive: true }); + fs.mkdirSync(frontendAssetsDir, { recursive: true }); fs.writeFileSync(frontendHtmlPath, 'Smoke
'); + fs.writeFileSync(frontendSmokeAssetPath, 'console.log("smoke asset");'); const mod = await import('../../src/server.js'); app = mod.default; }); @@ -32,6 +40,11 @@ afterEach(() => { } else { try { fs.unlinkSync(frontendHtmlPath); } catch {} } + if (hadSmokeAsset && originalSmokeAsset !== null) { + fs.writeFileSync(frontendSmokeAssetPath, originalSmokeAsset); + } else { + try { fs.unlinkSync(frontendSmokeAssetPath); } catch {} + } for (const suffix of ['', '-wal', '-shm']) { try { fs.unlinkSync(dbPath + suffix); } catch {} } @@ -48,6 +61,13 @@ describe('Smoke checks', () => { expect(rootRes.text).toContain('
'); }); + it('serves frontend assets from /assets', async () => { + const assetRes = await request(app).get('/assets/smoke.js'); + expect(assetRes.status).toBe(200); + expect(assetRes.text).toContain('smoke asset'); + expect(assetRes.headers['content-type']).toContain('javascript'); + }); + it('supports a keyed create-list-delete smoke flow', async () => { process.env.EXCALIDRAW_API_KEY = 'smoke-secret'; diff --git a/tests/backend/tenant-authz-behavior.test.ts b/tests/backend/tenant-authz-behavior.test.ts new file mode 100644 index 0000000..5068943 --- /dev/null +++ b/tests/backend/tenant-authz-behavior.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import request from 'supertest'; +import { + closeDb, + ensureTenant, + initDb, + setActiveTenant, +} from '../../src/db.js'; +import path from 'path'; +import os from 'os'; +import fs from 'fs'; + +let dbPath: string; +let app: any; + +beforeEach(async () => { + dbPath = path.join( + os.tmpdir(), + `excalidraw-tenant-authz-${Date.now()}-${Math.random().toString(36).slice(2)}.db` + ); + initDb(dbPath); + setActiveTenant('default'); + process.env.EXCALIDRAW_API_KEY = 'tenant-secret'; + const mod = await import('../../src/server.js'); + app = mod.default; +}); + +afterEach(() => { + delete process.env.EXCALIDRAW_API_KEY; + closeDb(); + for (const suffix of ['', '-wal', '-shm']) { + try { fs.unlinkSync(dbPath + suffix); } catch {} + } +}); + +describe('Tenant scoping behavior with API key auth', () => { + it('any valid API key caller can scope into any existing tenant via X-Tenant-Id', async () => { + ensureTenant('tenant-a', 'Tenant A', '/a'); + ensureTenant('tenant-b', 'Tenant B', '/b'); + + await request(app) + .post('/api/elements') + .set('X-API-Key', 'tenant-secret') + .set('X-Tenant-Id', 'tenant-a') + .send({ id: 'a-only', type: 'rectangle', x: 0, y: 0, width: 40, height: 30 }); + + await request(app) + .post('/api/elements') + .set('X-API-Key', 'tenant-secret') + .set('X-Tenant-Id', 'tenant-b') + .send({ id: 'b-only', type: 'ellipse', x: 0, y: 0, width: 40, height: 30 }); + + const aRes = await request(app) + .get('/api/elements') + .set('X-API-Key', 'tenant-secret') + .set('X-Tenant-Id', 'tenant-a'); + + const bRes = await request(app) + .get('/api/elements') + .set('X-API-Key', 'tenant-secret') + .set('X-Tenant-Id', 'tenant-b'); + + expect(aRes.status).toBe(200); + expect(bRes.status).toBe(200); + expect(aRes.body.elements.map((el: any) => el.id)).toContain('a-only'); + expect(aRes.body.elements.map((el: any) => el.id)).not.toContain('b-only'); + expect(bRes.body.elements.map((el: any) => el.id)).toContain('b-only'); + expect(bRes.body.elements.map((el: any) => el.id)).not.toContain('a-only'); + }); + + it('missing X-Tenant-Id falls back to active tenant context', async () => { + ensureTenant('tenant-fallback', 'Tenant Fallback', '/fallback'); + + const switchRes = await request(app) + .put('/api/tenant/active') + .set('X-API-Key', 'tenant-secret') + .send({ tenantId: 'tenant-fallback' }); + expect(switchRes.status).toBe(200); + + await request(app) + .post('/api/elements') + .set('X-API-Key', 'tenant-secret') + .send({ id: 'fallback-el', type: 'rectangle', x: 0, y: 0, width: 10, height: 10 }); + + const listRes = await request(app) + .get('/api/elements') + .set('X-API-Key', 'tenant-secret'); + + expect(listRes.status).toBe(200); + expect(listRes.body.elements.map((el: any) => el.id)).toContain('fallback-el'); + }); + + it('unknown X-Tenant-Id is rejected by server behavior (document current trust boundary)', async () => { + const res = await request(app) + .get('/api/elements') + .set('X-API-Key', 'tenant-secret') + .set('X-Tenant-Id', 'does-not-exist'); + + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(600); + }); +}); diff --git a/tests/e2e/phase2-regressions.spec.ts b/tests/e2e/phase2-regressions.spec.ts new file mode 100644 index 0000000..bb2bff2 --- /dev/null +++ b/tests/e2e/phase2-regressions.spec.ts @@ -0,0 +1,152 @@ +import { test, expect, type Page } from '@playwright/test'; + +const API = 'http://127.0.0.1:3100'; + +async function resetCanvas(request: any): Promise { + await request.delete(`${API}/api/elements/clear?confirm=true`); +} + +async function waitForConnected(page: Page): Promise { + await expect(page.locator('.status span')).toContainText('Connected', { timeout: 5000 }); +} + +test.beforeEach(async ({ request }) => { + await resetCanvas(request); +}); + +test.describe('Phase 2 regressions', () => { + test('position stability survives reloads for pre-seeded elements', async ({ page, request }) => { + await request.post(`${API}/api/elements`, { + data: { + id: 'pos-stable-1', + type: 'rectangle', + x: 220, + y: 140, + width: 260, + height: 110, + label: { text: 'Stable Label' }, + }, + }); + + await page.goto('/'); + await waitForConnected(page); + await page.waitForTimeout(800); + + const initialRes = await request.get(`${API}/api/elements/pos-stable-1`); + expect(initialRes.ok()).toBe(true); + const initial = (await initialRes.json()).element as { + x: number; + y: number; + width: number; + height: number; + label?: { text?: string }; + }; + + await page.reload(); + await waitForConnected(page); + await page.waitForTimeout(800); + + const afterReloadRes = await request.get(`${API}/api/elements/pos-stable-1`); + expect(afterReloadRes.ok()).toBe(true); + const afterReload = (await afterReloadRes.json()).element as { + x: number; + y: number; + width: number; + height: number; + label?: { text?: string }; + }; + + expect(afterReload.x).toBe(initial.x); + expect(afterReload.y).toBe(initial.y); + expect(afterReload.width).toBe(initial.width); + expect(afterReload.height).toBe(initial.height); + expect(afterReload.label?.text).toBe('Stable Label'); + }); + + test('new container arrival auto-injects title and subtitle text', async ({ page, request }) => { + await page.goto('/'); + await waitForConnected(page); + + const createRes = await request.post(`${API}/api/elements`, { + data: { + id: 'auto-title-seed', + type: 'rectangle', + x: 220, + y: 140, + width: 260, + height: 110, + }, + }); + expect(createRes.ok()).toBe(true); + + await page.waitForTimeout(1200); + await page.getByRole('button', { name: /^Sync$/ }).click(); + + await expect.poll(async () => { + const listRes = await request.get(`${API}/api/elements`); + if (!listRes.ok()) return false; + const listBody = await listRes.json() as { elements: any[] }; + const titleText = listBody.elements.find((el) => el.type === 'text' && el.text === 'Title'); + const subtitleText = listBody.elements.find((el) => el.type === 'text' && el.text === 'Text here'); + return Boolean(titleText && subtitleText); + }, { timeout: 7000 }).toBe(true); + }); + + test('two connected tabs receive cross-tab sync events', async ({ page, context }) => { + const page2 = await context.newPage(); + + await page2.addInitScript(() => { + const NativeWS = window.WebSocket; + (window as any).__wsSeenTypes = [] as string[]; + + const Wrapped = function(this: any, url: string | URL, protocols?: string | string[]) { + const ws = protocols !== undefined ? new NativeWS(url, protocols) : new NativeWS(url); + ws.addEventListener('message', (event) => { + try { + const raw = typeof event.data === 'string' ? event.data : ''; + const parsed = JSON.parse(raw); + if (parsed?.type) { + (window as any).__wsSeenTypes.push(parsed.type); + } + } catch {} + }); + return ws; + } as any; + + Wrapped.prototype = NativeWS.prototype; + Object.assign(Wrapped, NativeWS); + window.WebSocket = Wrapped; + }); + + await page.goto('/'); + await page2.goto('/'); + await waitForConnected(page); + await waitForConnected(page2); + + const createRes = await page.evaluate(async () => { + const res = await fetch('/api/elements', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: 'two-tab-sync-1', + type: 'rectangle', + x: 30, + y: 40, + width: 120, + height: 70, + }), + }); + return { ok: res.ok, status: res.status }; + }); + expect(createRes.ok).toBe(true); + + await expect.poll(async () => { + return await page2.evaluate(() => + Array.isArray((window as any).__wsSeenTypes) && + (window as any).__wsSeenTypes.includes('element_created') + ); + }, { timeout: 6000 }).toBe(true); + + await page2.close(); + }); +}); diff --git a/tests/frontend/helpers.test.ts b/tests/frontend/helpers.test.ts index e7b108b..5e83d38 100644 --- a/tests/frontend/helpers.test.ts +++ b/tests/frontend/helpers.test.ts @@ -33,7 +33,8 @@ describe('cleanElementForExcalidraw', () => { expect(cleaned).not.toHaveProperty('createdAt'); expect(cleaned).not.toHaveProperty('updatedAt'); - expect(cleaned).not.toHaveProperty('version'); + // version is kept — it is the Excalidraw element version, not a DB field + expect(cleaned).toHaveProperty('version', 3); expect(cleaned).not.toHaveProperty('syncedAt'); expect(cleaned).not.toHaveProperty('source'); expect(cleaned).not.toHaveProperty('syncTimestamp'); @@ -219,6 +220,21 @@ describe('computeElementHash', () => { const hash = computeElementHash([{ id: 'x', version: 1 }]); expect(hash.startsWith('1')).toBe(true); }); + + it('is order-stable for same id/version set', () => { + const a = [ + { id: 'a', version: 1 }, + { id: 'b', version: 3 }, + { id: 'c', version: 2 }, + ]; + const b = [ + { id: 'c', version: 2 }, + { id: 'a', version: 1 }, + { id: 'b', version: 3 }, + ]; + + expect(computeElementHash(a)).toBe(computeElementHash(b)); + }); }); // ─── isImageElement ───────────────────────────────────────── diff --git a/tests/frontend/scene-preparation.test.ts b/tests/frontend/scene-preparation.test.ts new file mode 100644 index 0000000..412644c --- /dev/null +++ b/tests/frontend/scene-preparation.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + expandLabelsToNative, + prepareElementsForScene, +} from '../../frontend/src/utils/scenePreparation.js'; +import type { ServerElement } from '../../frontend/src/utils/elementHelpers.js'; + +describe('expandLabelsToNative', () => { + it('creates a native bound text element at container center', () => { + const input = [{ + id: 'box-1', + type: 'rectangle', + x: 100, + y: 200, + width: 300, + height: 120, + label: { text: 'Title' }, + boundElements: [{ id: 'arrow-1', type: 'arrow' }], + }]; + + const out = expandLabelsToNative(input as any[]); + expect(out).toHaveLength(2); + + const container = out.find((el) => el.id === 'box-1') as any; + const text = out.find((el) => el.id === 'box-1_label') as any; + + expect(container.boundElements).toEqual([ + { id: 'arrow-1', type: 'arrow' }, + { id: 'box-1_label', type: 'text' }, + ]); + expect(text.containerId).toBe('box-1'); + expect(text.text).toBe('Title'); + expect(text.x).toBe(230); + expect(text.y).toBe(250); + }); + + it('passes through elements with no label.text unchanged', () => { + const a = { id: 'a', type: 'rectangle', x: 0, y: 0, width: 100, height: 40 }; + const b = { id: 'b', type: 'text', x: 10, y: 10, text: 'Hello' }; + const out = expandLabelsToNative([a, b] as any[]); + expect(out).toEqual([a, b]); + }); +}); + +describe('prepareElementsForScene', () => { + it('routes native browser-synced elements without conversion', () => { + const native = { + id: 'native-1', + type: 'rectangle', + x: 0, + y: 0, + width: 100, + height: 50, + seed: 123, + versionNonce: 456, + version: 1, + } as any as ServerElement; + + const converter = vi.fn((elements: readonly any[]) => + elements.map((el) => ({ ...el, converted: true })) + ); + + const out = prepareElementsForScene([native], converter as any); + expect(converter).not.toHaveBeenCalled(); + expect(out).toHaveLength(1); + expect((out[0] as any).id).toBe('native-1'); + expect((out[0] as any).converted).toBeUndefined(); + }); + + it('routes MCP stubs through converter', () => { + const stub = { + id: 'stub-1', + type: 'rectangle', + x: 10, + y: 20, + width: 80, + height: 40, + label: { text: 'Stub' }, + version: 1, + } as ServerElement; + + const converter = vi.fn((elements: readonly any[]) => + elements.map((el) => ({ ...el, converted: true })) + ); + + const out = prepareElementsForScene([stub], converter as any); + expect(converter).toHaveBeenCalledTimes(1); + expect(out.some((el) => (el as any).id === 'stub-1' && (el as any).converted)).toBe(true); + }); +}); diff --git a/tests/frontend/sync-logic.test.ts b/tests/frontend/sync-logic.test.ts index 5b1a6ad..845d4e7 100644 --- a/tests/frontend/sync-logic.test.ts +++ b/tests/frontend/sync-logic.test.ts @@ -12,7 +12,7 @@ import { // ─── cleanElementForExcalidraw comprehensive ──────────────── describe('cleanElementForExcalidraw - comprehensive', () => { - it('strips all server-only metadata fields', () => { + it('strips server-only metadata fields but preserves Excalidraw version', () => { const serverEl = { id: 'el-1', type: 'rectangle', @@ -31,7 +31,8 @@ describe('cleanElementForExcalidraw - comprehensive', () => { const cleaned = cleanElementForExcalidraw(serverEl); expect(cleaned).not.toHaveProperty('createdAt'); expect(cleaned).not.toHaveProperty('updatedAt'); - expect(cleaned).not.toHaveProperty('version'); + // version is kept — it is the Excalidraw element version, not a DB field + expect(cleaned).toHaveProperty('version', 1); expect(cleaned).not.toHaveProperty('syncedAt'); expect(cleaned).not.toHaveProperty('source'); expect(cleaned).not.toHaveProperty('syncTimestamp');