Files
sm-journal-plugin/tests/blogeditor/schema.test.ts
Jakub Zych f1e6c11107 fix(16.2): WR-05 keep only view-safe attributes in the editor schema
captureAttributes and renderCaptured dropped only on* names, so id,
name, style, srcset, background and the like from stored HTML were
rendered by ProseMirror into the live admin DOM (DOM clobbering of
document.querySelector, fixed-position overlays, remote loads). htmlAttrs
now keeps an allowlist: class, title, alt, width, height, colspan,
rowspan, start, rel, target and data-*; href and src stay with the link
and image nodes. Other attributes are dropped from a block once it is
edited; untouched documents and untouched blocks keep their bytes.
2026-10-10 16:38:25 +02:00

262 lines
13 KiB
TypeScript

// The Blog Editor's preservation schema (Phase 16.2 D-03, D-04): what the
// schema models, what becomes a raw HTML card, which attributes survive an
// edit, and how the stored form of links, tables and figures looks when a
// block is serialized from the model.
import { afterEach, describe, expect, it } from 'vitest'
import { RAW_HTML_TAGS } from '../../assets/src/blogeditor/extensions'
import { serializeDocument } from '../../assets/src/blogeditor/serializer'
import { insertAtBlock, mount } from './helpers'
afterEach(() => {
document.body.replaceChildren()
})
/** types lists the top-level node types of the editor's document. */
function types(html: string): string[] {
const { editor } = mount(html)
const out: string[] = []
editor.state.doc.forEach((node) => out.push(node.type.name))
return out
}
/**
* shape describes markup for comparison: element names, attributes sorted by
* name (a style without its final semicolon) and text. An edited block may
* list attributes in another order (the model renders modelled ones after
* the captured ones) and the DOM normalizes a style declaration; names and
* values must stay the same.
*/
function shape(html: string): string {
const template = document.createElement('template')
template.innerHTML = html
const walk = (node: Node): string => {
if (node.nodeType === 3) return JSON.stringify(node.nodeValue)
if (node.nodeType !== 1) return ''
const el = node as Element
const attrs = Array.from(el.attributes)
.map((a) => `${a.name}=${JSON.stringify(a.name === 'style' ? a.value.trim().replace(/;$/, '') : a.value)}`)
.sort()
return `<${el.nodeName.toLowerCase()} ${attrs.join(' ')}>${Array.from(el.childNodes).map(walk).join('')}</>`
}
return Array.from(template.content.childNodes).map(walk).join('')
}
/** model serializes the whole document from the model, without block reuse. */
function model(html: string): string {
const { editor } = mount(html)
return serializeDocument(editor, null)
}
const rawSamples: Record<(typeof RAW_HTML_TAGS)[number], string> = {
footer: '<footer class="author-footer" itemscope="" itemtype="https://schema.org/Person"><p>Autor</p></footer>',
section: '<section class="s"><p>Sekcja</p></section>',
aside: '<aside>Na marginesie</aside>',
header: '<header><h2>Nagłówek</h2></header>',
nav: '<nav><a href="/a">a</a></nav>',
details: '<details><summary>Więcej</summary>Treść</details>',
style: '<style>.x { color: red; }</style>',
div: '<div class="box" data-x="1">Pudełko</div>',
article: '<article><p>Artykuł</p></article>',
main: '<main><p>Główna</p></main>',
address: '<address>ul. Leśna 1</address>',
dl: '<dl><dt>Termin</dt><dd>Opis</dd></dl>',
iframe: '<iframe title="video"></iframe>',
video: '<video controls=""></video>',
audio: '<audio controls=""></audio>',
picture: '<picture><source srcset="/a.webp"></picture>',
object: '<object data="/x.bin"></object>',
embed: '<embed src="/x.bin">',
form: '<form action="/x"><input name="q"></form>',
svg: '<svg viewBox="0 0 1 1"><circle r="1"></circle></svg>',
math: '<math><mi>x</mi></math>',
script: '<script>window.__schemaPwned = 1</script>',
noscript: '<noscript>Bez skryptów</noscript>',
}
describe('raw HTML blocks', () => {
it('has a sample for every raw HTML tag rule', () => {
expect(Object.keys(rawSamples).sort()).toEqual([...RAW_HTML_TAGS].sort())
})
it.each(Object.entries(rawSamples))('keeps <%s> as one raw HTML card through an edit elsewhere', (_tag, block) => {
const html = `<p>a</p>${block}`
const { el, editor } = mount(html)
expect(editor.state.doc.childCount).toBe(2)
expect(editor.state.doc.child(1).type.name).toBe('rawHtml')
insertAtBlock(editor, 0, 'X')
expect(el.value).toBe(`<p>Xa</p>${block}`)
expect((window as unknown as { __schemaPwned?: number }).__schemaPwned).toBeUndefined()
})
it('shows the card source as escaped text, never as live markup', () => {
const payload = '<div><img src="x" onerror="window.__schemaPwned = 2"><b>bold</b></div>'
const { el } = mount(payload)
const source = el.querySelector('.blogeditor__raw-source')!
expect(source.textContent).toBe(payload)
expect(source.innerHTML).toContain('&lt;img')
expect(el.querySelector('.ProseMirror img, .ProseMirror b')).toBeNull()
expect((window as unknown as { __schemaPwned?: number }).__schemaPwned).toBeUndefined()
})
it('emits a raw block verbatim when the document is serialized from the model', () => {
expect(model(rawSamples.footer)).toBe(rawSamples.footer)
})
})
describe('figures', () => {
it('models a figure holding only an image', () => {
expect(types('<figure><img src="/a.jpg" alt="a"></figure>')).toEqual(['figure'])
expect(model('<figure><img src="/a.jpg" alt="a"></figure>')).toBe('<figure><img src="/a.jpg" alt="a"></figure>')
})
it('models a figure with an image and a caption', () => {
const html = '<figure><img src="/a.jpg" alt="a"><figcaption>Podpis <em>zdjęcia</em></figcaption></figure>'
const { editor } = mount(html)
const figure = editor.state.doc.child(0)
expect(figure.type.name).toBe('figure')
expect(figure.child(0).type.name).toBe('figureImage')
expect(figure.child(1).type.name).toBe('figcaption')
expect(serializeDocument(editor, null)).toBe(html)
})
it.each([
['other content', '<figure><p>tekst</p></figure>'],
['a quote with a caption', '<figure class="quote"><blockquote>Cytat</blockquote><figcaption>Autor</figcaption></figure>'],
['two images', '<figure><img src="/a.jpg"><img src="/b.jpg"></figure>'],
['loose text', '<figure>tekst<img src="/a.jpg"></figure>'],
['a caption first', '<figure><figcaption>Najpierw</figcaption><img src="/a.jpg"></figure>'],
['a data URL image', '<figure><img src="data:image/png;base64,AAAA"></figure>'],
['an image without src', '<figure><img alt="x"></figure>'],
['nothing', '<figure></figure>'],
])('keeps a figure with %s as raw HTML', (_name, html) => {
expect(types(html)).toEqual(['rawHtml'])
})
it('makes an uncaptioned inline image a paragraph image without base64', () => {
expect(model('<p><img src="/a.jpg" alt="a"></p>')).toBe('<p><img src="/a.jpg" alt="a"></p>')
expect(model('<p>a<img src="data:image/png;base64,AAAA">b</p>')).toBe('<p>ab</p>')
})
})
describe('marks and global attributes', () => {
it('keeps a span with kept attributes as a mark and drops a bare span', () => {
expect(shape(model('<p>a <span class="hl" title="t" data-x="1">b</span> c</p>'))).toBe(shape('<p>a <span class="hl" title="t" data-x="1">b</span> c</p>'))
expect(model('<p>a <span>b</span> c</p>')).toBe('<p>a b c</p>')
})
it('drops a span whose attributes are all view-unsafe', () => {
expect(model('<p>a <span style="color: red" id="s">b</span> c</p>')).toBe('<p>a b c</p>')
expect(model('<p>a <span class="hl" style="color: red">b</span> c</p>')).toBe('<p>a <span class="hl">b</span> c</p>')
})
it.each([
['paragraph', '<p class="lead" data-x="1">a</p>'],
['heading', '<h2 class="h" title="Intro">a</h2>'],
['blockquote', '<blockquote class="q"><p>a</p></blockquote>'],
['bullet list and item', '<ul class="list"><li class="item"><p>a</p></li></ul>'],
['ordered list with start', '<ol start="3" class="steps"><li><p>a</p></li></ol>'],
['code block', '<pre class="code"><code>a</code></pre>'],
['horizontal rule', '<hr class="sep">'],
['image', '<p><img src="/a.jpg" alt="a" class="photo" data-id="7"></p>'],
['figcaption', '<figure><img src="/a.jpg" alt="a"><figcaption class="cap">c</figcaption></figure>'],
['link', '<p><a href="/a" class="more" data-id="7">a</a></p>'],
['table, row and cells', '<table class="t" width="100%"><tbody><tr class="r"><td class="c" data-x="1" width="50" height="20">a</td></tr></tbody></table>'],
])('keeps unmodelled attributes on a %s', (_name, html) => {
expect(shape(model(html))).toBe(shape(html))
})
// WR-05: ProseMirror renders captured attributes into the live admin DOM,
// so only view-safe ones are kept once a block is serialized from the model.
it.each([
['id', '<h2 id="intro" class="h">a</h2>', '<h2 class="h">a</h2>'],
['name', '<p><img src="/a.jpg" alt="a" name="querySelector"></p>', '<p><img src="/a.jpg" alt="a"></p>'],
['style', '<p class="x" style="position:fixed;inset:0;z-index:99999">a</p>', '<p class="x">a</p>'],
['srcset and loading', '<p><img src="/a.jpg" alt="a" srcset="https://evil.test/a.jpg 2x" loading="lazy"></p>', '<p><img src="/a.jpg" alt="a"></p>'],
['background', '<table background="https://evil.test/bg.png"><tbody><tr><td>a</td></tr></tbody></table>', '<table><tbody><tr><td>a</td></tr></tbody></table>'],
['form, contenteditable and popover', '<p form="f" contenteditable="true" popover="auto" class="k">a</p>', '<p class="k">a</p>'],
['itemprop, dir and lang', '<p itemprop="description" dir="rtl" lang="pl">a</p>', '<p>a</p>'],
['a cell style', '<table><tbody><tr><td style="text-align: right" id="c">a</td></tr></tbody></table>', '<table><tbody><tr><td>a</td></tr></tbody></table>'],
])('drops the view-unsafe attribute %s from a block serialized from the model', (_name, html, want) => {
expect(shape(model(html))).toBe(shape(want))
})
it('never renders a view-unsafe stored attribute into the live editor DOM', () => {
const html =
'<h2 id="intro">a</h2><p name="querySelector" style="position:fixed" class="k" data-x="1">b <span style="color:red" id="s" class="hl">c</span></p>' +
'<p><img src="/a.jpg" alt="a" name="img" srcset="https://evil.test/a.jpg 2x" class="photo"></p>' +
'<table background="https://evil.test/bg.png"><tbody><tr><td style="color:red" id="c">d</td></tr></tbody></table>'
const { el } = mount(html)
const view = el.querySelector('.ProseMirror')!
for (const attribute of ['id', 'name', 'srcset', 'background']) {
expect(Array.from(view.querySelectorAll(`[${attribute}]`)).map((node) => node.outerHTML), attribute).toEqual([])
}
// The table node view sets its own min-width style; no stored style
// reaches the view.
const styles = Array.from(view.querySelectorAll('[style]')).map((node) => node.getAttribute('style'))
expect(styles.filter((style) => /fixed|red/.test(style ?? ''))).toEqual([])
expect(view.querySelector('p.k[data-x="1"]')).not.toBeNull()
expect(view.querySelector('span.hl')).not.toBeNull()
expect(view.querySelector('img.photo')).not.toBeNull()
// Untouched, the element still returns the stored bytes (D-03).
expect(el.value).toBe(html)
})
it('never keeps an event handler attribute', () => {
const out = model('<p class="a" onclick="x()" ONMOUSEOVER="y()">a</p><p><img src="/a.jpg" alt="a" onerror="z()"></p>')
expect(out).toBe('<p class="a">a</p><p><img src="/a.jpg" alt="a"></p>')
})
})
describe('links', () => {
it('adds no target, rel or class and keeps stored ones', () => {
expect(model('<p><a href="https://example.test/">a</a></p>')).toBe('<p><a href="https://example.test/">a</a></p>')
const stored = model('<p><a href="https://example.test/" target="_blank" rel="noopener">a</a></p>')
expect(stored).toContain('target="_blank"')
expect(stored).toContain('rel="noopener"')
})
it.each(['javascript:alert(1)', 'JAVASCRIPT:alert(1)', 'java\tscript:alert(1)', 'vbscript:x', 'data:text/html,x', 'tel:123'])(
'drops the link but keeps the text for %s',
(href) => {
const out = model(`<p><a href="${href}">tekst</a></p>`)
expect(out).toBe('<p>tekst</p>')
},
)
})
describe('tables', () => {
it('renders without colgroup or a min-width style, header row in thead, bare cell text', () => {
const out = model('<table><thead><tr><th>A</th><th>B</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table>')
expect(out).toBe('<table><thead><tr><th>A</th><th>B</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table>')
expect(out).not.toContain('colgroup')
expect(out).not.toContain('min-width')
})
it('keeps a paragraph with attributes inside a cell', () => {
expect(model('<table><tbody><tr><td><p class="x">1</p></td></tr></tbody></table>')).toBe('<table><tbody><tr><td><p class="x">1</p></td></tr></tbody></table>')
})
it('keeps colspan and rowspan', () => {
const html = '<table><tbody><tr><td colspan="2">a</td></tr><tr><td rowspan="2">b</td><td>c</td></tr><tr><td>d</td></tr></tbody></table>'
const out = model(html)
expect(out).toContain('colspan="2"')
expect(out).toContain('rowspan="2"')
})
})
describe('document shape', () => {
it('adds no trailing paragraph after a final block', () => {
for (const html of ['<h2>Tytuł</h2>', '<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>', '<table><tbody><tr><td>a</td></tr></tbody></table>', rawSamples.footer]) {
const { el, editor } = mount(html)
expect(editor.state.doc.lastChild!.type.name).not.toBe('paragraph')
expect(el.value).toBe(html)
}
})
it('serializes an edited heading level and list without wrappers it did not have', () => {
const { el, editor } = mount('<h3>Tytuł</h3>')
insertAtBlock(editor, 0, 'Nowy ')
expect(el.value).toBe('<h3>Nowy Tytuł</h3>')
})
})