import { describe, expect, it } from 'bun:test'
import { sanitizeTeamsHtml } from './html-sanitizer'
describe('sanitizeTeamsHtml', () => {
it('keeps a Teams mention tag with its id attribute', () => {
expect(sanitizeTeamsHtml('Hey John')).toBe('Hey John')
})
it('keeps whitelisted formatting tags', () => {
expect(sanitizeTeamsHtml('bold')).toBe('bold')
})
it('escapes a disallowed tag with a dangerous attribute', () => {
expect(sanitizeTeamsHtml('
')).toBe('<img src=x onerror=alert(1)>')
})
it('escapes script tags entirely', () => {
expect(sanitizeTeamsHtml('')).toBe('<script>alert(1)</script>')
})
it('keeps a whitelisted tag but drops its disallowed attribute', () => {
expect(sanitizeTeamsHtml('x')).toBe('x')
})
it('drops an unsafe href but keeps the anchor tag', () => {
expect(sanitizeTeamsHtml('x')).toBe('x')
})
it('escapes a bare < that is not part of a recognized tag', () => {
expect(sanitizeTeamsHtml('a < b')).toBe('a < b')
})
describe('bypass attempts', () => {
it('blocks case-variant script tags', () => {
expect(sanitizeTeamsHtml('')).toBe('<ScRiPt>x</ScRiPt>')
})
it('recognizes a whitelisted tag regardless of case', () => {
expect(sanitizeTeamsHtml('John')).toBe('John')
})
it('blocks a tag name followed directly by non-whitespace garbage', () => {
const result = sanitizeTeamsHtml('')
expect(result).not.toContain('')
expect(result).not.toContain('')).toBe('<script>alert(1)</script>')
})
it('does not double-escape already-escaped content', () => {
expect(sanitizeTeamsHtml('<script>')).toBe('<script>')
})
it('accepts void/self-closing br in any spelling', () => {
expect(sanitizeTeamsHtml('
')).toBe('
')
expect(sanitizeTeamsHtml('
')).toBe('
')
expect(sanitizeTeamsHtml('
')).toBe('
')
})
it('allows an https href', () => {
expect(sanitizeTeamsHtml('link')).toBe('link')
})
it('drops a javascript: href', () => {
expect(sanitizeTeamsHtml('link')).toBe('link')
})
it('drops a javascript: href even with leading whitespace before it', () => {
expect(sanitizeTeamsHtml('link')).toBe('link')
})
it('escapes hyphenated custom elements instead of rewriting them to a shorter allowed tag', () => {
// Without a hyphen in the tag-name pattern, "" matched as "" plus
// leftover text, silently rewriting an unknown element into an anchor.
expect(sanitizeTeamsHtml('hi')).toBe('<a-b onclick="x">hi</a-b>')
expect(sanitizeTeamsHtml('x')).toBe('<my-widget>x</my-widget>')
})
it('escapes tag names that merely start with an allowed name', () => {
// A tag name must end at a real boundary. Otherwise "" matched the
// "at" prefix and was promoted into a Teams mention, and "" into
// bold — arbitrary markup turning into formatting or a mention.
expect(sanitizeTeamsHtml('x')).toBe('<at:id>x</at:id>')
expect(sanitizeTeamsHtml('x')).toBe('<b_extra>x</b_extra>')
expect(sanitizeTeamsHtml('x')).toBe('<b.bold>x</b.bold>')
expect(sanitizeTeamsHtml('x')).toBe(
'<a:link href="https://evil.com">x</a:link>',
)
})
it('ignores attribute names that merely end with an allowed name', () => {
// An attribute name must start at a real boundary. Otherwise "foo.id"
// was truncated to "id" and "foo.href" to "href", letting arbitrary
// attributes forge a mention or a link.
expect(sanitizeTeamsHtml('J')).toBe('J')
expect(sanitizeTeamsHtml('J')).toBe('J')
expect(sanitizeTeamsHtml('x')).toBe('x')
})
})
})