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
This commit is contained in:
mahula 2026-01-15 13:29:46 +01:00
parent 783a205c58
commit ff6d940ad9
3 changed files with 863 additions and 0 deletions

View File

@ -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 = '<https://example.com>'
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('<https://www.youtube.com/watch?v=abc123def45>')
expect(result).toBe('<video-embed provider="youtube" video-id="abc123def45"></video-embed>')
})
it('converts YouTube autolink (short URL)', () => {
const result = preprocessVideoLinks('<https://youtu.be/abc123def45>')
expect(result).toBe('<video-embed provider="youtube" video-id="abc123def45"></video-embed>')
})
it('converts YouTube markdown link (standard)', () => {
const result = preprocessVideoLinks('[Video](https://youtube.com/watch?v=abc123)')
expect(result).toBe('<video-embed provider="youtube" video-id="abc123"></video-embed>')
})
it('converts YouTube markdown link (short URL)', () => {
const result = preprocessVideoLinks('[Watch](https://youtu.be/xyz789)')
expect(result).toBe('<video-embed provider="youtube" video-id="xyz789"></video-embed>')
})
it('handles YouTube without www', () => {
const result = preprocessVideoLinks('<https://youtube.com/watch?v=test123>')
expect(result).toBe('<video-embed provider="youtube" video-id="test123"></video-embed>')
})
})
describe('YouTube - Edge Cases', () => {
it('extracts only video-id, ignores extra params', () => {
const result = preprocessVideoLinks('<https://youtube.com/watch?v=abc123&t=120&list=xyz>')
expect(result).toBe('<video-embed provider="youtube" video-id="abc123"></video-embed>')
})
it('handles http (non-https) YouTube links', () => {
const result = preprocessVideoLinks('<http://youtube.com/watch?v=test>')
expect(result).toBe('<video-embed provider="youtube" video-id="test"></video-embed>')
})
it('handles short URL with query params', () => {
const result = preprocessVideoLinks('<https://youtu.be/abc?t=30>')
expect(result).toBe('<video-embed provider="youtube" video-id="abc"></video-embed>')
})
})
describe('Rumble - Happy Path', () => {
it('converts Rumble autolink', () => {
const result = preprocessVideoLinks('<https://rumble.com/embed/v1abc>')
expect(result).toBe('<video-embed provider="rumble" video-id="v1abc"></video-embed>')
})
it('converts Rumble markdown link', () => {
const result = preprocessVideoLinks('[Rumble Video](https://rumble.com/embed/xyz123)')
expect(result).toBe('<video-embed provider="rumble" video-id="xyz123"></video-embed>')
})
})
describe('Non-Video Links', () => {
it('does not convert non-video autolinks', () => {
const input = '<https://example.com>'
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: <https://youtu.be/abc> and more text')
expect(result).toContain('<video-embed provider="youtube" video-id="abc"></video-embed>')
expect(result).toContain('Check this:')
expect(result).toContain('and more text')
})
it('converts multiple videos', () => {
const result = preprocessVideoLinks('<https://youtu.be/a> and <https://rumble.com/embed/b>')
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 <span data-hashtag data-label="world">#world</span>')
})
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 <span data-item-mention data-label="Alice" data-id="abc-123">@Alice</span>',
)
})
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('<https://youtu.be/abc>')
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()
})
})
})

View File

@ -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('<strong>bold</strong>')
})
it('converts bold with double underscores', () => {
const result = simpleMarkdownToHtml('__bold__', [])
expect(result).toContain('<strong>bold</strong>')
})
it('converts italic with single asterisk', () => {
const result = simpleMarkdownToHtml('*italic*', [])
expect(result).toContain('<em>italic</em>')
})
it('converts italic with single underscore', () => {
const result = simpleMarkdownToHtml('_italic_', [])
expect(result).toContain('<em>italic</em>')
})
it('converts inline code', () => {
const result = simpleMarkdownToHtml('use `code` here', [])
expect(result).toContain('<code>code</code>')
})
it('converts headers H1-H6', () => {
expect(simpleMarkdownToHtml('# Title', [])).toContain('<h1>Title</h1>')
expect(simpleMarkdownToHtml('## Subtitle', [])).toContain('<h2>Subtitle</h2>')
expect(simpleMarkdownToHtml('### Section', [])).toContain('<h3>Section</h3>')
expect(simpleMarkdownToHtml('###### Deep', [])).toContain('<h6>Deep</h6>')
})
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('&gt;')
})
})
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('</p><p>')
})
it('converts single newline to br', () => {
const result = simpleMarkdownToHtml('Line1\nLine2', [])
expect(result).toContain('<br>')
})
it('wraps content in paragraph tags', () => {
const result = simpleMarkdownToHtml('Hello world', [])
expect(result).toMatch(/^<p>.*<\/p>$/)
})
})
describe('Video Embeds', () => {
it('converts YouTube video-embed to iframe', () => {
const input = '<video-embed provider="youtube" video-id="abc123"></video-embed>'
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 = '<video-embed provider="rumble" video-id="xyz789"></video-embed>'
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 = '<span data-hashtag data-label="nature">#nature</span>'
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 = '<span data-hashtag data-label="unknown">#unknown</span>'
const result = simpleMarkdownToHtml(input, [])
expect(result).toContain('style="color: inherit')
})
})
describe('Item Mentions', () => {
it('renders mention as link with item color', () => {
const input = '<span data-item-mention data-label="Alice" data-id="abc-123">@Alice</span>'
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 = '<span data-item-mention data-label="Unknown" data-id="xxx">@Unknown</span>'
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('<p></p>')
})
})
})

