Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion src/components/studio/views/chat-subcomponents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { VoiceInput } from '../voice-input'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { sanitizeUrl } from '@/lib/security'
import { cn } from '@/lib/utils'
import { motion, AnimatePresence } from 'framer-motion'
import type { ChatMessage } from '@/lib/studio/types'
Expand Down Expand Up @@ -40,11 +41,11 @@
/** Button label to clear the chat history. */
export const CLEAR_CHAT_LABEL = 'Clear'
/** Screen-reader prefix for user-authored messages. */
export const USER_ROLE_LABEL = 'You: '

Check notice on line 44 in src/components/studio/views/chat-subcomponents.tsx

View check run for this annotation

nexus-check / GitNexus

Changed symbol: ASSISTANT_ROLE_LABEL

`ASSISTANT_ROLE_LABEL` (Const) is directly changed by this PR. PR-wide downstream impact: 2 direct dependent(s), 4 indirect. See the check summary for the impacted-file breakdown.
/** Screen-reader prefix for assistant-authored messages. */
export const ASSISTANT_ROLE_LABEL = 'Assistant: '

Check notice on line 46 in src/components/studio/views/chat-subcomponents.tsx

View check run for this annotation

nexus-check / GitNexus

Changed symbol: MAX_INPUT_LENGTH

`MAX_INPUT_LENGTH` (Const) is directly changed by this PR. PR-wide downstream impact: 2 direct dependent(s), 4 indirect. See the check summary for the impacted-file breakdown.
/** Maximum input length enforced on the chat textarea. */
export const MAX_INPUT_LENGTH = 2000

Check notice on line 48 in src/components/studio/views/chat-subcomponents.tsx

View check run for this annotation

nexus-check / GitNexus

Changed symbol: COUNTER_THRESHOLD

`COUNTER_THRESHOLD` (Const) is directly changed by this PR. PR-wide downstream impact: 2 direct dependent(s), 4 indirect. See the check summary for the impacted-file breakdown.
/** Input length at which the character counter becomes visible. */
export const COUNTER_THRESHOLD = 1800

Expand Down Expand Up @@ -79,10 +80,10 @@
/** Formats an ISO timestamp into a localized time string. */
export const formatTime = (timestamp: string): string =>
new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' }).format(new Date(timestamp))

/** Properties for the WelcomePanel component. */
export interface WelcomePanelProps {
reducedMotion: boolean

Check notice on line 86 in src/components/studio/views/chat-subcomponents.tsx

View check run for this annotation

nexus-check / GitNexus

Changed symbol: WelcomePanelProps

`WelcomePanelProps` (Interface) is directly changed by this PR. PR-wide downstream impact: 2 direct dependent(s), 4 indirect. See the check summary for the impacted-file breakdown.
onSend: (query: string) => void
}

Expand Down Expand Up @@ -133,13 +134,13 @@

/** A single local source cited by an assistant answer. */
export type ChatCitation = { entityId: string; entityName: string; snippet: string }

/** Properties for the CitationDisclosure sub-component. */
export interface CitationDisclosureProps {
citations: ChatCitation[]
reducedMotion: boolean
expanded: boolean
onToggle: () => void

Check notice on line 143 in src/components/studio/views/chat-subcomponents.tsx

View check run for this annotation

nexus-check / GitNexus

Changed symbol: CitationDisclosureProps

`CitationDisclosureProps` (Interface) is directly changed by this PR. PR-wide downstream impact: 2 direct dependent(s), 4 indirect. See the check summary for the impacted-file breakdown.
onCitationClick: (entityId: string) => void
}

Expand Down Expand Up @@ -200,76 +201,97 @@
</AnimatePresence>
</div>
)

/** Properties for the MessageList component. */
export interface MessageListProps {
chat: ChatMessage[]
reducedMotion: boolean
showCitations: string | null
onToggleCitations: (id: string) => void

Check notice on line 210 in src/components/studio/views/chat-subcomponents.tsx

View check run for this annotation

nexus-check / GitNexus

Changed symbol: MessageListProps

`MessageListProps` (Interface) is directly changed by this PR. PR-wide downstream impact: 2 direct dependent(s), 4 indirect. See the check summary for the impacted-file breakdown.
onCitationClick: (entityId: string) => void
}

