From ff6d940ad92719c588e770eeaed2879348176abb Mon Sep 17 00:00:00 2001
From: mahula
Date: Thu, 15 Jan 2026 13:29:46 +0100
Subject: [PATCH] test(tiptap): add unit tests for markdown preprocessing and
XSS prevention
- preprocessMarkdown.spec.ts: 76 tests covering URL conversion, video links,
hashtags, item mentions, markdown removal, and truncation
- simpleMarkdownToHtml.spec.ts: 21 tests for HTML conversion and formatting
- xss.spec.ts: 22 security tests covering script injection, event handlers,
javascript URLs, tag restoration bypass, and attribute injection
---
.../TipTap/utils/preprocessMarkdown.spec.ts | 501 ++++++++++++++++++
.../TipTap/utils/simpleMarkdownToHtml.spec.ts | 162 ++++++
lib/src/Components/TipTap/utils/xss.spec.ts | 200 +++++++
3 files changed, 863 insertions(+)
create mode 100644 lib/src/Components/TipTap/utils/preprocessMarkdown.spec.ts
create mode 100644 lib/src/Components/TipTap/utils/simpleMarkdownToHtml.spec.ts
create mode 100644 lib/src/Components/TipTap/utils/xss.spec.ts
diff --git a/lib/src/Components/TipTap/utils/preprocessMarkdown.spec.ts b/lib/src/Components/TipTap/utils/preprocessMarkdown.spec.ts
new file mode 100644
index 00000000..4beeaebb
--- /dev/null
+++ b/lib/src/Components/TipTap/utils/preprocessMarkdown.spec.ts
@@ -0,0 +1,501 @@
+import { describe, it, expect } from 'vitest'
+
+import {
+ preprocessMarkdown,
+ preprocessVideoLinks,
+ preprocessHashtags,
+ preprocessItemMentions,
+ removeMarkdownSyntax,
+ truncateMarkdown,
+} from './preprocessMarkdown'
+
+// ============================================================================
+// convertNakedUrls (tested via preprocessMarkdown)
+// ============================================================================
+describe('convertNakedUrls (via preprocessMarkdown)', () => {
+ describe('Happy Path', () => {
+ it('converts a naked URL to markdown link', () => {
+ const result = preprocessMarkdown('Check https://example.com out')
+ expect(result).toContain('[example.com](https://example.com)')
+ })
+
+ it('removes www from display text', () => {
+ const result = preprocessMarkdown('Visit https://www.example.com')
+ expect(result).toContain('[example.com](https://www.example.com)')
+ })
+
+ it('converts multiple naked URLs', () => {
+ const result = preprocessMarkdown('See https://a.com and https://b.com')
+ expect(result).toContain('[a.com](https://a.com)')
+ expect(result).toContain('[b.com](https://b.com)')
+ })
+
+ it('preserves query parameters in URL', () => {
+ const result = preprocessMarkdown('Link https://example.com?a=1&b=2 here')
+ expect(result).toContain('](https://example.com?a=1&b=2)')
+ })
+
+ it('converts http URLs', () => {
+ const result = preprocessMarkdown('Old http://example.com link')
+ expect(result).toContain('[example.com](http://example.com)')
+ })
+ })
+
+ describe('Skip - Already Linked', () => {
+ it('does not convert URL already in markdown link', () => {
+ const input = '[my link](https://example.com)'
+ const result = preprocessMarkdown(input)
+ // Should not double-wrap
+ expect(result).toBe(input)
+ })
+
+ it('does not convert URL in autolink syntax', () => {
+ const input = ''
+ const result = preprocessMarkdown(input)
+ // Autolinks are preserved (may be converted to video embeds if matching)
+ expect(result).not.toContain('](https://example.com)')
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('handles URL at end of sentence with period', () => {
+ const result = preprocessMarkdown('Visit https://example.com.')
+ // Note: Current implementation includes trailing period in URL
+ // This documents actual behavior - may be improved later
+ expect(result).toContain('[example.com.](https://example.com.)')
+ })
+
+ it('handles URL in parentheses', () => {
+ const result = preprocessMarkdown('(https://example.com)')
+ expect(result).toContain('[example.com](https://example.com)')
+ })
+
+ it('handles URL at line start', () => {
+ const result = preprocessMarkdown('https://example.com is great')
+ expect(result).toContain('[example.com](https://example.com)')
+ })
+
+ it('handles URL with path', () => {
+ const result = preprocessMarkdown('See https://example.com/path/to/page')
+ expect(result).toContain('[example.com/path/to/page](https://example.com/path/to/page)')
+ })
+ })
+})
+
+// ============================================================================
+// preprocessVideoLinks
+// ============================================================================
+describe('preprocessVideoLinks', () => {
+ describe('YouTube - Happy Path', () => {
+ it('converts YouTube autolink (standard)', () => {
+ const result = preprocessVideoLinks('')
+ expect(result).toBe('')
+ })
+
+ it('converts YouTube autolink (short URL)', () => {
+ const result = preprocessVideoLinks('')
+ expect(result).toBe('')
+ })
+
+ it('converts YouTube markdown link (standard)', () => {
+ const result = preprocessVideoLinks('[Video](https://youtube.com/watch?v=abc123)')
+ expect(result).toBe('')
+ })
+
+ it('converts YouTube markdown link (short URL)', () => {
+ const result = preprocessVideoLinks('[Watch](https://youtu.be/xyz789)')
+ expect(result).toBe('')
+ })
+
+ it('handles YouTube without www', () => {
+ const result = preprocessVideoLinks('')
+ expect(result).toBe('')
+ })
+ })
+
+ describe('YouTube - Edge Cases', () => {
+ it('extracts only video-id, ignores extra params', () => {
+ const result = preprocessVideoLinks('')
+ expect(result).toBe('')
+ })
+
+ it('handles http (non-https) YouTube links', () => {
+ const result = preprocessVideoLinks('')
+ expect(result).toBe('')
+ })
+
+ it('handles short URL with query params', () => {
+ const result = preprocessVideoLinks('')
+ expect(result).toBe('')
+ })
+ })
+
+ describe('Rumble - Happy Path', () => {
+ it('converts Rumble autolink', () => {
+ const result = preprocessVideoLinks('')
+ expect(result).toBe('')
+ })
+
+ it('converts Rumble markdown link', () => {
+ const result = preprocessVideoLinks('[Rumble Video](https://rumble.com/embed/xyz123)')
+ expect(result).toBe('')
+ })
+ })
+
+ describe('Non-Video Links', () => {
+ it('does not convert non-video autolinks', () => {
+ const input = ''
+ expect(preprocessVideoLinks(input)).toBe(input)
+ })
+
+ it('does not convert non-video markdown links', () => {
+ const input = '[Example](https://example.com)'
+ expect(preprocessVideoLinks(input)).toBe(input)
+ })
+ })
+
+ describe('Mixed Content', () => {
+ it('converts video in mixed content', () => {
+ const result = preprocessVideoLinks('Check this: and more text')
+ expect(result).toContain('')
+ expect(result).toContain('Check this:')
+ expect(result).toContain('and more text')
+ })
+
+ it('converts multiple videos', () => {
+ const result = preprocessVideoLinks(' and ')
+ expect(result).toContain('video-id="a"')
+ expect(result).toContain('video-id="b"')
+ })
+ })
+})
+
+// ============================================================================
+// preprocessHashtags
+// ============================================================================
+describe('preprocessHashtags', () => {
+ describe('Happy Path', () => {
+ it('converts simple hashtag', () => {
+ const result = preprocessHashtags('Hello #world')
+ expect(result).toBe('Hello #world')
+ })
+
+ it('converts multiple hashtags', () => {
+ const result = preprocessHashtags('#one #two #three')
+ expect(result).toContain('data-label="one"')
+ expect(result).toContain('data-label="two"')
+ expect(result).toContain('data-label="three"')
+ })
+
+ it('converts hashtag with numbers', () => {
+ const result = preprocessHashtags('#test123')
+ expect(result).toContain('data-label="test123"')
+ })
+
+ it('converts hashtag with underscore', () => {
+ const result = preprocessHashtags('#my_tag')
+ expect(result).toContain('data-label="my_tag"')
+ })
+
+ it('converts hashtag with hyphen', () => {
+ const result = preprocessHashtags('#my-tag')
+ expect(result).toContain('data-label="my-tag"')
+ })
+
+ it('converts hashtag with German umlauts', () => {
+ const result = preprocessHashtags('#München')
+ expect(result).toContain('data-label="München"')
+ })
+
+ it('converts hashtag with French accents', () => {
+ const result = preprocessHashtags('#café')
+ expect(result).toContain('data-label="café"')
+ })
+ })
+
+ describe('Skip - Inside Links', () => {
+ it('does not convert hashtag in link text', () => {
+ const input = '[#tag](#anchor)'
+ const result = preprocessHashtags(input)
+ expect(result).not.toContain('data-hashtag')
+ })
+
+ it('does not convert hashtag in link URL', () => {
+ const input = '[section](#section-heading)'
+ const result = preprocessHashtags(input)
+ expect(result).not.toContain('data-hashtag')
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('handles concurrent hashtags without space', () => {
+ const result = preprocessHashtags('#tag1#tag2')
+ // First should convert, second might not (depends on lookbehind)
+ expect(result).toContain('data-label="tag1"')
+ })
+
+ it('does not convert lone # symbol', () => {
+ const result = preprocessHashtags('Just #')
+ expect(result).not.toContain('data-hashtag')
+ expect(result).toBe('Just #')
+ })
+
+ it('handles hashtag at start of text', () => {
+ const result = preprocessHashtags('#first thing')
+ expect(result).toContain('data-label="first"')
+ })
+
+ it('handles hashtag at end of text', () => {
+ const result = preprocessHashtags('last is #final')
+ expect(result).toContain('data-label="final"')
+ })
+ })
+})
+
+// ============================================================================
+// preprocessItemMentions
+// ============================================================================
+describe('preprocessItemMentions', () => {
+ describe('Happy Path', () => {
+ it('converts standard mention format', () => {
+ const result = preprocessItemMentions('Hello [@Alice](/item/abc-123)')
+ expect(result).toBe(
+ 'Hello @Alice',
+ )
+ })
+
+ it('converts mention with layer (legacy format)', () => {
+ const result = preprocessItemMentions('[@Bob](/item/people/def-456)')
+ expect(result).toContain('data-id="def-456"')
+ expect(result).toContain('data-label="Bob"')
+ })
+
+ it('converts relative path format with leading slash', () => {
+ // Note: Relative paths without leading slash are not supported by regex
+ // The regex requires /item/ or item/ with UUID pattern (hex + dashes only)
+ const result = preprocessItemMentions('[@Name](/item/abc-def-123)')
+ expect(result).toContain('data-id="abc-def-123"')
+ })
+
+ it('converts multiple mentions', () => {
+ const result = preprocessItemMentions('[@A](/item/1) and [@B](/item/2)')
+ expect(result).toContain('data-id="1"')
+ expect(result).toContain('data-id="2"')
+ })
+
+ it('handles UUID with uppercase letters', () => {
+ const result = preprocessItemMentions('[@Name](/item/ABC-DEF-123)')
+ expect(result).toContain('data-id="ABC-DEF-123"')
+ })
+
+ it('handles label with spaces', () => {
+ // Note: UUID must be hex chars + dashes only (no letters like 'uuid')
+ const result = preprocessItemMentions('[@Max Müller](/item/abc-def-123)')
+ expect(result).toContain('data-label="Max Müller"')
+ })
+ })
+
+ describe('Skip - Non-Matching', () => {
+ it('does not convert non-item links', () => {
+ const input = '[@Name](/other/path)'
+ const result = preprocessItemMentions(input)
+ expect(result).toBe(input)
+ })
+
+ it('does not convert regular links (no @)', () => {
+ const input = '[Name](/item/123)'
+ const result = preprocessItemMentions(input)
+ expect(result).toBe(input)
+ })
+ })
+})
+
+
+// ============================================================================
+// removeMarkdownSyntax
+// ============================================================================
+describe('removeMarkdownSyntax', () => {
+ describe('Happy Path', () => {
+ it('removes bold syntax', () => {
+ expect(removeMarkdownSyntax('**bold**')).toBe('bold')
+ })
+
+ it('removes italic syntax (asterisk)', () => {
+ expect(removeMarkdownSyntax('*italic*')).toBe('italic')
+ })
+
+ it('removes italic syntax (underscore)', () => {
+ expect(removeMarkdownSyntax('_italic_')).toBe('italic')
+ })
+
+ it('removes headers', () => {
+ expect(removeMarkdownSyntax('# Heading')).toBe('Heading')
+ expect(removeMarkdownSyntax('## Subheading')).toBe('Subheading')
+ })
+
+ it('removes links, keeps text', () => {
+ expect(removeMarkdownSyntax('[text](https://example.com)')).toBe('text')
+ })
+
+ it('removes images completely', () => {
+ expect(removeMarkdownSyntax('')).toBe('')
+ })
+
+ it('removes inline code', () => {
+ expect(removeMarkdownSyntax('`code`')).toBe('code')
+ })
+
+ it('removes blockquotes', () => {
+ expect(removeMarkdownSyntax('> quote')).toBe('quote')
+ })
+ })
+
+ describe('Preserve Special Elements', () => {
+ it('preserves @mentions', () => {
+ const result = removeMarkdownSyntax('Hello [@Alice](/item/123)')
+ expect(result).toContain('[@Alice](/item/123)')
+ })
+
+ it('preserves hashtags', () => {
+ const result = removeMarkdownSyntax('Hello #world')
+ expect(result).toContain('#world')
+ })
+ })
+})
+
+// ============================================================================
+// truncateMarkdown
+// ============================================================================
+describe('truncateMarkdown', () => {
+ describe('Happy Path', () => {
+ it('returns unchanged if under limit', () => {
+ expect(truncateMarkdown('Short text', 100)).toBe('Short text')
+ })
+
+ it('truncates and adds ellipsis if over limit', () => {
+ const text = 'A'.repeat(150)
+ const result = truncateMarkdown(text, 100)
+ expect(result).toHaveLength(103) // 100 chars + '...'
+ expect(result.endsWith('...')).toBe(true)
+ })
+
+ it('respects exact limit', () => {
+ const result = truncateMarkdown('Hello World', 5)
+ expect(result).toBe('Hello...')
+ })
+ })
+
+ describe('Atomic Token Preservation', () => {
+ it('preserves complete hashtag', () => {
+ const result = truncateMarkdown('A'.repeat(95) + ' #tag', 100)
+ // Should either include complete #tag or cut before it
+ if (result.includes('#')) {
+ expect(result).toContain('#tag')
+ }
+ })
+
+ it('preserves complete mention', () => {
+ const result = truncateMarkdown('A'.repeat(90) + ' [@Alice](/item/123)', 100)
+ // Should either include complete mention or cut before it
+ if (result.includes('@')) {
+ expect(result).toContain('[@Alice](/item/123)')
+ }
+ })
+
+ it('preserves complete link', () => {
+ const result = truncateMarkdown('See [link](url) more text here', 8)
+ // Visible text "See link" is 8 chars
+ expect(result).toContain('[link](url)')
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('does not count newlines toward limit', () => {
+ const result = truncateMarkdown('Line1\n\nLine2', 10)
+ expect(result).toContain('Line1')
+ expect(result).toContain('Line2')
+ })
+
+ it('handles empty text', () => {
+ expect(truncateMarkdown('', 100)).toBe('')
+ })
+
+ it('handles limit of 0', () => {
+ expect(truncateMarkdown('Text', 0)).toBe('...')
+ })
+
+ it('handles negative limit gracefully', () => {
+ // Should not throw
+ expect(() => truncateMarkdown('Text', -1)).not.toThrow()
+ })
+ })
+})
+
+// ============================================================================
+// preprocessMarkdown (Full Pipeline)
+// ============================================================================
+describe('preprocessMarkdown', () => {
+ describe('Happy Path', () => {
+ it('processes complete content with all features', () => {
+ const input = 'Check https://example.com #tag [@Alice](/item/123)'
+ const result = preprocessMarkdown(input)
+
+ // URL converted
+ expect(result).toContain('[example.com](https://example.com)')
+ // Hashtag converted
+ expect(result).toContain('data-hashtag')
+ // Mention converted
+ expect(result).toContain('data-item-mention')
+ })
+
+ it('processes video links', () => {
+ const result = preprocessMarkdown('')
+ expect(result).toContain('video-embed')
+ })
+
+ it('converts email addresses to mailto links', () => {
+ const result = preprocessMarkdown('Contact test@example.com')
+ expect(result).toContain('[test@example.com](mailto:test@example.com)')
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('handles empty string', () => {
+ expect(preprocessMarkdown('')).toBe('')
+ })
+
+ it('handles null input', () => {
+ // @ts-expect-error testing null input
+ expect(preprocessMarkdown(null)).toBe('')
+ })
+
+ it('handles undefined input', () => {
+ // @ts-expect-error testing undefined input
+ expect(preprocessMarkdown(undefined)).toBe('')
+ })
+
+ it('preserves whitespace', () => {
+ expect(preprocessMarkdown(' ')).toBe(' ')
+ })
+
+ it('handles very long text without timeout', () => {
+ const longText = 'A'.repeat(10000)
+ const start = performance.now()
+ preprocessMarkdown(longText)
+ const elapsed = performance.now() - start
+ expect(elapsed).toBeLessThan(1000) // Should complete in <1s
+ })
+ })
+
+ describe('Error Handling', () => {
+ it('handles malformed markdown without throwing', () => {
+ expect(() => preprocessMarkdown('[unclosed link')).not.toThrow()
+ expect(() => preprocessMarkdown('**unclosed bold')).not.toThrow()
+ })
+
+ it('handles malformed URLs without throwing', () => {
+ expect(() => preprocessMarkdown('http:/broken')).not.toThrow()
+ })
+ })
+})
+
diff --git a/lib/src/Components/TipTap/utils/simpleMarkdownToHtml.spec.ts b/lib/src/Components/TipTap/utils/simpleMarkdownToHtml.spec.ts
new file mode 100644
index 00000000..f7e32eae
--- /dev/null
+++ b/lib/src/Components/TipTap/utils/simpleMarkdownToHtml.spec.ts
@@ -0,0 +1,162 @@
+import { describe, it, expect } from 'vitest'
+
+import { simpleMarkdownToHtml } from './simpleMarkdownToHtml'
+
+import type { Item } from '#types/Item'
+import type { Tag } from '#types/Tag'
+
+// Test fixtures
+const mockTags: Tag[] = [
+ { id: '1', name: 'nature', color: '#22c55e' },
+ { id: '2', name: 'tech', color: '#3b82f6' },
+]
+
+const mockItems: Item[] = [
+ { id: 'abc-123', name: 'Alice', color: '#ef4444' } as Item,
+ { id: 'def-456', name: 'Bob', color: '#8b5cf6' } as Item,
+]
+
+const mockGetItemColor = (item: Item | undefined, fallback = '#3b82f6') => item?.color ?? fallback
+
+// ============================================================================
+// Basic Markdown Formatting
+// ============================================================================
+describe('simpleMarkdownToHtml', () => {
+ describe('Basic Formatting', () => {
+ it('converts bold with double asterisks', () => {
+ const result = simpleMarkdownToHtml('**bold**', [])
+ expect(result).toContain('bold')
+ })
+
+ it('converts bold with double underscores', () => {
+ const result = simpleMarkdownToHtml('__bold__', [])
+ expect(result).toContain('bold')
+ })
+
+ it('converts italic with single asterisk', () => {
+ const result = simpleMarkdownToHtml('*italic*', [])
+ expect(result).toContain('italic')
+ })
+
+ it('converts italic with single underscore', () => {
+ const result = simpleMarkdownToHtml('_italic_', [])
+ expect(result).toContain('italic')
+ })
+
+ it('converts inline code', () => {
+ const result = simpleMarkdownToHtml('use `code` here', [])
+ expect(result).toContain('code')
+ })
+
+ it('converts headers H1-H6', () => {
+ expect(simpleMarkdownToHtml('# Title', [])).toContain('Title
')
+ expect(simpleMarkdownToHtml('## Subtitle', [])).toContain('Subtitle
')
+ expect(simpleMarkdownToHtml('### Section', [])).toContain('Section
')
+ expect(simpleMarkdownToHtml('###### Deep', [])).toContain('Deep
')
+ })
+
+ it('does not convert blockquotes (> is HTML-escaped before regex)', () => {
+ // Note: The current implementation HTML-escapes > before the blockquote regex runs
+ // This documents actual behavior - blockquotes are not supported in simpleMarkdownToHtml
+ const result = simpleMarkdownToHtml('> quoted text', [])
+ expect(result).toContain('>')
+ })
+ })
+
+ describe('Links', () => {
+ it('converts external links with target blank', () => {
+ const result = simpleMarkdownToHtml('[Example](https://example.com)', [])
+ expect(result).toContain('href="https://example.com"')
+ expect(result).toContain('target="_blank"')
+ expect(result).toContain('rel="noopener noreferrer"')
+ })
+
+ it('converts internal links without target blank', () => {
+ const result = simpleMarkdownToHtml('[Profile](/profile)', [])
+ expect(result).toContain('href="/profile"')
+ expect(result).not.toContain('target="_blank"')
+ })
+ })
+
+ describe('Line Breaks and Paragraphs', () => {
+ it('converts double newline to paragraph break', () => {
+ const result = simpleMarkdownToHtml('Para1\n\nPara2', [])
+ expect(result).toContain('
')
+ })
+
+ it('converts single newline to br', () => {
+ const result = simpleMarkdownToHtml('Line1\nLine2', [])
+ expect(result).toContain('
')
+ })
+
+ it('wraps content in paragraph tags', () => {
+ const result = simpleMarkdownToHtml('Hello world', [])
+ expect(result).toMatch(/^
.*<\/p>$/)
+ })
+ })
+
+ describe('Video Embeds', () => {
+ it('converts YouTube video-embed to iframe', () => {
+ const input = ''
+ const result = simpleMarkdownToHtml(input, [])
+ expect(result).toContain('iframe')
+ expect(result).toContain('youtube-nocookie.com/embed/abc123')
+ })
+
+ it('converts Rumble video-embed to iframe', () => {
+ const input = ''
+ const result = simpleMarkdownToHtml(input, [])
+ expect(result).toContain('iframe')
+ expect(result).toContain('rumble.com/embed/xyz789')
+ })
+ })
+
+ describe('Hashtags', () => {
+ it('renders hashtag with known tag color', () => {
+ const input = '#nature'
+ const result = simpleMarkdownToHtml(input, mockTags)
+ expect(result).toContain('style="color: #22c55e')
+ expect(result).toContain('class="hashtag"')
+ })
+
+ it('renders hashtag with inherit color for unknown tag', () => {
+ const input = '#unknown'
+ const result = simpleMarkdownToHtml(input, [])
+ expect(result).toContain('style="color: inherit')
+ })
+ })
+
+ describe('Item Mentions', () => {
+ it('renders mention as link with item color', () => {
+ const input = '@Alice'
+ const result = simpleMarkdownToHtml(input, [], { items: mockItems, getItemColor: mockGetItemColor })
+ expect(result).toContain('href="/item/abc-123"')
+ expect(result).toContain('class="item-mention"')
+ expect(result).toContain('style="color: #ef4444')
+ })
+
+ it('renders mention with fallback color for unknown item', () => {
+ const input = '@Unknown'
+ const result = simpleMarkdownToHtml(input, [], { items: [], getItemColor: mockGetItemColor })
+ // Fallback uses CSS variable with hex fallback
+ expect(result).toContain('style="color: var(--color-primary, #3b82f6)')
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('returns empty string for empty input', () => {
+ expect(simpleMarkdownToHtml('', [])).toBe('')
+ })
+
+ it('returns empty string for null input', () => {
+ // @ts-expect-error testing null input
+ expect(simpleMarkdownToHtml(null, [])).toBe('')
+ })
+
+ it('handles consecutive newlines without excessive empty elements', () => {
+ const result = simpleMarkdownToHtml('\n\n\n\n', [])
+ expect(result).not.toContain('
')
+ })
+ })
+})
+
diff --git a/lib/src/Components/TipTap/utils/xss.spec.ts b/lib/src/Components/TipTap/utils/xss.spec.ts
new file mode 100644
index 00000000..88fb43b4
--- /dev/null
+++ b/lib/src/Components/TipTap/utils/xss.spec.ts
@@ -0,0 +1,200 @@
+/**
+ * XSS Security Test Suite
+ *
+ * Tests for Cross-Site Scripting (XSS) prevention in simpleMarkdownToHtml.
+ * These tests verify that malicious input is properly escaped or sanitized.
+ */
+import { describe, it, expect } from 'vitest'
+
+import { simpleMarkdownToHtml } from './simpleMarkdownToHtml'
+
+// ============================================================================
+// XSS Attack Vectors
+// ============================================================================
+const XSS_VECTORS = {
+ // Basic script injection
+ scriptTag: '',
+ scriptTagUppercase: '',
+ scriptTagMixed: '',
+
+ // Event handlers
+ imgOnerror: '
',
+ svgOnload: '