// Unit tests for src/lib/sanitize.ts — CodeQL-safe HTML stripping import { describe, it, expect } from 'vitest'; import { stripHtml, truncateText } from '../sanitize'; describe('stripHtml', () => { describe('basic tag stripping', () => { it('strips simple tags', () => { expect(stripHtml('Hello World!')).toBe('Hello World!'); }); it('strips nested tags', () => { expect(stripHtml('

One

Two

')).toBe('OneTwo'); }); it('strips tags with attributes', () => { expect(stripHtml('click')) .toBe('click'); }); it('strips self-closing tags', () => { expect(stripHtml('A
B
C')).toBe('ABC'); }); }); describe('script block removal (XSS prevention)', () => { it('removes after')) .toBe('beforeafter'); }); it('removes done')) .toBe('done'); }); it('removes safe'; expect(stripHtml(evil)).toBe('safe'); }); it('removes text')) .toBe('text'); }); it('handles multiple script blocks', () => { expect(stripHtml('abc')).toBe('abc'); }); }); describe('HTML comment removal', () => { it('removes comments including their content', () => { expect(stripHtml('beforeafter')) .toBe('beforeafter'); }); it('removes multi-line comments', () => { expect(stripHtml('ab')).toBe('ab'); }); }); describe('CDATA section unwrapping', () => { it('unwraps CDATA sections (preserves inner content for processing)', () => { // Inner content survives — gets processed by later passes (tag strip, entity decode) expect(stripHtml('beforeinner

]]>after')) .toBe('beforeinnerafter'); }); it('handles CDATA with HTML entities inside', () => { expect(stripHtml('a & b

]]>')) .toBe('a & b'); }); }); describe('entity decoding', () => { it('decodes the 6 mandatory XML entities', () => { expect(stripHtml('< > & " '')) .toBe('< > & " \''); }); it('decodes   (preserved as U+00A0)', () => { expect(stripHtml('a b')).toBe('a\u00A0b'); }); it('decodes numeric decimal entities', () => { expect(stripHtml('ABC')).toBe('ABC'); }); it('decodes numeric hex entities (lowercase)', () => { expect(stripHtml('ABC')).toBe('ABC'); }); it('decodes numeric hex entities (uppercase)', () => { expect(stripHtml('ABC')).toBe('ABC'); }); it('decodes common typographic entities', () => { expect(stripHtml('… — –')) .toBe('… — –'); }); it('leaves unknown named entities as-is (defensive)', () => { expect(stripHtml('foo &unknownentity; bar')).toBe('foo &unknownentity; bar'); }); it('rejects out-of-range code points', () => { expect(stripHtml('�')).toBe('\uFFFD'); }); it('rejects surrogate code points', () => { expect(stripHtml('�')).toBe('\uFFFD'); }); it('rejects non-character code points', () => { expect(stripHtml('﷐')).toBe('\uFFFD'); }); }); describe('whitespace handling', () => { it('collapses multiple spaces', () => { expect(stripHtml('a b c')).toBe('a b c'); }); it('collapses newlines and tabs', () => { expect(stripHtml('a\n\nb\t\tc')).toBe('a b c'); }); it('trims leading and trailing whitespace', () => { expect(stripHtml(' hello ')).toBe('hello'); }); it('preserves non-breaking spaces (intentional)', () => { expect(stripHtml('a  b')).toBe('a\u00A0\u00A0b'); }); }); describe('empty / null input', () => { it('handles empty string', () => { expect(stripHtml('')).toBe(''); }); it('handles null coerced to empty', () => { expect(stripHtml(null as unknown as string)).toBe(''); }); it('handles undefined coerced to empty', () => { expect(stripHtml(undefined as unknown as string)).toBe(''); }); }); describe('Thai text preservation', () => { it('preserves Thai text with tags stripped', () => { expect(stripHtml('

สวัสดี ครับ

')).toBe('สวัสดี ครับ'); }); it('preserves Thai text with entities', () => { expect(stripHtml('ประกาศ "ค่าจ้างขั้นต่ำ"')).toBe('ประกาศ "ค่าจ้างขั้นต่ำ"'); }); }); describe('real-world RSS samples', () => { it('handles RSS CDATA-wrapped HTML with Thai + entities', () => { const rss = 'ประกาศกระทรวงแรงงาน เรื่อง ค่าจ้างขั้นต่ำ & โอที

]]>'; expect(stripHtml(rss)).toBe('ประกาศกระทรวงแรงงาน เรื่อง ค่าจ้างขั้นต่ำ & โอที'); }); it('handles RSS item with malicious markup', () => { const evil = 'กฎหมายแรงงาน<img src=x onerror=alert(1)>'; expect(stripHtml(evil)).toBe('กฎหมายแรงงาน'); }); }); describe('CodeQL bypass attempts (defense-in-depth)', () => { // These are the exact attack patterns CodeQL warned about in alerts #16-#20. // The old regex-based implementation failed these; the new manual-scanning // implementation must pass them all. it('defeats nested-comment bypass: >', () => { // Old regex //g would leave "" behind. // Our impl may leave a stray ">" but never an active comment. const result = stripHtml('>'); expect(result).not.toContain(''); expect(result).not.toContain('comment'); }); it('defeats split-script-tag bypass: ...t>alert(123)', () => { // Old regex would leave "" — a full active tag. // Our impl may leave plain-text "alert(123)" but NEVER an active t>alert(123)'; const result = stripHtml(evil); expect(result).not.toContain(' { const evil = 'safe'; expect(stripHtml(evil)).toBe('safe'); }); it('defeats case-insensitive script tag: ', () => { expect(stripHtml('safe')).toBe('safe'); }); it('defeats mixed-case style tag: text', () => { expect(stripHtml('text')).toBe('text'); }); it('defeats entity-encoded XSS payload: <script>alert(1)</script>', () => { // Entity decoding must NOT reintroduce active HTML — < decodes to literal // "<" but there's no ">" forming a tag in the decoded output of this string // alone. However if the entities form a complete tag after decoding, that tag // would need to be stripped too. Our impl decodes entities AFTER stripping // tags, so a fully entity-encoded tag would survive as literal text (which // is safe in plain-text contexts but NOT in HTML rendering contexts). const result = stripHtml('<script>alert(1)</script>'); // In plain-text context, this is safe — the decoded text is just characters expect(result).toBe(''); // Note: for HTML rendering, use DOMPurify instead. }); it('defeats nested CDATA with script inside', () => { const evil = 'alert(1)]]>safe'; const result = stripHtml(evil); expect(result).toBe('safe'); }); it('defeats comment containing fake script tag: safe', () => { expect(stripHtml('safe')).toBe('safe'); }); it('defeats unclosed tag at end of input', () => { expect(stripHtml('texttext', () => { // The first '>' closes the tag, leaving "text" which is then stripped const result = stripHtml('text'); expect(result).toBe('text'); }); it('never leaves an active ', '<', 'ipt>alert(1)', '', ]; for (const input of tricky) { const result = stripHtml(input); expect(result).not.toMatch(/