- schema: every raw HTML tag rule, figure shapes, span mark, unmodelled attributes, handler attributes dropped, links, tables, no trailing node - serializer: block reuse after an edit, a deletion and an insertion, separators and comments kept, captureBlocks fallbacks - embeds: parseEmbedUrl table, placeholder round trip, isPolicyUrl table, upload id matching and timeout, labels, disabled and invalid
277 lines
11 KiB
TypeScript
277 lines
11 KiB
TypeScript
// Embeds, the URL rule, uploads and labels of the Blog Editor (Phase 16.2
|
|
// D-04, D-05, D-13, D-18).
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { EMBED_PROVIDERS, isEmbedProvider, parseEmbedUrl } from '../../assets/src/blogeditor/embed'
|
|
import { isPolicyUrl } from '../../assets/src/blogeditor/extensions'
|
|
import { format, labelsFor } from '../../assets/src/blogeditor/labels'
|
|
import { button, click, fill, form, mount, status } from './helpers'
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers()
|
|
document.body.replaceChildren()
|
|
})
|
|
|
|
const ID = 'dQw4w9WgXcQ'
|
|
const PLACEHOLDER = `<figure data-embed="youtube" data-id="${ID}"></figure>`
|
|
|
|
describe('parseEmbedUrl', () => {
|
|
it.each([
|
|
[`https://www.youtube.com/watch?v=${ID}`],
|
|
[`https://youtube.com/watch?v=${ID}`],
|
|
[`https://m.youtube.com/watch?v=${ID}`],
|
|
[`https://music.youtube.com/watch?v=${ID}`],
|
|
[`http://www.youtube.com/watch?v=${ID}`],
|
|
[`https://WWW.YOUTUBE.COM/watch?v=${ID}`],
|
|
[`https://www.youtube.com/watch?v=${ID}&t=42s&list=PL123`],
|
|
[`https://www.youtube.com/watch?feature=share&v=${ID}`],
|
|
[`https://youtu.be/${ID}`],
|
|
[`https://youtu.be/${ID}?t=10`],
|
|
[`https://www.youtube.com/shorts/${ID}`],
|
|
[`https://www.youtube.com/shorts/${ID}/`],
|
|
[`https://www.youtube.com/embed/${ID}`],
|
|
[`https://www.youtube-nocookie.com/embed/${ID}?rel=0`],
|
|
[` https://youtu.be/${ID} `],
|
|
])('accepts %s', (url) => {
|
|
expect(parseEmbedUrl(url)).toEqual({ provider: 'youtube', id: ID })
|
|
})
|
|
|
|
it.each([
|
|
['a 10-character id', 'https://youtu.be/dQw4w9WgXc'],
|
|
['a 12-character id', 'https://youtu.be/dQw4w9WgXcQQ'],
|
|
['an id with a dot', 'https://www.youtube.com/watch?v=dQw4w9WgX.Q'],
|
|
['no id', 'https://www.youtube.com/watch'],
|
|
['a channel page', 'https://www.youtube.com/@channel'],
|
|
['an extra path segment', `https://www.youtube.com/embed/${ID}/extra`],
|
|
['another provider', `https://vimeo.com/${ID}`],
|
|
['a lookalike host', `https://www.youtube.com.evil.test/watch?v=${ID}`],
|
|
['a suffix host', `https://notyoutube.com/watch?v=${ID}`],
|
|
['a subdomain of youtu.be', `https://x.youtu.be/${ID}`],
|
|
['javascript:', `javascript:alert('${ID}')`],
|
|
['ftp:', `ftp://youtu.be/${ID}`],
|
|
['data:', `data:text/html,${ID}`],
|
|
['a relative URL', `/watch?v=${ID}`],
|
|
['text', 'not a url'],
|
|
['empty', ''],
|
|
])('refuses %s', (_name, url) => {
|
|
expect(parseEmbedUrl(url)).toBeNull()
|
|
})
|
|
|
|
it('has YouTube as the only provider, with an 11-character id pattern', () => {
|
|
expect(Object.keys(EMBED_PROVIDERS)).toEqual(['youtube'])
|
|
expect(EMBED_PROVIDERS.youtube.idPattern.test(ID)).toBe(true)
|
|
expect(EMBED_PROVIDERS.youtube.idPattern.test(`${ID}\n`)).toBe(false)
|
|
expect(isEmbedProvider('youtube')).toBe(true)
|
|
expect(isEmbedProvider('vimeo')).toBe(false)
|
|
expect(isEmbedProvider('toString')).toBe(false)
|
|
expect(isEmbedProvider('__proto__')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('embed node', () => {
|
|
it('round-trips the exact placeholder between other blocks', () => {
|
|
const html = `<p>Film:</p>${PLACEHOLDER}<p>Koniec</p>`
|
|
const { el, editor } = mount(html)
|
|
const node = editor.state.doc.child(1)
|
|
expect(node.type.name).toBe('embed')
|
|
expect(node.attrs).toMatchObject({ provider: 'youtube', id: ID })
|
|
expect(el.value).toBe(html)
|
|
editor.view.dispatch(editor.state.tr.insertText('X', 1))
|
|
expect(el.value).toBe(`<p>XFilm:</p>${PLACEHOLDER}<p>Koniec</p>`)
|
|
})
|
|
|
|
it('shows a card and never an iframe', () => {
|
|
const { el } = mount(PLACEHOLDER)
|
|
expect(el.querySelector('iframe')).toBeNull()
|
|
expect(el.querySelector('.blogeditor__embed')!.textContent).toBe(`${labelsFor("en").embedCard}: YouTube ${ID}`)
|
|
})
|
|
|
|
it.each([
|
|
['another provider', `<figure data-embed="vimeo" data-id="${ID}"></figure>`],
|
|
['a short id', '<figure data-embed="youtube" data-id="abc"></figure>'],
|
|
['an extra attribute', `<figure data-embed="youtube" data-id="${ID}" class="x"></figure>`],
|
|
['content', `<figure data-embed="youtube" data-id="${ID}">x</figure>`],
|
|
['no id', '<figure data-embed="youtube"></figure>'],
|
|
])('keeps a placeholder with %s as raw HTML, byte for byte', (_name, html) => {
|
|
const { el, editor } = mount(html)
|
|
expect(editor.state.doc.child(0).type.name).toBe('rawHtml')
|
|
expect(el.value).toBe(html)
|
|
})
|
|
|
|
it('serializes the attributes in placeholder order whatever the stored order', () => {
|
|
const { el, editor } = mount(`<p>a</p><figure data-id="${ID}" data-embed="youtube"></figure>`)
|
|
expect(editor.state.doc.child(1).type.name).toBe('embed')
|
|
editor.view.dispatch(editor.state.tr.insertText('X', 1))
|
|
// The untouched embed block keeps its stored bytes.
|
|
expect(el.value).toBe(`<p>Xa</p><figure data-id="${ID}" data-embed="youtube"></figure>`)
|
|
})
|
|
|
|
it('setEmbed refuses an unknown provider or a bad id', () => {
|
|
const { el, editor } = mount('')
|
|
expect(editor.commands.setEmbed({ provider: 'vimeo', id: ID })).toBe(false)
|
|
expect(editor.commands.setEmbed({ provider: 'youtube', id: 'short' })).toBe(false)
|
|
expect(el.value).toBe('')
|
|
expect(editor.commands.setEmbed({ provider: 'youtube', id: ID })).toBe(true)
|
|
expect(el.value).toBe(PLACEHOLDER)
|
|
})
|
|
|
|
it('inserts from the toolbar, keeps the form open on an error and closes on cancel', () => {
|
|
const { el, changes } = mount('<p>a</p>')
|
|
click(button(el, 'embed'))
|
|
const embedForm = form(el, 'embed')!
|
|
fill(embedForm.querySelector('input'), 'javascript:alert(1)')
|
|
click(embedForm.querySelector('[data-action="insert"]'))
|
|
expect(form(el, 'embed')).not.toBeNull()
|
|
expect(el.value).toBe('<p>a</p>')
|
|
expect(changes).toEqual([])
|
|
click(embedForm.querySelector('[data-action="cancel"]'))
|
|
expect(form(el, 'embed')).toBeNull()
|
|
|
|
click(button(el, 'embed'))
|
|
fill(form(el, 'embed')!.querySelector('input'), `https://www.youtube.com/shorts/${ID}`)
|
|
click(form(el, 'embed')!.querySelector('[data-action="insert"]'))
|
|
expect(el.value).toContain(PLACEHOLDER)
|
|
expect(changes).toHaveLength(1)
|
|
})
|
|
})
|
|
|
|
describe('isPolicyUrl', () => {
|
|
it.each(['http://a.test', 'https://a.test/x?y#z', 'HTTPS://A.TEST', 'mailto:a@b.test', '/rel', 'rel/path', '../up', '#fragment', '?q=1', '', 'https://a.test/javascript:x'])(
|
|
'accepts %j',
|
|
(url) => {
|
|
expect(isPolicyUrl(url)).toBe(true)
|
|
},
|
|
)
|
|
|
|
it.each([
|
|
'javascript:alert(1)',
|
|
'JavaScript:alert(1)',
|
|
' javascript:alert(1)',
|
|
'java\tscript:alert(1)',
|
|
'java\nscript:alert(1)',
|
|
'java\rscript:alert(1)',
|
|
'javascript\u0000:alert(1)',
|
|
'vbscript:msgbox(1)',
|
|
'data:text/html,x',
|
|
'tel:123',
|
|
'ftp://a.test',
|
|
'file:///etc/passwd',
|
|
])('refuses %j', (url) => {
|
|
expect(isPolicyUrl(url)).toBe(false)
|
|
})
|
|
|
|
it('refuses null and undefined', () => {
|
|
expect(isPolicyUrl(null)).toBe(false)
|
|
expect(isPolicyUrl(undefined)).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('upload flow', () => {
|
|
const png = () => new File([new Uint8Array([137, 80, 78, 71])], 'grzyb.png', { type: 'image/png' })
|
|
|
|
function pick(el: HTMLElement, files: File[]): void {
|
|
const input = el.querySelector<HTMLInputElement>('input[type="file"]')!
|
|
Object.defineProperty(input, 'files', { value: files, configurable: true })
|
|
input.dispatchEvent(new Event('change'))
|
|
}
|
|
|
|
function answer(el: HTMLElement, detail: Record<string, string>): void {
|
|
el.dispatchEvent(new CustomEvent('summer-upload-result', { detail }))
|
|
}
|
|
|
|
it('ignores an answer with an id it did not send', () => {
|
|
const { el, uploads, changes } = mount('<p>a</p>')
|
|
pick(el, [png()])
|
|
answer(el, { id: 'not-ours', url: '/x.png' })
|
|
answer(el, { id: 'not-ours', error: 'nope' })
|
|
expect(form(el, 'image')).toBeNull()
|
|
expect(status(el)).toBe('Uploading grzyb.png…')
|
|
answer(el, { id: uploads[0]!.id, url: '/ours.png' })
|
|
expect(form(el, 'image')).not.toBeNull()
|
|
expect(changes).toEqual([])
|
|
})
|
|
|
|
it('times out after 60 seconds and ignores the late answer', () => {
|
|
vi.useFakeTimers()
|
|
const { el, uploads } = mount('')
|
|
pick(el, [png()])
|
|
vi.advanceTimersByTime(59_999)
|
|
expect(status(el)).toBe('Uploading grzyb.png…')
|
|
vi.advanceTimersByTime(1)
|
|
expect(status(el)).toBe('The upload of grzyb.png took too long.')
|
|
answer(el, { id: uploads[0]!.id, url: '/late.png' })
|
|
expect(form(el, 'image')).toBeNull()
|
|
expect(el.value).toBe('')
|
|
})
|
|
|
|
it('answers each of several uploads by its own id', () => {
|
|
const { el, uploads } = mount('')
|
|
pick(el, [png(), new File([new Uint8Array([255, 216, 255])], 'b.jpg', { type: 'image/jpeg' })])
|
|
expect(uploads).toHaveLength(2)
|
|
expect(uploads[0]!.id).not.toBe(uploads[1]!.id)
|
|
answer(el, { id: uploads[1]!.id, error: 'Too big' })
|
|
expect(status(el)).toBe('Upload failed: Too big')
|
|
})
|
|
|
|
it('refuses an uploaded URL outside the policy', () => {
|
|
const { el, uploads } = mount('')
|
|
pick(el, [png()])
|
|
answer(el, { id: uploads[0]!.id, url: 'javascript:alert(1)' })
|
|
expect(form(el, 'image')).toBeNull()
|
|
expect(status(el)).toMatch(/^Upload failed/)
|
|
expect(el.value).toBe('')
|
|
})
|
|
})
|
|
|
|
describe('labels', () => {
|
|
it('has Polish labels, an English fallback and every key in both', () => {
|
|
const en = labelsFor('en')
|
|
const pl = labelsFor('pl')
|
|
expect(Object.keys(pl).sort()).toEqual(Object.keys(en).sort())
|
|
for (const key of Object.keys(en) as (keyof typeof en)[]) {
|
|
expect(en[key], key).not.toBe('')
|
|
expect(pl[key], key).not.toBe('')
|
|
}
|
|
expect(pl.tabSource).not.toBe(en.tabSource)
|
|
expect(labelsFor('pl-PL')).toBe(pl)
|
|
expect(labelsFor('PL')).toBe(pl)
|
|
expect(labelsFor('de')).toBe(en)
|
|
expect(labelsFor('')).toBe(en)
|
|
expect(labelsFor(null)).toBe(en)
|
|
expect(labelsFor(undefined)).toBe(en)
|
|
})
|
|
|
|
it('follows the ui-locale attribute of the element', () => {
|
|
const { el } = mount('<p>a</p>', { 'ui-locale': 'pl' })
|
|
expect(el.querySelector('button[data-tab="source"]')!.textContent).toBe(labelsFor('pl').tabSource)
|
|
const { el: other } = mount('<p>a</p>', { 'ui-locale': 'fr' })
|
|
expect(other.querySelector('button[data-tab="source"]')!.textContent).toBe(labelsFor('en').tabSource)
|
|
})
|
|
|
|
it('format fills known placeholders and leaves unknown ones', () => {
|
|
expect(format('Uploading {name}…', { name: 'a.png' })).toBe('Uploading a.png…')
|
|
expect(format('{a} and {b}', { a: '1' })).toBe('1 and {b}')
|
|
})
|
|
})
|
|
|
|
describe('disabled and invalid', () => {
|
|
it('disables the formatting buttons and the source while disabled, and marks the editor invalid', () => {
|
|
const { el } = mount('<p>a</p>', { disabled: '', invalid: '' })
|
|
expect(el.editor!.isEditable).toBe(false)
|
|
expect(button(el, 'bold').disabled).toBe(true)
|
|
expect(el.querySelector('textarea')!.readOnly).toBe(true)
|
|
expect(el.editor!.view.dom.getAttribute('aria-invalid')).toBe('true')
|
|
// The preview toggle stays usable: it never edits.
|
|
expect(button(el, 'preview').disabled).toBe(false)
|
|
el.removeAttribute('disabled')
|
|
expect(button(el, 'bold').disabled).toBe(false)
|
|
})
|
|
|
|
it('emits nothing for clicks on a disabled editor', () => {
|
|
const { el, changes } = mount('<p>a</p>', { disabled: '' })
|
|
click(button(el, 'bold'))
|
|
click(button(el, 'embed'))
|
|
expect(changes).toEqual([])
|
|
expect(el.value).toBe('<p>a</p>')
|
|
})
|
|
})
|