// 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('')).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 = 'กฎหมายแรงงาน
';
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('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(/