fix(admin): render markdown preview from server-sanitized HTML
- MarkdownField posts the source to POST /markdown/preview when Preview opens and again 300 ms after a change while open; stale answers dropped - the pane binds only data.html of a 2xx answer; a refusal is a text notice - mlmarkdown previews the active locale and follows a locale switch - .summer-markdown style kit restores headings, lists, code and tables - vitest coverage, forms.md and rebuilt modules/boardwalk/dist
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, mount } from '@vue/test-utils'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { ref } from 'vue'
|
||||
import type { FormField } from '../../src/api/types'
|
||||
import MarkdownField from '../../src/components/form/fields/MarkdownField.vue'
|
||||
@@ -8,7 +8,7 @@ import MLTextField from '../../src/components/form/fields/MLTextField.vue'
|
||||
import { FORM_ENABLED_LOCALES } from '../../src/components/form/formContext'
|
||||
import { editablePayload, initialValues, mergeMLValue } from '../../src/components/form/formState'
|
||||
import { isRegistered, rendererFor } from '../../src/components/form/registry'
|
||||
import { resetState } from '../helpers'
|
||||
import { API, mockApi, resetState } from '../helpers'
|
||||
|
||||
function field(type: string, name = 'title'): FormField {
|
||||
return { name, label: 'Title', type } as FormField
|
||||
@@ -126,6 +126,7 @@ describe('ML field registry and nested save body', () => {
|
||||
})
|
||||
|
||||
it('does not execute raw HTML in the markdown preview', async () => {
|
||||
mockApi({ [`POST ${API}/markdown/preview`]: { body: { data: { html: '<p><!-- raw HTML omitted --></p>\n' }, meta: {} } } })
|
||||
const wrapper = mount(MarkdownField, {
|
||||
props: {
|
||||
field: field('markdown', 'body'),
|
||||
@@ -135,10 +136,10 @@ describe('ML field registry and nested save body', () => {
|
||||
attachTo: document.body,
|
||||
})
|
||||
await wrapper.find('[data-markdown-preview]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('script').exists()).toBe(false)
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
expect((window as unknown as { __ml_xss?: number }).__ml_xss).toBeUndefined()
|
||||
expect(wrapper.find('[data-markdown-preview-pane]').text()).toContain('<script>')
|
||||
})
|
||||
|
||||
it('seeds empty maps and lists every enabled locale on create', () => {
|
||||
|
||||
@@ -1,68 +1,205 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { ref } from 'vue'
|
||||
import type { FormField } from '../../src/api/types'
|
||||
import MarkdownField from '../../src/components/form/fields/MarkdownField.vue'
|
||||
import MLMarkdownField from '../../src/components/form/fields/MLMarkdownField.vue'
|
||||
import { FORM_ENABLED_LOCALES } from '../../src/components/form/formContext'
|
||||
import { resetState } from '../helpers'
|
||||
import { API, mockApi, requestsTo, resetState, type Reply } from '../helpers'
|
||||
|
||||
const PREVIEW = `${API}/markdown/preview`
|
||||
|
||||
function field(type = 'markdown', name = 'body'): FormField {
|
||||
return { name, label: 'Body', type } as FormField
|
||||
}
|
||||
|
||||
function html(value: string): Reply {
|
||||
return { body: { data: { html: value }, meta: {} } }
|
||||
}
|
||||
|
||||
function mountField(modelValue: string) {
|
||||
return mount(MarkdownField, {
|
||||
props: { field: field(), modelValue, controlId: 'f-body' },
|
||||
attachTo: document.body,
|
||||
})
|
||||
}
|
||||
|
||||
async function bodyOf(request: Request): Promise<unknown> {
|
||||
return request.clone().json()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
document.body.innerHTML = ''
|
||||
delete (window as unknown as { __md_xss?: number }).__md_xss
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('MarkdownField composition and sinks', () => {
|
||||
it('edits source and previews interpolated text without v-html', async () => {
|
||||
const wrapper = mount(MarkdownField, {
|
||||
props: {
|
||||
field: field(),
|
||||
modelValue: '# Hello',
|
||||
controlId: 'f-body',
|
||||
},
|
||||
attachTo: document.body,
|
||||
})
|
||||
describe('MarkdownField preview', () => {
|
||||
it('posts the source on Preview and renders the server HTML', async () => {
|
||||
const calls = mockApi({ [`POST ${PREVIEW}`]: html('<h1>Hello</h1>\n') })
|
||||
const wrapper = mountField('# Hello')
|
||||
expect(wrapper.find('textarea').element.value).toBe('# Hello')
|
||||
|
||||
await wrapper.find('[data-markdown-preview]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const sent = requestsTo(calls, 'POST', PREVIEW)
|
||||
expect(sent).toHaveLength(1)
|
||||
expect(await bodyOf(sent[0]!)).toEqual({ markdown: '# Hello' })
|
||||
expect(sent[0]!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
|
||||
const pane = wrapper.find('[data-markdown-preview-pane]')
|
||||
expect(pane.find('h1').text()).toBe('Hello')
|
||||
expect(pane.attributes('aria-busy')).toBe('false')
|
||||
expect(wrapper.find('textarea').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('binds the server answer, not the source', async () => {
|
||||
mockApi({ [`POST ${PREVIEW}`]: html('<p><!-- raw HTML omitted --></p>\n') })
|
||||
const wrapper = mountField('<img src=x onerror="window.__md_xss = 1"><script>window.__md_xss = 1</script>')
|
||||
await wrapper.find('[data-markdown-preview]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
expect(wrapper.find('script').exists()).toBe(false)
|
||||
expect((window as unknown as { __md_xss?: number }).__md_xss).toBeUndefined()
|
||||
expect(wrapper.find('[data-markdown-preview-pane] p').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows a refused output as a text notice', async () => {
|
||||
const message = 'The rendered HTML was refused <b>here</b>.'
|
||||
mockApi({
|
||||
[`POST ${PREVIEW}`]: {
|
||||
status: 422,
|
||||
body: { error: { code: 'validation_failed', message: 'Validation failed', details: { markdown: [message] } } },
|
||||
},
|
||||
})
|
||||
const wrapper = mountField('<b>see</b> data: here')
|
||||
await wrapper.find('[data-markdown-preview]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const notice = wrapper.find('[data-markdown-preview-error]')
|
||||
expect(notice.exists()).toBe(true)
|
||||
expect(notice.text()).toBe(message)
|
||||
expect(notice.find('b').exists()).toBe(false)
|
||||
const pane = wrapper.find('[data-markdown-preview-pane]')
|
||||
expect(pane.element.children).toHaveLength(0)
|
||||
expect(pane.text()).toBe('')
|
||||
expect(wrapper.find('b').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows a generic notice when the request fails', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('network down'))
|
||||
const wrapper = mountField('# Hello')
|
||||
await wrapper.find('[data-markdown-preview]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-markdown-preview-error]').text()).toBe('Preview unavailable.')
|
||||
expect(wrapper.find('[data-markdown-preview-pane]').element.children).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('sends nothing for a blank source', async () => {
|
||||
const calls = mockApi({ [`POST ${PREVIEW}`]: html('<p>x</p>') })
|
||||
const wrapper = mountField(' \n ')
|
||||
await wrapper.find('[data-markdown-preview]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requestsTo(calls, 'POST', PREVIEW)).toHaveLength(0)
|
||||
expect(wrapper.find('[data-markdown-preview-pane]').text()).toBe('')
|
||||
expect(wrapper.find('[data-markdown-preview-error]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not fetch while editing with Preview closed', async () => {
|
||||
const calls = mockApi({ [`POST ${PREVIEW}`]: html('<p>x</p>') })
|
||||
const wrapper = mountField('# Hello')
|
||||
await wrapper.find('textarea').setValue('# Changed')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['# Changed'])
|
||||
await wrapper.setProps({ modelValue: '# Changed' })
|
||||
await wrapper.find('[data-markdown-preview]').trigger('click')
|
||||
expect(wrapper.find('[data-markdown-preview-pane]').text()).toBe('# Changed')
|
||||
expect(wrapper.find('h1').exists()).toBe(false)
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'POST', PREVIEW)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not execute script, iframe, or event-handler HTML', async () => {
|
||||
const wrapper = mount(MarkdownField, {
|
||||
props: {
|
||||
field: field(),
|
||||
modelValue:
|
||||
'<script>window.__md_xss = 1</script><iframe src="javascript:alert(1)"></iframe><img src=x onerror="window.__md_xss = 1">',
|
||||
controlId: 'f-body',
|
||||
it('re-fetches once, debounced, when the source changes while open', async () => {
|
||||
vi.useFakeTimers()
|
||||
const calls = mockApi({
|
||||
[`POST ${PREVIEW}`]: async (request) => {
|
||||
const { markdown } = (await request.json()) as { markdown: string }
|
||||
return html(markdown === '# Hello' ? '<h1>Hello</h1>' : '<h2>Changed</h2>')
|
||||
},
|
||||
attachTo: document.body,
|
||||
})
|
||||
const wrapper = mountField('# Hello')
|
||||
await wrapper.find('[data-markdown-preview]').trigger('click')
|
||||
expect(wrapper.find('script').exists()).toBe(false)
|
||||
expect(wrapper.find('iframe').exists()).toBe(false)
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
expect((window as unknown as { __md_xss?: number }).__md_xss).toBeUndefined()
|
||||
expect(wrapper.html()).not.toMatch(/v-html|innerHTML/)
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'POST', PREVIEW)).toHaveLength(1)
|
||||
|
||||
await wrapper.setProps({ modelValue: '## Chan' })
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await wrapper.setProps({ modelValue: '## Changed' })
|
||||
await vi.advanceTimersByTimeAsync(299)
|
||||
expect(requestsTo(calls, 'POST', PREVIEW)).toHaveLength(1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await flushPromises()
|
||||
|
||||
const sent = requestsTo(calls, 'POST', PREVIEW)
|
||||
expect(sent).toHaveLength(2)
|
||||
expect(await bodyOf(sent[1]!)).toEqual({ markdown: '## Changed' })
|
||||
expect(wrapper.find('[data-markdown-preview-pane] h2').text()).toBe('Changed')
|
||||
})
|
||||
|
||||
it('composes inside mlmarkdown without a raw-HTML sink', async () => {
|
||||
it('ignores an older answer that resolves after a newer one', async () => {
|
||||
vi.useFakeTimers()
|
||||
const pending: Array<(reply: Reply) => void> = []
|
||||
const calls = mockApi({
|
||||
[`POST ${PREVIEW}`]: () => new Promise<Reply>((resolve) => pending.push(resolve)),
|
||||
})
|
||||
const wrapper = mountField('# Old')
|
||||
await wrapper.find('[data-markdown-preview]').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.setProps({ modelValue: '# New' })
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'POST', PREVIEW)).toHaveLength(2)
|
||||
expect(pending).toHaveLength(2)
|
||||
|
||||
pending[1]!(html('<h1>New</h1>'))
|
||||
await flushPromises()
|
||||
pending[0]!(html('<h1>Old</h1>'))
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-markdown-preview-pane] h1').text()).toBe('New')
|
||||
})
|
||||
|
||||
it('returns to the unchanged source when Preview is clicked again', async () => {
|
||||
mockApi({ [`POST ${PREVIEW}`]: html('<h1>Hello</h1>') })
|
||||
const wrapper = mountField('# Hello')
|
||||
await wrapper.find('[data-markdown-preview]').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.find('[data-markdown-preview]').trigger('click')
|
||||
|
||||
expect(wrapper.find('[data-markdown-preview-pane]').exists()).toBe(false)
|
||||
expect(wrapper.find('textarea').element.value).toBe('# Hello')
|
||||
expect(wrapper.find('[data-markdown-preview]').attributes('aria-pressed')).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
describe('MarkdownField inside mlmarkdown', () => {
|
||||
it('previews the active locale and follows a locale switch', async () => {
|
||||
vi.useFakeTimers()
|
||||
const calls = mockApi({
|
||||
[`POST ${PREVIEW}`]: async (request) => {
|
||||
const { markdown } = (await request.json()) as { markdown: string }
|
||||
return html(markdown === '# Hi' ? '<h1>Hi</h1>' : '<h1>Hallo</h1>')
|
||||
},
|
||||
})
|
||||
const wrapper = mount(MLMarkdownField, {
|
||||
props: {
|
||||
field: field('mlmarkdown', 'body'),
|
||||
modelValue: { en: '# Hi', pl: '<script>window.__md_xss = 1</script>' },
|
||||
modelValue: { en: '# Hi', pl: '# Hallo' },
|
||||
controlId: 'f-body',
|
||||
},
|
||||
attachTo: document.body,
|
||||
@@ -73,10 +210,17 @@ describe('MarkdownField composition and sinks', () => {
|
||||
},
|
||||
})
|
||||
expect(wrapper.findComponent(MarkdownField).exists()).toBe(true)
|
||||
await wrapper.find('[data-ml-locale]').setValue('pl')
|
||||
await wrapper.find('[data-markdown-preview]').trigger('click')
|
||||
expect(wrapper.find('script').exists()).toBe(false)
|
||||
expect((window as unknown as { __md_xss?: number }).__md_xss).toBeUndefined()
|
||||
expect(wrapper.find('[data-markdown-preview-pane]').text()).toContain('<script>')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-markdown-preview-pane] h1').text()).toBe('Hi')
|
||||
|
||||
await wrapper.find('[data-ml-locale]').setValue('pl')
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
await flushPromises()
|
||||
|
||||
const sent = requestsTo(calls, 'POST', PREVIEW)
|
||||
expect(sent).toHaveLength(2)
|
||||
expect(await bodyOf(sent[1]!)).toEqual({ markdown: '# Hallo' })
|
||||
expect(wrapper.find('[data-markdown-preview-pane] h1').text()).toBe('Hallo')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user