mirror of
https://github.com/utopia-os/utopia-ui.git
synced 2026-02-06 09:55:47 +00:00
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:
parent
783a205c58
commit
ff6d940ad9
501
lib/src/Components/TipTap/utils/preprocessMarkdown.spec.ts
Normal file
501
lib/src/Components/TipTap/utils/preprocessMarkdown.spec.ts
Normal 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('')).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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
162
lib/src/Components/TipTap/utils/simpleMarkdownToHtml.spec.ts
Normal file
162
lib/src/Components/TipTap/utils/simpleMarkdownToHtml.spec.ts
Normal 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('>')
|
||||
})
|
||||
})
|
||||
|
||||
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>')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
200
lib/src/Components/TipTap/utils/xss.spec.ts
Normal file
200
lib/src/Components/TipTap/utils/xss.spec.ts
Normal 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: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('<script')
|
||||
})
|
||||
|
||||
it('escapes uppercase script tags', () => {
|
||||
const result = simpleMarkdownToHtml(XSS_VECTORS.scriptTagUppercase, [])
|
||||
expect(result).not.toContain('<SCRIPT')
|
||||
expect(result).toContain('<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('<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,<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('<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 (<span)
|
||||
expect(result).toContain('<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('<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>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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user