/* The safe Markdown renderer. * * This file is where H06 and H07 are decided for narrator prose, so these are * security tests before they are formatting tests. The two that matter most: * markup in the source never becomes markup in the page, and a `javascript:` * URL never becomes an href. */ import { render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import { Markdown, isSafeHref } from './markdown' describe('safe rendering', () => { it('renders a script tag as visible text, not as an element (H06)', () => { const { container } = render( alert(1) after'} />, ) expect(container.querySelector('script')).toBeNull() expect(container.textContent).toContain('') }) it('renders an img tag with an onerror handler as text (H06)', () => { const { container } = render( '} />, ) expect(container.querySelector('img')).toBeNull() expect(container.textContent).toContain('onerror') }) it('never creates an href for a javascript: URL (H07)', () => { const { container } = render( , ) expect(container.querySelector('a')).toBeNull() // The reader still sees what the text said. expect(container.textContent).toContain('click me') }) it('refuses data: and vbscript: URLs too', () => { const { container } = render( 1) [b](vbscript:x)'} />, ) expect(container.querySelector('a')).toBeNull() }) it('does not fetch a remote image — it renders a placeholder (G09)', () => { const { container } = render( , ) expect(container.querySelector('img')).toBeNull() expect(screen.getByText(/Remote image blocked/)).toBeInTheDocument() }) it('intercepts an external link rather than navigating', async () => { const onLink = vi.fn() render() const link = screen.getByRole('link', { name: 'site' }) link.click() expect(onLink).toHaveBeenCalledWith('https://example.com/x') }) }) describe('isSafeHref', () => { it('accepts http, https and mailto', () => { expect(isSafeHref('http://a.test')).toBe(true) expect(isSafeHref('https://a.test')).toBe(true) expect(isSafeHref('mailto:a@b.test')).toBe(true) }) it('rejects javascript: however it is spelled', () => { expect(isSafeHref('javascript:alert(1)')).toBe(false) expect(isSafeHref('JaVaScRiPt:alert(1)')).toBe(false) // A tab inside the scheme is stripped by the URL parser, which is exactly // why parsing beats pattern-matching here. expect(isSafeHref('java\tscript:alert(1)')).toBe(false) }) it('rejects an empty or unparseable href', () => { expect(isSafeHref('')).toBe(false) expect(isSafeHref(null)).toBe(false) }) }) describe('formatting (§76)', () => { it('renders headings, and never above h3', () => { const { container } = render() // A narrator writing `#` must not produce a second

on the page. expect(container.querySelector('h1')).toBeNull() expect(container.querySelector('h3')).toHaveTextContent('Title') expect(container.querySelector('h4')).toHaveTextContent('Sub') }) it('renders emphasis', () => { const { container } = render() expect(container.querySelector('strong')).toHaveTextContent('bold') expect(container.querySelector('em')).toHaveTextContent('italic') }) it('renders bullet and ordered lists', () => { const { container } = render() expect(container.querySelectorAll('ul li')).toHaveLength(2) const ordered = render() expect(ordered.container.querySelectorAll('ol li')).toHaveLength(2) }) it('renders blockquotes and code', () => { const { container } = render( quoted\n\n```\nlet x = 1\n```\n\nand `inline`'} />, ) expect(container.querySelector('blockquote')).toHaveTextContent('quoted') expect(container.querySelector('pre code')).toHaveTextContent('let x = 1') expect(container.querySelectorAll('code')).toHaveLength(2) }) it('keeps markup inside a fenced block literal', () => { const { container } = render( alert(1)\n```'} />, ) expect(container.querySelector('script')).toBeNull() expect(container.querySelector('pre')).toHaveTextContent('') }) it('leaves unsupported syntax as the literal text the narrator wrote', () => { const { container } = render() expect(container.querySelector('table')).toBeNull() expect(container.textContent).toContain('| a | b |') }) it('renders nothing for empty input', () => { const { container } = render() expect(container.firstChild).toBeNull() }) })