|
| 1 | +import { describe, expect, it } from 'vitest'; |
| 2 | +import { htmlToMarkdown, sanitizeUrl } from './markdown'; |
| 3 | + |
| 4 | +const META = { title: 'Demo', schemaVersion: 1 as const }; |
| 5 | + |
| 6 | +describe('htmlToMarkdown', () => { |
| 7 | + it('writes a YAML frontmatter with title and schemaVersion', () => { |
| 8 | + const out = htmlToMarkdown('<p>hi</p>', META); |
| 9 | + expect(out.startsWith('---\ntitle: Demo\nschemaVersion: 1\n---\n')).toBe(true); |
| 10 | + expect(out).toContain('hi'); |
| 11 | + }); |
| 12 | + |
| 13 | + it('converts headings h1..h6', () => { |
| 14 | + const html = '<h1>One</h1><h2>Two</h2><h3>Three</h3><h6>Six</h6>'; |
| 15 | + const out = htmlToMarkdown(html, META); |
| 16 | + expect(out).toContain('# One'); |
| 17 | + expect(out).toContain('## Two'); |
| 18 | + expect(out).toContain('### Three'); |
| 19 | + expect(out).toContain('###### Six'); |
| 20 | + }); |
| 21 | + |
| 22 | + it('converts paragraphs to blank-line wrapped text', () => { |
| 23 | + const out = htmlToMarkdown('<p>First</p><p>Second</p>', META); |
| 24 | + expect(out).toMatch(/First\n\nSecond/); |
| 25 | + }); |
| 26 | + |
| 27 | + it('converts links and images', () => { |
| 28 | + const out = htmlToMarkdown( |
| 29 | + '<a href="https://example.com">site</a><img src="/a.png" alt="logo" />', |
| 30 | + META, |
| 31 | + ); |
| 32 | + expect(out).toContain('[site](https://example.com)'); |
| 33 | + expect(out).toContain(''); |
| 34 | + }); |
| 35 | + |
| 36 | + it('converts unordered and ordered lists', () => { |
| 37 | + const ul = htmlToMarkdown('<ul><li>a</li><li>b</li></ul>', META); |
| 38 | + expect(ul).toContain('- a'); |
| 39 | + expect(ul).toContain('- b'); |
| 40 | + const ol = htmlToMarkdown('<ol><li>x</li><li>y</li></ol>', META); |
| 41 | + expect(ol).toContain('1. x'); |
| 42 | + expect(ol).toContain('2. y'); |
| 43 | + }); |
| 44 | + |
| 45 | + it('converts strong/em/code/pre', () => { |
| 46 | + const out = htmlToMarkdown( |
| 47 | + '<p><strong>bold</strong> and <em>italic</em> with <code>x</code></p><pre>line1\nline2</pre>', |
| 48 | + META, |
| 49 | + ); |
| 50 | + expect(out).toContain('**bold**'); |
| 51 | + expect(out).toContain('*italic*'); |
| 52 | + expect(out).toContain('`x`'); |
| 53 | + expect(out).toContain('```\nline1\nline2\n```'); |
| 54 | + }); |
| 55 | + |
| 56 | + it('strips script/style/head and unknown tags', () => { |
| 57 | + const out = htmlToMarkdown( |
| 58 | + '<head><title>x</title></head><script>evil()</script><style>.a{}</style><div><p>kept</p></div>', |
| 59 | + META, |
| 60 | + ); |
| 61 | + expect(out).not.toContain('evil'); |
| 62 | + expect(out).not.toContain('.a{}'); |
| 63 | + expect(out).toContain('kept'); |
| 64 | + }); |
| 65 | + |
| 66 | + it('decodes entities', () => { |
| 67 | + const out = htmlToMarkdown('<p>A & B < C</p>', META); |
| 68 | + expect(out).toContain('A & B < C'); |
| 69 | + }); |
| 70 | + |
| 71 | + it('handles empty input gracefully', () => { |
| 72 | + const out = htmlToMarkdown('', META); |
| 73 | + expect(out).toContain('schemaVersion: 1'); |
| 74 | + }); |
| 75 | + |
| 76 | + it('handles malformed HTML without throwing', () => { |
| 77 | + const out = htmlToMarkdown('<p>open<strong>bold<p>next', META); |
| 78 | + expect(typeof out).toBe('string'); |
| 79 | + expect(out.length).toBeGreaterThan(0); |
| 80 | + }); |
| 81 | + |
| 82 | + it('strips javascript: links but keeps the visible text', () => { |
| 83 | + const out = htmlToMarkdown('<p><a href="javascript:alert(1)">x</a></p>', META); |
| 84 | + expect(out).toContain('x'); |
| 85 | + expect(out).not.toContain('javascript:'); |
| 86 | + expect(out).not.toContain(']('); |
| 87 | + }); |
| 88 | + |
| 89 | + it('keeps https links untouched', () => { |
| 90 | + const out = htmlToMarkdown('<a href="https://x.test">x</a>', META); |
| 91 | + expect(out).toContain('[x](https://x.test)'); |
| 92 | + }); |
| 93 | + |
| 94 | + it('allows mailto and relative link schemes', () => { |
| 95 | + const out = htmlToMarkdown( |
| 96 | + '<a href="mailto:a@b.test">mail</a><a href="/foo">rel</a><a href="#anchor">anc</a>', |
| 97 | + META, |
| 98 | + ); |
| 99 | + expect(out).toContain('[mail](mailto:a@b.test)'); |
| 100 | + expect(out).toContain('[rel](/foo)'); |
| 101 | + expect(out).toContain('[anc](#anchor)'); |
| 102 | + }); |
| 103 | + |
| 104 | + it('keeps inline data:image/* sources', () => { |
| 105 | + const src = 'data:image/png;base64,iVBORw0KGgo='; |
| 106 | + const out = htmlToMarkdown(`<img src="${src}" alt="px" />`, META); |
| 107 | + expect(out).toContain(``); |
| 108 | + }); |
| 109 | + |
| 110 | + it('strips non-image data: URLs from images', () => { |
| 111 | + const out = htmlToMarkdown('<img src="data:text/html,<script>x</script>" alt="bad" />', META); |
| 112 | + expect(out).not.toContain('data:text/html'); |
| 113 | + expect(out).not.toContain('![bad]'); |
| 114 | + }); |
| 115 | + |
| 116 | + it('strips other dangerous schemes (vbscript:, file:)', () => { |
| 117 | + const out = htmlToMarkdown( |
| 118 | + '<a href="vbscript:msgbox">v</a><a href="file:///etc/passwd">f</a>', |
| 119 | + META, |
| 120 | + ); |
| 121 | + expect(out).not.toContain('vbscript:'); |
| 122 | + expect(out).not.toContain('file:'); |
| 123 | + expect(out).toContain('v'); |
| 124 | + expect(out).toContain('f'); |
| 125 | + }); |
| 126 | +}); |
| 127 | + |
| 128 | +describe('sanitizeUrl encoded-scheme bypass guard', () => { |
| 129 | + it('rejects HTML hex-entity encoded javascript scheme', () => { |
| 130 | + expect(sanitizeUrl('javascript:alert(1)', 'link')).toBeNull(); |
| 131 | + }); |
| 132 | + |
| 133 | + it('rejects HTML decimal-entity encoded javascript scheme', () => { |
| 134 | + expect(sanitizeUrl('javascript:alert(1)', 'link')).toBeNull(); |
| 135 | + }); |
| 136 | + |
| 137 | + it('rejects entity-encoded colon javascript scheme', () => { |
| 138 | + expect(sanitizeUrl('javascript:alert(1)', 'link')).toBeNull(); |
| 139 | + }); |
| 140 | + |
| 141 | + it('rejects URL percent-encoded javascript scheme', () => { |
| 142 | + expect(sanitizeUrl('%6Aavascript:alert(1)', 'link')).toBeNull(); |
| 143 | + }); |
| 144 | + |
| 145 | + it('rejects javascript scheme with leading whitespace', () => { |
| 146 | + expect(sanitizeUrl(' javascript:alert(1)', 'link')).toBeNull(); |
| 147 | + }); |
| 148 | + |
| 149 | + it('rejects javascript scheme with leading tab', () => { |
| 150 | + expect(sanitizeUrl('\tjavascript:alert(1)', 'link')).toBeNull(); |
| 151 | + }); |
| 152 | + |
| 153 | + it('rejects javascript scheme with embedded tab/newline before colon', () => { |
| 154 | + expect(sanitizeUrl('java\tscript:alert(1)', 'link')).toBeNull(); |
| 155 | + expect(sanitizeUrl('java\nscript:alert(1)', 'link')).toBeNull(); |
| 156 | + }); |
| 157 | + |
| 158 | + it('rejects mixed-case javascript scheme', () => { |
| 159 | + expect(sanitizeUrl('JavaScript:alert(1)', 'link')).toBeNull(); |
| 160 | + }); |
| 161 | + |
| 162 | + it('still permits safe http/mailto/relative URLs after decoding', () => { |
| 163 | + expect(sanitizeUrl('https://x.test', 'link')).toBe('https://x.test'); |
| 164 | + expect(sanitizeUrl('mailto:a@b.test', 'link')).toBe('mailto:a@b.test'); |
| 165 | + expect(sanitizeUrl('/foo/bar', 'link')).toBe('/foo/bar'); |
| 166 | + expect(sanitizeUrl('#anchor', 'link')).toBe('#anchor'); |
| 167 | + }); |
| 168 | + |
| 169 | + it('strips entity-encoded javascript when used inside markdown link conversion', () => { |
| 170 | + const out = htmlToMarkdown('<p><a href="javascript:alert(1)">x</a></p>', { |
| 171 | + title: 'Demo', |
| 172 | + schemaVersion: 1, |
| 173 | + }); |
| 174 | + expect(out).toContain('x'); |
| 175 | + expect(out).not.toContain('javascript:'); |
| 176 | + expect(out).not.toContain(']('); |
| 177 | + }); |
| 178 | + |
| 179 | + it('keeps URLs containing percent-encoded path/query characters as-is', () => { |
| 180 | + expect(sanitizeUrl('https://example.com/path?q=%2F', 'link')).toBe( |
| 181 | + 'https://example.com/path?q=%2F', |
| 182 | + ); |
| 183 | + expect(sanitizeUrl('https://example.com/p%C3%A9', 'link')).toBe('https://example.com/p%C3%A9'); |
| 184 | + }); |
| 185 | + |
| 186 | + it('keeps URLs with stray literal % that would break decodeURIComponent', () => { |
| 187 | + expect(sanitizeUrl('https://x.test/?q=100%', 'link')).toBe('https://x.test/?q=100%'); |
| 188 | + }); |
| 189 | +}); |
0 commit comments