import { afterEach, describe, expect, it } from 'vitest' import { enableAutoUnmount, mount } from '@vue/test-utils' import type { FormField } from '../../src/api/types' import RichEditorField from '../../src/components/form/fields/RichEditorField.vue' import { displayHtml, isAllowedLink } from '../../src/components/form/richeditor/html' import { EDITOR_TAGS, inertFragment } from '../../src/components/form/richeditor/lossy' // The richeditor read-only display in a real engine (Vitest browser mode, // Playwright Chromium; T-10-16, T-17-10). happy-dom reproduces neither form // control clobbering nor DOMPurify's walk, so every exact display assertion // lives here. Decision note: // .planning/notes/2026-10-12-admin-display-sanitizer-dompurify.md enableAutoUnmount(afterEach) /** Problems of output against the display policy; empty when it complies. */ function policyProblems(output: string): string[] { const problems: string[] = [] for (const element of Array.from(inertFragment(output).querySelectorAll('*'))) { const name = element.localName.toLowerCase() if (!EDITOR_TAGS.has(name)) { problems.push(`element ${name}`) } for (const attribute of Array.from(element.attributes)) { const allowed = name === 'a' && attribute.name === 'href' && isAllowedLink(attribute.value) if (!allowed) { problems.push(`${name}[${attribute.name}=${attribute.value}]`) } } } return problems } /** Asserts output keeps the policy and none of the named elements or on* attributes. */ function expectClean(output: string, label: string): void { expect(policyProblems(output), label).toEqual([]) const fragment = inertFragment(output) for (const tag of ['form', 'input', 'img', 'button', 'iframe', 'meta', 'script']) { expect(fragment.querySelector(tag), `${label}: ${tag}`).toBeNull() } expect(output, label).not.toMatch(/\son[a-z]+\s*=/i) } const CLOBBER_VECTORS: Record = { 'form/input name=nodeType + img onerror': '
', 'img name=nodeType': '', 'button name=nodeType': '', 'form/button name=nodeType': '
', } describe('richeditor display in a real browser engine', () => { it('runs where form controls clobber form properties', () => { const doc = new DOMParser().parseFromString('
', 'text/html') const form = doc.querySelector('form')! // In a real engine the named control shadows Node.prototype.nodeType. expect(typeof (form as unknown as { nodeType: unknown }).nodeType).not.toBe('number') }) it('drops clobbered forms with everything inside them', () => { for (const [label, vector] of Object.entries(CLOBBER_VECTORS)) { expectClean(displayHtml(vector), label) expectClean(displayHtml(`
${vector}
`), `${label} nested in a div`) } expect((window as unknown as { __clobbered?: number }).__clobbered).toBeUndefined() }) it('keeps a form control named localName from breaking the view', () => { let output = '' expect(() => { output = displayHtml('

t

') }).not.toThrow() expect(output).toBe('

t

') }) it('drops an off-origin form, iframes, meta refresh and disallowed links', () => { const output = displayHtml( [ '
', '', '', '

para

', 'js', 'ftp', 'sms', 'rel', 'mail', 'ok', ].join(''), ) expectClean(output, 'off-origin vectors') expect(output).not.toContain('evil.example') const fragment = inertFragment(output) expect(fragment.querySelector('p')!.hasAttribute('href')).toBe(false) const hrefs = Object.fromEntries(Array.from(fragment.querySelectorAll('a')).map((a) => [a.textContent, a.getAttribute('href')])) expect(hrefs).toEqual({ js: null, ftp: null, sms: null, rel: 'docs/page', mail: 'mailto:a@example.test', ok: '/ok' }) }) it('parses into an inert document, so nothing loads before the value is bound', async () => { const token = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` const probe = `/__display-probe-${token}.png` const control = `/__display-control-${token}.png` displayHtml(`

x

`) // The control proves the observation works: a live image does produce an entry. const image = document.createElement('img') await new Promise((resolve) => { image.addEventListener('load', () => resolve(), { once: true }) image.addEventListener('error', () => resolve(), { once: true }) image.src = control }) await new Promise((resolve) => setTimeout(resolve, 100)) const names = performance.getEntriesByType('resource').map((entry) => entry.name) expect(names.some((name) => name.includes(control))).toBe(true) expect(names.filter((name) => name.includes(probe))).toEqual([]) }) }) describe('richeditor policy helpers', () => { it('unwraps unknown elements and drops scripts for display', () => { expect(displayHtml('

a

')).toBe('

a

') }) }) describe('RichEditorField', () => { it('renders read-only values exactly through the display policy', () => { const field = { name: 'body', label: 'Body', type: 'richeditor', readOnly: true } as FormField const wrapper = mount(RichEditorField, { props: { field, modelValue: '

Hi bad ok

', controlId: 'f-body', }, }) const box = wrapper.find('[data-richeditor-readonly]') expect(box.find('script').exists()).toBe(false) expect(box.find('[onclick]').exists()).toBe(false) expect(box.findAll('a').map((link) => link.attributes('href'))).toEqual([undefined, '/ok']) expect((window as unknown as { __re?: number }).__re).toBeUndefined() const clobbered = mount(RichEditorField, { props: { field, modelValue: CLOBBER_VECTORS['form/input name=nodeType + img onerror'], controlId: 'f-body-2' }, }) const clobberedBox = clobbered.find('[data-richeditor-readonly]') for (const tag of ['form', 'input', 'img']) { expect(clobberedBox.find(tag).exists(), tag).toBe(false) } }) })