import { afterEach, beforeAll, describe, expect, it } from 'vitest' import { safeImageURL } from '../../assets/src/elements/image' import { labelsFor } from '../../assets/src/elements/labels' // E7 golem15-seo-image: which stored URLs the preview may load, the // upload round trip through summer-upload / summer-upload-result, errors // and Remove. beforeAll(async () => { await import('../../assets/src/elements/index') }) afterEach(() => { document.body.replaceChildren() }) describe('E7 safeImageURL', () => { it('accepts a path and an http(s) URL', () => { for (const url of ['/media/a.png', 'https://cdn.example/a.png', 'HTTP://cdn.example/a.png']) { expect(safeImageURL(url), url).toBe(true) } }) it('refuses addresses a browser reads as another host or another scheme (WR-20)', () => { for (const url of ['//evil.example/x.png', '/\\evil.example/x.png', 'javascript:alert(1)', 'data:image/png;base64,AA', 'a.png', '']) { expect(safeImageURL(url), url).toBe(false) } }) }) describe('E7 element (WR-25)', () => { type ImageNode = HTMLElement & { value: string } function mount(value = ''): { node: ImageNode; changes: string[]; uploads: { id: string; file: File }[] } { const node = document.createElement('golem15-seo-image') as ImageNode node.setAttribute('ui-locale', 'en') document.body.append(node) node.value = value const changes: string[] = [] const uploads: { id: string; file: File }[] = [] node.addEventListener('summer-change', (ev) => changes.push((ev as CustomEvent).detail.value)) node.addEventListener('summer-upload', (ev) => uploads.push((ev as CustomEvent).detail)) return { node, changes, uploads } } function choose(node: HTMLElement): File { const input = node.querySelector('input[type="file"]')! const file = new File(['x'], 'a.png', { type: 'image/png' }) Object.defineProperty(input, 'files', { value: [file] }) input.dispatchEvent(new Event('change')) return file } function status(node: HTMLElement): string { return node.querySelector('.seo-image__status')?.textContent ?? '' } it('shows a safe stored image and hides an unsafe one', () => { expect(mount('/media/a.png').node.querySelector('img')?.getAttribute('src')).toBe('/media/a.png') expect(mount('/\\evil.example/x.png').node.querySelector('img')).toBeNull() }) it('uploads through summer-upload and takes only the answer to its own id', () => { const { node, changes, uploads } = mount() const file = choose(node) expect(uploads).toHaveLength(1) expect(uploads[0].file).toBe(file) expect(status(node)).toBe(labelsFor('en').imageUploading) node.dispatchEvent(new CustomEvent('summer-upload-result', { detail: { id: 'other', url: '/media/wrong.png' } })) expect(changes).toEqual([]) expect(status(node)).toBe(labelsFor('en').imageUploading) node.dispatchEvent(new CustomEvent('summer-upload-result', { detail: { id: uploads[0].id, url: '/media/new.png' } })) expect(changes).toEqual(['/media/new.png']) expect(node.value).toBe('/media/new.png') expect(node.querySelector('img')?.getAttribute('src')).toBe('/media/new.png') expect(status(node)).toBe('') }) it('shows the upload error, or imageFailed when the answer names none', () => { const { node, changes, uploads } = mount() choose(node) node.dispatchEvent(new CustomEvent('summer-upload-result', { detail: { id: uploads[0].id, error: 'Too big.' } })) expect(status(node)).toBe('Too big.') choose(node) node.dispatchEvent(new CustomEvent('summer-upload-result', { detail: { id: uploads[1].id } })) expect(status(node)).toBe(labelsFor('en').imageFailed) expect(node.querySelector('.seo-image__status--error')).not.toBeNull() expect(changes).toEqual([]) }) it('Remove clears the value; a disabled element offers no actions', () => { const { node, changes } = mount('/media/a.png') node.querySelector('.seo-image__button--secondary')!.click() expect(changes).toEqual(['']) expect(node.querySelector('img')).toBeNull() const off = mount('/media/a.png').node off.setAttribute('disabled', '') expect(off.querySelector('.seo-image__actions')).toBeNull() }) })