import { describe, test, expect } from 'vitest'; import { escapeHtml, escapeAttr, safeUrl, stableHash, scopeId } from './escape'; describe('escapeHtml', () => { test('escapes &, <, >, "', () => { expect(escapeHtml('a & b "d"')).toBe('a & b <c> "d"'); }); test('escapes ampersand first (no double-escaping)', () => { expect(escapeHtml('&')).toBe('&amp;'); }); test('coerces non-string / null / undefined safely', () => { expect(escapeHtml(null as any)).toBe(''); expect(escapeHtml(undefined as any)).toBe(''); expect(escapeHtml(123 as any)).toBe('123'); }); }); describe('escapeAttr', () => { test('does everything escapeHtml does', () => { expect(escapeAttr('a & b "d"')).toBe('a & b <c> "d"'); }); test('also escapes single quote and backtick', () => { expect(escapeAttr(`'`)).toBe('''); expect(escapeAttr('`')).toBe('`'); }); test('combined example', () => { expect(escapeAttr(`it's a "test" \``)).toBe('it's a "test" <b>`'); }); test('coerces non-string / null / undefined safely', () => { expect(escapeAttr(null as any)).toBe(''); expect(escapeAttr(undefined as any)).toBe(''); }); }); describe('safeUrl', () => { test('blocks javascript: scheme', () => { expect(safeUrl('javascript:alert(1)')).toBe(''); }); test('blocks javascript: with leading whitespace and mixed case', () => { expect(safeUrl(' JavaScript:alert(1)')).toBe(''); }); test('blocks javascript: with tab/control-char obfuscation', () => { expect(safeUrl('java\tscript:alert(1)')).toBe(''); }); test('blocks entity-obfuscated javascript: scheme (decimal)', () => { expect(safeUrl('javascript:alert(1)')).toBe(''); }); test('blocks entity-obfuscated javascript: scheme (zero-padded decimal)', () => { expect(safeUrl('java:script:alert(1)')).toBe(''); }); test('blocks vbscript: scheme', () => { expect(safeUrl('vbscript:msgbox(1)')).toBe(''); }); test('blocks data:text/html', () => { expect(safeUrl('data:text/html;base64,PHNjcmlwdD4=')).toBe(''); }); test('allows data:image/png', () => { const s = 'data:image/png;base64,iVBORw0KGgo='; expect(safeUrl(s)).toBe(s); }); test.each([ 'https://x.com/a?b=1&c=2', 'http://x', '/relative/path', '#anchor', 'mailto:a@b.com', 'tel:+15551234', '', ])('allows %s unchanged (trimmed)', (input) => { expect(safeUrl(input)).toBe(input.trim()); }); test('coerces non-string to empty string', () => { expect(safeUrl(null as any)).toBe(''); expect(safeUrl(undefined as any)).toBe(''); expect(safeUrl(123 as any)).toBe(''); }); test('does not mangle query strings on allowed urls', () => { expect(safeUrl('https://x.com/a?b=1&c=2')).toBe('https://x.com/a?b=1&c=2'); }); }); describe('stableHash', () => { test('same input always produces the same output', () => { expect(stableHash('hello')).toBe(stableHash('hello')); }); test('different inputs produce different output (no collision for these cases)', () => { expect(stableHash('hello')).not.toBe(stableHash('world')); }); test('returns a non-empty string', () => { expect(stableHash('')).toEqual(expect.any(String)); expect(stableHash('x').length).toBeGreaterThan(0); }); }); describe('scopeId', () => { test('same node id -> identical scope id across calls (deterministic)', () => { expect(scopeId('node-abc', 'seed', 'sb')).toBe(scopeId('node-abc', 'seed', 'sb')); }); test('different node ids -> different, non-colliding scope ids even with the same fallback seed', () => { const id1 = scopeId('node-a', 'same-seed', 'sb'); const id2 = scopeId('node-b', 'same-seed', 'sb'); expect(id1).not.toBe(id2); }); test('node id is slugified (non-alphanumeric characters stripped, lowercased)', () => { expect(scopeId('Node ID! 123', 'seed', 'sb')).toBe('sb_nodeid123'); }); test('no nodeId (legacy 2-arg call sites) falls back to a deterministic hash of the seed, not Math.random', () => { const id1 = scopeId(undefined, 'same-seed', 'sb'); const id2 = scopeId(undefined, 'same-seed', 'sb'); expect(id1).toBe(id2); }); test('no nodeId: different seeds fall back to different hashes', () => { const id1 = scopeId(undefined, 'seed-one', 'sb'); const id2 = scopeId(undefined, 'seed-two', 'sb'); expect(id1).not.toBe(id2); }); test('prefix is applied', () => { expect(scopeId('node-abc', 'seed', 'tabs')).toMatch(/^tabs_/); }); });