diff --git a/explorer/package.json b/explorer/package.json index 0c6f913a..fbd0b202 100644 --- a/explorer/package.json +++ b/explorer/package.json @@ -8,10 +8,8 @@ "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", - "test": "node --import tsx --test tests/markdownContentViewer.test.ts tests/graphSceneState.display.test.ts tests/temporalLifecycle.test.ts", "test:graph-store": "node --test tests/graphStore.multi-edge.test.mjs", "test:graph-workspace": "node --import tsx --test tests/markdownContentViewer.test.ts tests/graphSceneState.display.test.ts tests/temporalLifecycle.test.ts", - "test:markdown-viewer": "node --import tsx --test tests/markdownContentViewer.test.ts", "test:plugin-registry": "node --import tsx --test tests/pluginRegistry.temporal.test.mjs" }, "dependencies": { diff --git a/explorer/src/workspaces/GraphWorkspace/MarkdownContentViewer.tsx b/explorer/src/workspaces/GraphWorkspace/MarkdownContentViewer.tsx index 3ac5cd5a..6525c124 100644 --- a/explorer/src/workspaces/GraphWorkspace/MarkdownContentViewer.tsx +++ b/explorer/src/workspaces/GraphWorkspace/MarkdownContentViewer.tsx @@ -1,4 +1,4 @@ -import { useState, type CSSProperties } from "react"; +import { useState, useRef, useEffect, type CSSProperties } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { Check, Copy, Code2, Eye, ExternalLink, Image as ImageIcon } from "lucide-react"; @@ -13,7 +13,9 @@ export interface MarkdownContentViewerProps { export function isSafeUrl(url?: string): boolean { if (!url) return false; const trimmed = url.trim(); - if (trimmed.startsWith("#") || trimmed.startsWith("/")) return true; + if (trimmed.startsWith("//")) return false; + if (trimmed.startsWith("#")) return true; + if (trimmed.startsWith("/")) return true; try { const parsed = new URL(trimmed, "http://localhost"); return ["http:", "https:", "mailto:"].includes(parsed.protocol); @@ -29,6 +31,15 @@ export function MarkdownContentViewer({ }: MarkdownContentViewerProps) { const [activeMode, setActiveMode] = useState<"preview" | "source">(defaultMode); const [copied, setCopied] = useState(false); + const copyTimeoutRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (copyTimeoutRef.current) { + clearTimeout(copyTimeoutRef.current); + } + }; + }, []); const rawContent = typeof content === "string" ? content : ""; const hasContent = rawContent.trim().length > 0; @@ -37,8 +48,11 @@ export function MarkdownContentViewer({ if (!hasContent) return; try { await navigator.clipboard.writeText(rawContent); + if (copyTimeoutRef.current) { + clearTimeout(copyTimeoutRef.current); + } setCopied(true); - setTimeout(() => setCopied(false), 1500); + copyTimeoutRef.current = setTimeout(() => setCopied(false), 1500); } catch { // Clipboard write unavailable } diff --git a/explorer/tests/markdownContentViewer.test.ts b/explorer/tests/markdownContentViewer.test.ts index d0c608f1..5596d0ab 100644 --- a/explorer/tests/markdownContentViewer.test.ts +++ b/explorer/tests/markdownContentViewer.test.ts @@ -1,8 +1,13 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { isSafeUrl } from "../src/workspaces/GraphWorkspace/MarkdownContentViewer.tsx"; +import React from "react"; +import { renderToString } from "react-dom/server"; -test("isSafeUrl permits safe http, https, and mailto URLs", () => { +(globalThis as any).React = React; + +import { isSafeUrl, MarkdownContentViewer } from "../src/workspaces/GraphWorkspace/MarkdownContentViewer.tsx"; + +test("isSafeUrl permits safe http, https, and mailto URLs and relative paths", () => { assert.equal(isSafeUrl("https://example.com"), true); assert.equal(isSafeUrl("http://localhost:8000"), true); assert.equal(isSafeUrl("mailto:user@example.com"), true); @@ -10,7 +15,13 @@ test("isSafeUrl permits safe http, https, and mailto URLs", () => { assert.equal(isSafeUrl("/relative/path"), true); }); -test("isSafeUrl rejects dangerous schemes like javascript:, data:, and vbscript:", () => { +test("isSafeUrl rejects protocol-relative URLs and dangerous schemes", () => { + // Protocol-relative URLs (must be blocked) + assert.equal(isSafeUrl("//evil.com"), false); + assert.equal(isSafeUrl("//localhost:8000"), false); + assert.equal(isSafeUrl("//"), false); + + // Dangerous schemes assert.equal(isSafeUrl("javascript:alert('xss')"), false); assert.equal(isSafeUrl("JAVASCRIPT:alert(1)"), false); assert.equal(isSafeUrl("data:text/html;base64,PHNjcmlwdD4="), false); @@ -19,52 +30,91 @@ test("isSafeUrl rejects dangerous schemes like javascript:, data:, and vbscript: assert.equal(isSafeUrl(undefined), false); }); -test("preserves exact unmodified content, whitespace, and Unicode in source format", () => { - const sampleMarkdown = `# Title with Unicode 🚀\n\n * Indented item 1\n * Indented item 2\n\n\`\`\`python\ndef test():\n return "α + β = γ"\n\`\`\``; - - // Exact characters, newlines, and whitespace must remain unmodified - assert.equal(sampleMarkdown.includes(" * Indented item 1"), true); - assert.equal(sampleMarkdown.includes("🚀"), true); - assert.equal(sampleMarkdown.includes("α + β = γ"), true); - assert.equal(sampleMarkdown.includes(" return"), true); +test("renders Preview mode with formatted Markdown elements and tabs", () => { + const markdown = `# Main Title\n\n**Bold Statement**\n\n* Item A\n* Item B`; + const html = renderToString(React.createElement(MarkdownContentViewer, { content: markdown, defaultMode: "preview" })); + + // Tab buttons are present + assert.equal(html.includes("Preview"), true); + assert.equal(html.includes("Source"), true); + assert.equal(html.includes("Copy"), true); + + // Formatted preview elements + assert.equal(html.includes("Main Title"), true); + assert.equal(html.includes("Bold Statement"), true); + assert.equal(html.includes("Bold Statement"), true); + assert.equal(html.includes("Item A"), true); + assert.equal(html.includes("Item B"), true); }); -test("handles empty, null, and whitespace content gracefully without errors", () => { - const emptyValues = ["", " \n\t ", null, undefined]; - for (const val of emptyValues) { - const raw = typeof val === "string" ? val : ""; - const hasContent = raw.trim().length > 0; - assert.equal(hasContent, false); - } +test("renders Source mode with exact unmodified text inside pre/code", () => { + const markdown = `# Title 🚀\n\n * Indented item\n\n\`\`\`python\ndef test():\n return "α + β"\n\`\`\``; + const html = renderToString(React.createElement(MarkdownContentViewer, { content: markdown, defaultMode: "source" })); + + assert.equal(html.includes(" { - const plainText = "Simple plain text summary of graph entity without any formatting."; - const raw = typeof plainText === "string" ? plainText : ""; - const hasContent = raw.trim().length > 0; - assert.equal(hasContent, true); - assert.equal(raw, plainText); +test("renders raw HTML safely as escaped text without executing elements", () => { + const dangerousHtml = ``; + const html = renderToString(React.createElement(MarkdownContentViewer, { content: dangerousHtml, defaultMode: "preview" })); + + // Script and iframe tags must NOT be rendered as active DOM tags + assert.equal(html.includes("`; - // In source mode, content is preserved literally without execution - assert.equal(dangerousHtml.includes("