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('![alt](image.png)')).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: '', + bodyOnload: '', + divOnmouseover: '
hover
', + + // JavaScript URLs + jsHref: 'click', + jsHrefEncoded: 'click', + + // Data URLs + dataUrl: 'click', + + // Style injection + styleExpression: '
', + + // Object/embed tags + objectTag: '', + embedTag: '', +} + +describe('XSS Prevention - simpleMarkdownToHtml', () => { + describe('Script Tag Injection', () => { + it('escapes basic script tags', () => { + const result = simpleMarkdownToHtml(XSS_VECTORS.scriptTag, []) + expect(result).not.toContain(' { + const result = simpleMarkdownToHtml(XSS_VECTORS.scriptTagUppercase, []) + expect(result).not.toContain(' { + const result = simpleMarkdownToHtml(XSS_VECTORS.scriptTagMixed, []) + expect(result).not.toContain(' { + // Note: Event handlers in raw HTML are escaped as text (angle brackets escaped) + // The handler text remains but is not executable because the tag is escaped + it('escapes img tag making onerror non-executable', () => { + const result = simpleMarkdownToHtml(XSS_VECTORS.imgOnerror, []) + // Tag is escaped, so it renders as text, not as HTML + expect(result).toContain('<img') + }) + + it('escapes svg tag making onload non-executable', () => { + const result = simpleMarkdownToHtml(XSS_VECTORS.svgOnload, []) + expect(result).toContain('<svg') + }) + + it('escapes body tag making onload non-executable', () => { + const result = simpleMarkdownToHtml(XSS_VECTORS.bodyOnload, []) + expect(result).toContain('<body') + }) + + it('escapes div tag making onmouseover non-executable', () => { + const result = simpleMarkdownToHtml(XSS_VECTORS.divOnmouseover, []) + expect(result).toContain('<div') + }) + }) + + describe('JavaScript URL Injection', () => { + it('escapes javascript: URLs in raw HTML', () => { + const result = simpleMarkdownToHtml(XSS_VECTORS.jsHref, []) + // The raw HTML should be escaped, not rendered + expect(result).toContain('<a') + }) + + it('sanitizes javascript: URLs in markdown links', () => { + const result = simpleMarkdownToHtml('[click](javascript:alert(1))', []) + // javascript: URL should be replaced with '#' + expect(result).toContain('href="#"') + expect(result).not.toContain('javascript:') + }) + + it('sanitizes data: URLs in markdown links', () => { + const result = simpleMarkdownToHtml('[click](data:text/html,)', []) + expect(result).toContain('href="#"') + expect(result).not.toContain('data:') + }) + + it('allows safe http: and https: URLs', () => { + const httpResult = simpleMarkdownToHtml('[safe](http://example.com)', []) + const httpsResult = simpleMarkdownToHtml('[safe](https://example.com)', []) + expect(httpResult).toContain('href="http://example.com"') + expect(httpsResult).toContain('href="https://example.com"') + }) + + it('allows relative URLs', () => { + const result = simpleMarkdownToHtml('[profile](/user/123)', []) + expect(result).toContain('href="/user/123"') + }) + }) + + describe('Tag Restoration Security', () => { + // The tag restoration mechanism now uses strict patterns that only match + // exactly formatted tags, preventing injection of malicious attributes. + + it('blocks span tags with malicious onclick', () => { + const malicious = '#tag' + const result = simpleMarkdownToHtml(malicious, []) + // Malformed tag should remain escaped + expect(result).not.toContain(' { + const malicious = '' + const result = simpleMarkdownToHtml(malicious, []) + // Malformed tag should remain escaped + expect(result).not.toContain(' { + // Properly formatted hashtag span + const valid = '#nature' + const result = simpleMarkdownToHtml(valid, []) + expect(result).toContain('class="hashtag"') + }) + + it('only restores properly formatted video-embed tags', () => { + // Properly formatted video embed + const valid = '' + const result = simpleMarkdownToHtml(valid, []) + expect(result).toContain('iframe') + expect(result).toContain('youtube-nocookie.com/embed/abc123') + }) + }) + + describe('Attribute Injection Prevention', () => { + it('blocks extra attributes in hashtag spans (tag remains escaped)', () => { + // Attempt to inject onclick via extra attribute + const malicious = '#x' + const result = simpleMarkdownToHtml(malicious, []) + // The tag should remain fully escaped, not restored as HTML + // The key security property is that the opening tag stays escaped (<span) + expect(result).toContain('<span') + // It should NOT contain an unescaped ]*onclick/i) + }) + + it('blocks javascript: in hashtag labels', () => { + const malicious = '#tag' + const result = simpleMarkdownToHtml(malicious, []) + // Tag should remain escaped due to dangerous content in label + expect(result).toContain('<span') + }) + }) + + describe('HTML Entity Handling', () => { + it('preserves already-escaped content', () => { + const result = simpleMarkdownToHtml('& < >', []) + // Double-escaping - & becomes &amp; + expect(result).toContain('&amp;') + }) + + it('escapes angle brackets', () => { + const result = simpleMarkdownToHtml('1 < 2 > 0', []) + expect(result).toContain('<') + expect(result).toContain('>') + }) + }) + + describe('Edge Cases', () => { + it('handles nested script attempts', () => { + const result = simpleMarkdownToHtml('</script>', []) + expect(result).not.toContain(' { + const result = simpleMarkdownToHtml('alert(1)', []) + expect(result).not.toContain('