View File

@ -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: '<script>alert(1)</script>',
scriptTagUppercase: '<SCRIPT>alert(1)</SCRIPT>',
scriptTagMixed: '<ScRiPt>alert(1)</ScRiPt>',
// Event handlers
imgOnerror: '<img src=x onerror=alert(1)>',
svgOnload: '<svg onload=alert(1)>',
bodyOnload: '<body onload=alert(1)>',
divOnmouseover: '<div onmouseover=alert(1)>hover</div>',
// JavaScript URLs
jsHref: '<a href="javascript:alert(1)">click</a>',
jsHrefEncoded: '<a href="javascript&#58;alert(1)">click</a>',
// Data URLs
dataUrl: '<a href="data:text/html,<script>alert(1)</script>">click</a>',
// Style injection
styleExpression: '<div style="background:url(javascript:alert(1))">',
// Object/embed tags
objectTag: '<object data="javascript:alert(1)">',
embedTag: '<embed src="javascript:alert(1)">',
}
describe('XSS Prevention - simpleMarkdownToHtml', () => {
describe('Script Tag Injection', () => {
it('escapes basic script tags', () => {
const result = simpleMarkdownToHtml(XSS_VECTORS.scriptTag, [])
expect(result).not.toContain('<script')
expect(result).toContain('&lt;script')
})
it('escapes uppercase script tags', () => {
const result = simpleMarkdownToHtml(XSS_VECTORS.scriptTagUppercase, [])
expect(result).not.toContain('<SCRIPT')
expect(result).toContain('&lt;SCRIPT')
})
it('escapes mixed-case script tags', () => {
const result = simpleMarkdownToHtml(XSS_VECTORS.scriptTagMixed, [])
expect(result).not.toContain('<ScRiPt')
})
})
describe('Event Handler Injection', () => {
// 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('&lt;img')
})
it('escapes svg tag making onload non-executable', () => {
const result = simpleMarkdownToHtml(XSS_VECTORS.svgOnload, [])
expect(result).toContain('&lt;svg')
})
it('escapes body tag making onload non-executable', () => {
const result = simpleMarkdownToHtml(XSS_VECTORS.bodyOnload, [])
expect(result).toContain('&lt;body')
})
it('escapes div tag making onmouseover non-executable', () => {
const result = simpleMarkdownToHtml(XSS_VECTORS.divOnmouseover, [])
expect(result).toContain('&lt;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('&lt;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,<script>alert(1)</script>)', [])
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 = '<span data-hashtag onclick=alert(1)>#tag</span>'
const result = simpleMarkdownToHtml(malicious, [])
// Malformed tag should remain escaped
expect(result).not.toContain('<span data-hashtag onclick')
expect(result).toContain('&lt;span')
})
it('blocks video-embed with malicious onload', () => {
const malicious = '<video-embed onload=alert(1)></video-embed>'
const result = simpleMarkdownToHtml(malicious, [])
// Malformed tag should remain escaped
expect(result).not.toContain('<video-embed onload')
})
it('only restores properly formatted hashtag spans', () => {
// Properly formatted hashtag span
const valid = '<span data-hashtag data-label="nature">#nature</span>'
const result = simpleMarkdownToHtml(valid, [])
expect(result).toContain('class="hashtag"')
})
it('only restores properly formatted video-embed tags', () => {
// Properly formatted video embed
const valid = '<video-embed provider="youtube" video-id="abc123"></video-embed>'
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 = '<span data-hashtag data-label="x" onclick="alert(1)">#x</span>'
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 (&lt;span)
expect(result).toContain('&lt;span')
// It should NOT contain an unescaped <span with onclick
expect(result).not.toMatch(/<span[^>]*onclick/i)
})
it('blocks javascript: in hashtag labels', () => {
const malicious = '<span data-hashtag data-label="javascript:alert(1)">#tag</span>'
const result = simpleMarkdownToHtml(malicious, [])
// Tag should remain escaped due to dangerous content in label
expect(result).toContain('&lt;span')
})
})
describe('HTML Entity Handling', () => {
it('preserves already-escaped content', () => {
const result = simpleMarkdownToHtml('&amp; &lt; &gt;', [])
// Double-escaping - &amp; becomes &amp;amp;
expect(result).toContain('&amp;amp;')
})
it('escapes angle brackets', () => {
const result = simpleMarkdownToHtml('1 < 2 > 0', [])
expect(result).toContain('&lt;')
expect(result).toContain('&gt;')
})
})
describe('Edge Cases', () => {
it('handles nested script attempts', () => {
const result = simpleMarkdownToHtml('<<script>script>alert(1)<</script>/script>', [])
expect(result).not.toContain('<script')
})
it('handles null bytes', () => {
const result = simpleMarkdownToHtml('<scr\0ipt>alert(1)</script>', [])
expect(result).not.toContain('<scr')
})
})
})