/** Scrollable conversation history with per-message role labels and citations. */
export const MessageList = ({
chat,
reducedMotion,
showCitations,
onToggleCitations,
onCitationClick,
}: MessageListProps) => (
<>
{chat.map((m) => (
<motion.div
key={m.id}
initial={reducedMotion ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={reducedMotion ? { duration: 0 } : { duration: 0.25 }}
className={cn('flex gap-3', m.role === 'user' ? 'flex-row-reverse' : 'flex-row')}
>
{/* Avatar */}
<div
className={cn(
'flex h-8 w-8 shrink-0 items-center justify-center rounded-full',
m.role === 'user'
? 'bg-primary text-primary-foreground'
: 'bg-saffron-soft text-saffron-deep',
)}
>
{m.role === 'user' ? <User className="h-4 w-4" /> : <Bot className="h-4 w-4" />}
</div>

{/* Bubble */}
<div className={cn('max-w-[80%] space-y-1.5', m.role === 'user' && 'items-end')}>
<div
className={cn(
'rounded-2xl px-4 py-3 text-[14px] leading-relaxed',
m.role === 'user'
? 'bg-primary text-primary-foreground rounded-tr-sm'
: 'bg-card border border-border text-ink rounded-tl-sm',
)}
>
<span className="sr-only">
{m.role === 'user' ? USER_ROLE_LABEL : ASSISTANT_ROLE_LABEL}
</span>
{m.role === 'assistant' ? (
<div className="prose prose-sm dark:prose-invert max-w-none">
<Markdown remarkPlugins={[remarkGfm]}>{m.content}</Markdown>
<Markdown
remarkPlugins={[remarkGfm]}
urlTransform={(url) => sanitizeUrl(url)}
components={{
a: ({ href, children }) => {
const safeHref = typeof href === 'string' && href ? sanitizeUrl(href) : ''
if (!safeHref) return <span>{children}</span>
return (
<a href={safeHref} target="_blank" rel="noopener noreferrer">
{children}
</a>
)
},
img: ({ src, alt }) => {
const safeSrc = typeof src === 'string' && src ? sanitizeUrl(src) : ''
if (!safeSrc) return null
return <img src={safeSrc} alt={alt ?? ''} />
},
}}
>
{m.content}
</Markdown>
</div>
) : (
m.content
)}
</div>

{/* Timestamp */}
<p
className={cn(
'text-caption text-ink-faint',

Check notice on line 289 in src/components/studio/views/chat-subcomponents.tsx

View check run for this annotation

nexus-check / GitNexus

Changed symbol: MessageList

`MessageList` (Function) is directly changed by this PR. PR-wide downstream impact: 2 direct dependent(s), 4 indirect. See the check summary for the impacted-file breakdown.
m.role === 'user' ? 'text-right' : 'text-left',
)}
>
{formatTime(m.timestamp)}
</p>

Check notice on line 294 in src/components/studio/views/chat-subcomponents.tsx

View check run for this annotation

nexus-check / GitNexus

Changed symbol: SuggestionsBarProps

`SuggestionsBarProps` (Interface) is directly changed by this PR. PR-wide downstream impact: 2 direct dependent(s), 4 indirect. See the check summary for the impacted-file breakdown.

{/* Citations */}
{m.citations && m.citations.length > 0 && (
Expand All @@ -295,15 +317,15 @@
}

/** Quick prompt chips shown between the message list and the input. */
export const SuggestionsBar = ({ onSend }: SuggestionsBarProps) => (
<div className="border-t border-border bg-muted/20 px-5 py-3 lg:px-10">
<div className="mx-auto flex max-w-3xl flex-wrap items-center gap-2">
<span className="flex items-center gap-1 text-label font-medium text-ink-faint">
<Sparkles className="h-3 w-3" />
{SUGGESTIONS_LABEL}
</span>
{SUGGESTIONS.map((s) => (
<button

Check notice on line 328 in src/components/studio/views/chat-subcomponents.tsx

View check run for this annotation

nexus-check / GitNexus

Changed symbol: InputBarProps

`InputBarProps` (Interface) is directly changed by this PR. PR-wide downstream impact: 2 direct dependent(s), 4 indirect. See the check summary for the impacted-file breakdown.
key={s.label}
onClick={() => {
onSend(s.query)
Expand Down
11 changes: 7 additions & 4 deletions src/components/studio/views/editor-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
} from '@/lib/studio/types'
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'
import { toast } from 'sonner'
import Markdown, { defaultUrlTransform } from 'react-markdown'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { sanitizeUrl } from '@/lib/security'
import {
ExternalLink,
} from 'lucide-react'
Expand Down Expand Up @@ -50,15 +51,15 @@
EditorModeSelector,
EditorStatusBar,
} from '../editor-hooks'

/** Preserve the reserved dks:// mention protocol (defaultUrlTransform strips it). */
/** Preserve the reserved dks:// mention protocol (sanitizeUrl strips non-standard schemes). */

Check notice on line 55 in src/components/studio/views/editor-view.tsx

View check run for this annotation

nexus-check / GitNexus

Changed symbol: mentionAwareUrlTransform

`mentionAwareUrlTransform` (Function) is directly changed by this PR. PR-wide downstream impact: 2 direct dependent(s), 4 indirect. See the check summary for the impacted-file breakdown.
const mentionAwareUrlTransform = (url: string): string =>
url.startsWith(MENTION_SCHEME) ? url : defaultUrlTransform(url)
url.startsWith(MENTION_SCHEME) ? url : sanitizeUrl(url)

const SERIF_FONT_STYLE: React.CSSProperties = {

Check notice on line 59 in src/components/studio/views/editor-view.tsx

View check run for this annotation

nexus-check / GitNexus

Changed symbol: SERIF_FONT_STYLE

`SERIF_FONT_STYLE` (Const) is directly changed by this PR. PR-wide downstream impact: 2 direct dependent(s), 4 indirect. See the check summary for the impacted-file breakdown.
fontFamily: 'var(--font-newsreader), Georgia, serif',
} as const

Check notice on line 61 in src/components/studio/views/editor-view.tsx

View check run for this annotation

nexus-check / GitNexus

Changed symbol: ADVANCED_METADATA_TITLE

`ADVANCED_METADATA_TITLE` (Const) is directly changed by this PR. PR-wide downstream impact: 2 direct dependent(s), 4 indirect. See the check summary for the impacted-file breakdown.

Check notice on line 62 in src/components/studio/views/editor-view.tsx

View check run for this annotation

nexus-check / GitNexus

Changed symbol: ADVANCED_METADATA_DESCRIPTION

`ADVANCED_METADATA_DESCRIPTION` (Const) is directly changed by this PR. PR-wide downstream impact: 2 direct dependent(s), 4 indirect. See the check summary for the impacted-file breakdown.
const ADVANCED_METADATA_TITLE = 'Metadata & source'
const ADVANCED_METADATA_DESCRIPTION = 'Optional context that helps you find and revisit this note later. Tags stay visible above for quick editing.'

Expand Down Expand Up @@ -116,17 +117,17 @@
}
}

/** Format commands: command name → editor transform applied to the selection. */
/** Supported toolbar/keyboard format commands (static allowlist). */
const FORMAT_COMMANDS = [
'bold',
'italic',
'h1',
'h2',
'bullet',
'ordered',
'quote',
'code',

Check notice on line 130 in src/components/studio/views/editor-view.tsx

View check run for this annotation

nexus-check / GitNexus

Changed symbol: FORMAT_COMMANDS

`FORMAT_COMMANDS` (Const) is directly changed by this PR. PR-wide downstream impact: 2 direct dependent(s), 4 indirect. See the check summary for the impacted-file breakdown.
'link',
] as const

Expand All @@ -134,135 +135,135 @@

/** Narrows a raw command string to a supported FormatCommand. */
const isFormatCommand = (command: string): command is FormatCommand =>
(FORMAT_COMMANDS as readonly string[]).includes(command)

const FORMAT_HANDLERS: Record<
FormatCommand,
(content: string, sel: MarkdownSelection) => MarkdownCommandResult
> = {
bold: (c, s) => applyBold(c, s),
italic: (c, s) => applyItalic(c, s),
h1: (c, s) => applyHeading(c, s, 1),
h2: (c, s) => applyHeading(c, s, 2),
bullet: (c, s) => applyBulletList(c, s),
ordered: (c, s) => applyOrderedList(c, s),
quote: (c, s) => applyQuote(c, s),
code: (c, s) => applyInlineCode(c, s),

Check notice on line 151 in src/components/studio/views/editor-view.tsx

View check run for this annotation

nexus-check / GitNexus

Changed symbol: FORMAT_HANDLERS

`FORMAT_HANDLERS` (Const) is directly changed by this PR. PR-wide downstream impact: 2 direct dependent(s), 4 indirect. See the check summary for the impacted-file breakdown.
link: (c, s) => applyLink(c, s),
}

/** Rich-text entity editor with markdown preview, claims panel, and draft persistence. */
export const EditorView = () => {
const entities = useStudioStore((s) => s.entities)
const editingEntityId = useStudioStore((s) => s.editingEntityId)
const commitEntities = useStudioStore((s) => s.commitEntities)
const finishEditing = useStudioStore((s) => s.finishEditing)
const navigateToView = useStudioStore((s) => s.navigateToView)
const claims = useStudioStore((s) => s.claims)
const addClaim = useStudioStore((s) => s.addClaim)
const updateClaim = useStudioStore((s) => s.updateClaim)
const deleteClaim = useStudioStore((s) => s.deleteClaim)
const textareaRef = useRef<HTMLTextAreaElement>(null)

const editing = useMemo(
() => entities.find((e) => e.id === editingEntityId) || null,
[entities, editingEntityId],
)

const entityClaims = useMemo(
() => (editingEntityId ? claims.filter((c) => c.entityId === editingEntityId) : []),
[claims, editingEntityId],
)

const [name, setName] = useState(editing?.name || '')
const [type, setType] = useState<AnyEntityType>(editing?.type ?? 'note')
const [content, setContent] = useState(editing?.content || '')
const [description, setDescription] = useState(editing?.description || '')
const [sourceUrl, setSourceUrl] = useState(editing?.sourceUrl || '')
const [tags, setTags] = useState<string[]>(editing?.tags || [])
const [showAdvanced, setShowAdvanced] = useState(false)
const [showTypeMenu, setShowTypeMenu] = useState(false)
const [editMode, setEditMode] = useState<'edit' | 'preview' | 'split'>('edit')
// Caret position driving the @mention trigger (updated on change/click/keys).
const [caret, setCaret] = useState(0)
const [mentionHighlight, setMentionHighlight] = useState(0)
// Trigger dismissed by Escape/blur. Bound to the content prefix at that
// offset so deleting the `@` and typing a fresh one at the same index
// reopens the picker (a NEW trigger), while continued query typing keeps
// the dismissal intact.
const [dismissedMention, setDismissedMention] = useState<{
start: number
prefix: string
} | null>(null)
const dismissMention = useCallback(
(start: number): void => {
setDismissedMention({ start, prefix: content.slice(0, start) })
},
[content],
)

const { draftStatus, draftIdRef } = useEditorDraft({
editing,
name,
content,
description,
type,
sourceUrl,
tags,
setName,
setContent,
setDescription,
setType,
setSourceUrl,
setTags,
})

useEffect(() => {
const mq = window.matchMedia('(max-width: 768px)')
const handleChange = (e: MediaQueryListEvent | MediaQueryList) => {
if (e.matches) {
setEditMode((prev) => prev === 'split' ? 'edit' : prev)
}
}
handleChange(mq)
mq.addEventListener('change', handleChange)
return () => { mq.removeEventListener('change', handleChange) }
}, [])

const wordCount = useMemo(
() => content.trim().split(/\s+/).filter(Boolean).length,
[content],
)
const charCount = content.length
const isDirty = editing
? editing.name !== name ||
editing.content !== content ||
editing.type !== type ||
editing.description !== description ||
(editing.sourceUrl || '') !== sourceUrl ||
JSON.stringify(editing.tags) !== JSON.stringify(tags)
: name.trim() !== '' || content.trim() !== ''

/**
* @mention entity linking (N3): the trigger derives from the live content
* and caret — typing '@' opens the picker, moving the caret into/out of a
* mention context opens/closes it, and the inserted token is a plain
* markdown link `[@Name](dks://entity/<id>)` (see src/lib/editor/mention.ts
* for the token convention and save-time link derivation).
*/
const mentionTrigger = useMemo<MentionTrigger>(
() => getMentionTrigger(content, caret),
[content, caret],
)
// A dismissal applies only while the content prefix at the trigger offset is
// unchanged — a deleted/re-typed `@` at the same index is a new trigger.
const mentionOpen =
mentionTrigger.active &&
(dismissedMention === null ||
dismissedMention.start !== mentionTrigger.start ||
dismissedMention.prefix !== content.slice(0, mentionTrigger.start))

const mentionCandidates = useMemo(() => {

Check notice on line 266 in src/components/studio/views/editor-view.tsx

View check run for this annotation

nexus-check / GitNexus

Changed symbol: query

`query` (Const) is directly changed by this PR. PR-wide downstream impact: 2 direct dependent(s), 4 indirect. See the check summary for the impacted-file breakdown.
if (!mentionTrigger.active) return []
const query = mentionTrigger.query.toLowerCase()
return entities
Expand Down Expand Up @@ -339,7 +340,7 @@
const handleSave = useCallback(() => {
if (!name.trim()) {
toast.error('Entity name cannot be empty')
return

Check notice on line 343 in src/components/studio/views/editor-view.tsx

View check run for this annotation

nexus-check / GitNexus

Changed symbol: entityId

`entityId` (Const) is directly changed by this PR. PR-wide downstream impact: 2 direct dependent(s), 4 indirect. See the check summary for the impacted-file breakdown.
}
const entityId = editing?.id ?? crypto.randomUUID()
const { mentionLinks, mentions } = extractMentionLinks(content, entities, entityId)
Expand Down Expand Up @@ -480,23 +481,25 @@
<div className="prose prose-sm dark:prose-invert max-w-none min-h-[420px] rounded-lg border border-border bg-background p-4">
<Markdown
urlTransform={mentionAwareUrlTransform}
remarkPlugins={[remarkGfm]}
components={{
a: ({ href, children }) => {
// Mention tokens render as styled chips (no navigation);
// anything else stays a normal external link.
if (href?.startsWith(MENTION_SCHEME)) {
const entityId = href.slice(MENTION_SCHEME.length)
return (
<span
data-mention-id={entityId}
className="mx-0.5 rounded-full bg-saffron-soft px-2 py-0.5 font-medium text-saffron-deep no-underline"
>
{children}
</span>
)
}

Check notice on line 499 in src/components/studio/views/editor-view.tsx

View check run for this annotation

nexus-check / GitNexus

Changed symbol: a

`a` (Function) is directly changed by this PR. PR-wide downstream impact: 2 direct dependent(s), 4 indirect. See the check summary for the impacted-file breakdown.
return <a href={href} target="_blank" rel="noreferrer">{children}</a>
const safeHref = typeof href === 'string' && href ? sanitizeUrl(href) : ''
if (!safeHref) return <span>{children}</span>
return <a href={safeHref} target="_blank" rel="noopener noreferrer">{children}</a>
},
}}
>
Expand Down
215 changes: 215 additions & 0 deletions src/components/studio/views/markdown-security.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import React, { type ReactNode } from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { MessageList } from './chat-subcomponents'
import type { ChatMessage, Entity } from '@/lib/studio/types'

vi.mock('framer-motion', () => ({
motion: {
div: ({ children, initial: _i, animate: _a, transition: _t, ...props }: { children?: ReactNode; [key: string]: unknown }) => (
<div {...(props as React.HTMLAttributes<HTMLDivElement>)}>{children}</div>
),
},
AnimatePresence: ({ children }: { children?: ReactNode }) => children,
}))

vi.mock('lucide-react', () => {
const Icon = (props: Record<string, unknown>) => React.createElement('span', { 'data-testid': 'icon', ...props })
return {
__esModule: true,
Send: Icon,
Sparkles: Icon,
Trash2: Icon,
Bot: Icon,
User: Icon,
Quote: Icon,
ChevronDown: Icon,
MessageSquare: Icon,
ExternalLink: Icon,
Tag: Icon,
Save: Icon,
X: Icon,
Plus: Icon,
AtSign: Icon,
Bold: Icon,
Italic: Icon,
Heading1: Icon,
Heading2: Icon,
List: Icon,
ListOrdered: Icon,
Code: Icon,
Link2: Icon,
Mic: Icon,
}
})

vi.mock('@/lib/utils', () => ({
cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '),
}))

vi.mock('../voice-input', () => ({
VoiceInput: () => <div data-testid="voice-input" />,
}))

vi.mock('../remote-cursors', () => ({
CursorTracker: ({ children }: { children?: ReactNode }) => children,
}))

vi.mock('./editor-toolbar', () => ({
EditorToolbar: () => <div data-testid="editor-toolbar" />,
}))

vi.mock('./editor-claims-panel', () => ({
ClaimsPanel: () => <div data-testid="claims-panel" />,
}))

vi.mock('./type-selector', () => ({
TypeSelector: () => <div data-testid="type-selector" />,
}))

vi.mock('sonner', () => ({
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
}))

Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})

let currentEditingEntity: Entity | null = null

vi.mock('@/lib/studio/store', () => ({
useStudioStore: (selector: (s: Record<string, unknown>) => unknown) => {
return selector({
entities: currentEditingEntity ? [currentEditingEntity] : [],
editingEntityId: currentEditingEntity?.id ?? null,
commitEntities: vi.fn(),
saveEntity: vi.fn(),
finishEditing: vi.fn(),
navigateToView: vi.fn(),
claims: [],
addClaim: vi.fn(),
updateClaim: vi.fn(),
deleteClaim: vi.fn(),
})
},
}))

import { EditorView } from './editor-view'

const assistantMsg = (content: string): ChatMessage => ({
id: 'msg-1',
role: 'assistant',
content,
timestamp: '2026-08-14T00:00:00.000Z',
})

describe('Chat MessageList Markdown Link Security', () => {
const baseProps = {
reducedMotion: false,
showCitations: null,
onToggleCitations: vi.fn(),
onCitationClick: vi.fn(),
}

it('renders safe HTTP and HTTPS links with target="_blank" and rel="noopener noreferrer"', () => {
const chat = [assistantMsg('[Open Example](https://example.com)')]
render(<MessageList chat={chat} {...baseProps} />)

const link = screen.getByRole('link', { name: 'Open Example' })
expect(link).toHaveAttribute('href', 'https://example.com')
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
})

it('sanitizes dangerous javascript: links into plain non-clickable text', () => {
const chat = [assistantMsg('[Click Me](javascript:alert(1))')]
render(<MessageList chat={chat} {...baseProps} />)

expect(screen.queryByRole('link')).toBeNull()
expect(screen.getByText('Click Me')).toBeDefined()
})

it('sanitizes dangerous data: URIs into plain non-clickable text', () => {
const chat = [assistantMsg('[Malicious Data](data:text/html,<script>alert(1)</script>)')]
render(<MessageList chat={chat} {...baseProps} />)

expect(screen.queryByRole('link')).toBeNull()
expect(screen.getByText('Malicious Data')).toBeDefined()
})

it('sanitizes protocol-relative links (//evil.com) into plain non-clickable text', () => {
const chat = [assistantMsg('[Protocol Relative](//evil.com/phish)')]
render(<MessageList chat={chat} {...baseProps} />)

expect(screen.queryByRole('link')).toBeNull()
expect(screen.getByText('Protocol Relative')).toBeDefined()
})

it('renders safe image URLs and strips dangerous image sources', () => {
const chat = [
assistantMsg('![Safe Image](https://example.com/photo.png) ![Unsafe Image](javascript:alert(1))'),
]
const { container } = render(<MessageList chat={chat} {...baseProps} />)

const imgs = container.querySelectorAll('img')
expect(imgs).toHaveLength(1)
expect(imgs[0]).toHaveAttribute('src', 'https://example.com/photo.png')
expect(imgs[0]).toHaveAttribute('alt', 'Safe Image')
})
})

describe('EditorView Preview Markdown Link Security', () => {
it('renders external markdown links with target="_blank" and rel="noopener noreferrer"', () => {
currentEditingEntity = {
id: 'ent-sec-1',
name: 'Alice',
type: 'person',
description: 'A person',
content: '[External Link](https://example.com/doc)',
tags: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
links: [],
}
render(<EditorView />)

const previewBtn = screen.getByRole('radio', { name: 'Preview' })
fireEvent.click(previewBtn)

const link = screen.getByRole('link', { name: 'External Link' })
expect(link).toHaveAttribute('href', 'https://example.com/doc')
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
})

it('sanitizes dangerous links in editor preview to non-clickable text', () => {
currentEditingEntity = {
id: 'ent-sec-2',
name: 'Bob',
type: 'concept',
description: 'A concept',
content: '[Exploit](javascript:alert(document.cookie))',
tags: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
links: [],
}
render(<EditorView />)

const previewBtn = screen.getByRole('radio', { name: 'Preview' })
fireEvent.click(previewBtn)

expect(screen.queryByRole('link')).toBeNull()
expect(screen.getByText('Exploit')).toBeDefined()
})
})
Loading