diff --git a/src/extensions/Base64Image.hostileError.test.ts b/src/extensions/Base64Image.hostileError.test.ts new file mode 100644 index 00000000..67a1e425 --- /dev/null +++ b/src/extensions/Base64Image.hostileError.test.ts @@ -0,0 +1,106 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { waitFor } from '@testing-library/react'; +import { Editor } from '@tiptap/react'; +import StarterKit from '@tiptap/starter-kit'; +import { Base64Image, base64ImagePluginKey } from './Base64Image.js'; + +const PNG_BYTES = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, + 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, + 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, +]); + +const openEditors: Editor[] = []; + +afterEach(() => { + for (const editor of openEditors.splice(0)) { + if (!editor.isDestroyed) editor.destroy(); + } + vi.restoreAllMocks(); +}); + +function makeEditor( + onError: (error: Error) => void, + content = '

hello

', +): Editor { + const element = document.createElement('div'); + document.body.appendChild(element); + const editor = new Editor({ + element, + content, + extensions: [ + StarterKit, + Base64Image.configure({ + maxSizeBytes: 1024 * 1024, + maxDimension: 0, + quality: 0.85, + onError, + }), + ], + }); + openEditors.push(editor); + return editor; +} + +function paste(editor: Editor, event: unknown): boolean { + const plugin = base64ImagePluginKey.get(editor.state)!; + return (plugin.props.handlePaste as (view: unknown, event: unknown) => boolean)( + editor.view, + event, + ); +} + +describe('Base64Image hostile conversion failure containment', () => { + it('does not inspect a hostile thrown value before reporting a redacted error', async () => { + const privateSentinel = new Error('private image conversion sentinel'); + const getPrototypeOf = vi.fn(() => { + throw privateSentinel; + }); + const hostileThrownValue = new Proxy({}, { getPrototypeOf }); + const file = new File([PNG_BYTES], 'hostile.png', { type: 'image/png' }); + Object.defineProperty(file, 'arrayBuffer', { + configurable: true, + value: vi.fn().mockRejectedValue(hostileThrownValue), + }); + + const onError = vi.fn<(error: Error) => void>(); + const editor = makeEditor(onError); + const preventDefault = vi.fn(); + + expect( + paste(editor, { + clipboardData: { + items: [{ kind: 'file', getAsFile: () => file }], + }, + preventDefault, + }), + ).toBe(true); + expect(preventDefault).toHaveBeenCalledOnce(); + + await waitFor(() => expect(onError).toHaveBeenCalledOnce()); + expect(getPrototypeOf).not.toHaveBeenCalled(); + expect(onError.mock.calls[0][0]).toBeInstanceOf(Error); + expect(onError.mock.calls[0][0].message).toBe('Image processing failed.'); + expect(editor.getHTML()).not.toContain('data:image'); + }); + + it('contains host error-observer failures while rejecting unsafe parsed images', () => { + const privateSentinel = new Error('private image observer sentinel'); + const onError = vi.fn<(error: Error) => void>(() => { + throw privateSentinel; + }); + + let editor: Editor | undefined; + expect(() => { + editor = makeEditor( + onError, + '

before

after

', + ); + }).not.toThrow(); + + expect(onError).toHaveBeenCalledOnce(); + expect(editor?.getHTML()).not.toContain(' { }); it('embeds a pasted image file as inline base64', async () => { + vi.spyOn(window, 'prompt').mockReturnValue(''); const editor = track(makeEditor()); const items = [ { kind: 'file', getAsFile: () => null }, @@ -293,6 +294,7 @@ describe('Base64Image drop handler', () => { }); it('embeds a dropped image at the resolved drop coordinates', async () => { + vi.spyOn(window, 'prompt').mockReturnValue(''); const editor = track(makeEditor()); vi.spyOn(editor.view, 'posAtCoords').mockReturnValue({ pos: 1, @@ -314,6 +316,7 @@ describe('Base64Image drop handler', () => { }); it('falls back to the current selection when coords resolve to nothing', async () => { + vi.spyOn(window, 'prompt').mockReturnValue(''); const editor = track(makeEditor()); vi.spyOn(editor.view, 'posAtCoords').mockReturnValue(null); expect( diff --git a/src/extensions/Base64Image.ts b/src/extensions/Base64Image.ts index e506d02c..42fc9ae2 100644 --- a/src/extensions/Base64Image.ts +++ b/src/extensions/Base64Image.ts @@ -9,7 +9,7 @@ */ import Image from '@tiptap/extension-image'; import { Plugin, PluginKey } from '@tiptap/pm/state'; -import { blobToDataUri } from '../converter/base64.js'; +import { Base64SizeError, blobToDataUri } from '../converter/base64.js'; import { Base64ImageSourceError, validateInlineImageSource, @@ -46,6 +46,80 @@ export interface Base64ImageOptions { export const base64ImagePluginKey = new PluginKey('cwlBase64Image'); +const blobSizeGetter = Object.getOwnPropertyDescriptor( + globalThis.Blob.prototype, + 'size', +)!.get!; +const safeImageIngressErrors = new WeakSet(); +const INVALID_IMAGE_PROCESSING_OPTIONS_MESSAGE = + 'Image processing options must use bounded numeric values.'; + +/** Reject a malformed runtime byte or pixel limit before consuming input. */ +function assertNonNegativeSafeInteger(value: number): void { + if (!Number.isSafeInteger(value) || value < 0) { + throw new RangeError(INVALID_IMAGE_PROCESSING_OPTIONS_MESSAGE); + } +} + +/** Reject a malformed runtime image quality before browser image processing. */ +function assertImageQuality(value: number): void { + if (!Number.isFinite(value) || value < 0 || value > 1) { + throw new RangeError(INVALID_IMAGE_PROCESSING_OPTIONS_MESSAGE); + } +} + +/** Read Blob byte length from its platform internal slot, ignoring own accessors. */ +function intrinsicBlobSize(blob: Blob): number { + return Reflect.apply(blobSizeGetter, blob, []) as number; +} + +/** Mark an Inkspan-created ingress error without exposing a forgeable property. */ +function markSafeImageIngressError(error: T): T { + safeImageIngressErrors.add(error); + return error; +} + +/** Normalize an untrusted caught value without inspecting or coercing it. */ +function normalizeUntrustedImageIngressError(error: unknown): Error { + return safeImageIngressErrors.has(error as object) + ? (error as Error) + : new Error('Image processing failed.'); +} + +/** + * Notify the host about an image rejection without granting observer failures + * authority over parser, transaction-filter, or asynchronous ingress results. + */ +function reportImageError( + observer: Base64ImageOptions['onError'], + error: Error, +): void { + if (!observer) return; + try { + observer(error); + } catch { + // Host presentation/telemetry is best-effort and cannot change rejection. + } +} + +/** + * Validate a generated source and privately brand any deterministic policy + * failure so the async ingress boundary may preserve its safe diagnostic. + */ +function validateGeneratedInlineImageSource( + source: string, + maxSizeBytes: number, +): string { + try { + return validateInlineImageSource(source, maxSizeBytes); + } catch (error) { + if (typeof error === 'object' && error !== null) { + safeImageIngressErrors.add(error); + } + throw error; + } +} + /** * Downscale an image data URI using an offscreen canvas when it exceeds * `maxDimension`. Returns the original URI unchanged when no DOM is available @@ -56,10 +130,12 @@ export async function downscaleDataUri( maxDimension: number, quality: number, ): Promise { + assertNonNegativeSafeInteger(maxDimension); + assertImageQuality(quality); if ( typeof document === 'undefined' || typeof globalThis.Image === 'undefined' || - maxDimension <= 0 + maxDimension === 0 ) { return dataUri; } @@ -100,24 +176,39 @@ export async function imageFileToInlineDataUri( file: Blob, options: Pick, ): Promise { + assertNonNegativeSafeInteger(options.maxSizeBytes); + if (options.maxDimension !== undefined) { + assertNonNegativeSafeInteger(options.maxDimension); + } + assertImageQuality(options.quality); + + if (options.maxSizeBytes > 0) { + const sourceBytes = intrinsicBlobSize(file); + if (sourceBytes > options.maxSizeBytes) { + throw markSafeImageIngressError( + new Base64SizeError(sourceBytes, options.maxSizeBytes), + ); + } + } + const dataUri = await blobToDataUri(file, { maxBytes: options.maxSizeBytes > 0 ? options.maxSizeBytes : undefined, }); - validateInlineImageSource(dataUri, options.maxSizeBytes); - if (options.maxDimension && options.maxDimension > 0) { + validateGeneratedInlineImageSource(dataUri, options.maxSizeBytes); + if (options.maxDimension !== undefined && options.maxDimension > 0) { const scaled = await downscaleDataUri( dataUri, options.maxDimension, options.quality, ); - return validateInlineImageSource(scaled, options.maxSizeBytes); + return validateGeneratedInlineImageSource(scaled, options.maxSizeBytes); } return dataUri; } -/** Normalize a caught value to the Error contract exposed to hosts. */ +/** Normalize a caught value from Inkspan-controlled validation paths. */ function normalizeImageError(error: unknown): Error { - /* v8 ignore next -- all shipped validation and conversion paths throw Error. */ + /* v8 ignore next -- all shipped validation paths throw Error. */ return error instanceof Error ? error : new Error('Image processing failed.'); } @@ -155,7 +246,7 @@ export const Base64Image = Image.extend({ title: element.getAttribute('title'), }; } catch (error) { - this.options.onError?.(normalizeImageError(error)); + reportImageError(this.options.onError, normalizeImageError(error)); return false; } }, @@ -190,24 +281,45 @@ export const Base64Image = Image.extend({ const editor = this.editor; const insertFiles = (files: File[], at?: number) => { + if (!editor.isEditable) return false; const images = files.filter((file) => file.type.startsWith('image/')); if (images.length === 0) return false; - for (const file of images) { - imageFileToInlineDataUri(file, options) - .then((src) => { - if (editor.isDestroyed) return; - // New images are explicitly decorative until an author supplies - // meaningful replacement text through the toolbar. - const node = editor.schema.nodes.image.create({ src, alt: '' }); + + const insertInSourceOrder = async () => { + let insertionPosition = at; + for (const file of images) { + try { + const src = await imageFileToInlineDataUri(file, options); + if (editor.isDestroyed || !editor.isEditable) return; + const alternativeText = window.prompt( + 'Image alternative text. Leave empty only if this image is decorative.', + '', + ); + if (editor.isDestroyed || !editor.isEditable) return; + if (alternativeText === null) continue; + const node = editor.schema.nodes.image.create({ + src, + alt: alternativeText, + }); const pos = - typeof at === 'number' ? at : editor.state.selection.from; + typeof insertionPosition === 'number' + ? insertionPosition + : editor.state.selection.from; const transaction = editor.state.tr.insert(pos, node); + if (typeof insertionPosition === 'number') { + insertionPosition = transaction.mapping.map(pos, 1); + } editor.view.dispatch(transaction); - }) - .catch((error: unknown) => { - options.onError?.(normalizeImageError(error)); - }); - } + } catch (error) { + reportImageError( + options.onError, + normalizeUntrustedImageIngressError(error), + ); + } + } + }; + + void insertInSourceOrder(); return true; }; @@ -232,7 +344,7 @@ export const Base64Image = Image.extend({ } }); if (!rejection) return true; - options.onError?.(rejection); + reportImageError(options.onError, rejection); return false; }, props: { diff --git a/src/extensions/Base64ImageAlt.test.ts b/src/extensions/Base64ImageAlt.test.ts index 2176e410..c735a6a5 100644 --- a/src/extensions/Base64ImageAlt.test.ts +++ b/src/extensions/Base64ImageAlt.test.ts @@ -28,9 +28,10 @@ afterEach(() => { vi.restoreAllMocks(); }); -describe('Base64Image accessible insertion defaults', () => { - it('adds explicit empty alt text to pasted images', async () => { +describe('Base64Image explicit decorative insertion intent', () => { + it('adds explicit empty alt text to pasted images only after decorative intent', async () => { const editor = createEditor(); + const prompt = vi.spyOn(window, 'prompt').mockReturnValue(''); const plugin = base64ImagePluginKey.get(editor.state)!; const preventDefault = vi.fn(); const handled = ( @@ -45,13 +46,15 @@ describe('Base64Image accessible insertion defaults', () => { expect(handled).toBe(true); expect(preventDefault).toHaveBeenCalledOnce(); await waitFor(() => { + expect(prompt).toHaveBeenCalledOnce(); expect(editor.getHTML()).toContain('data:image/png;base64'); expect(editor.getHTML()).toContain('alt=""'); }); }); - it('adds explicit empty alt text to dropped images', async () => { + it('adds explicit empty alt text to dropped images only after decorative intent', async () => { const editor = createEditor(); + const prompt = vi.spyOn(window, 'prompt').mockReturnValue(''); const plugin = base64ImagePluginKey.get(editor.state)!; vi.spyOn(editor.view, 'posAtCoords').mockReturnValue({ pos: 0, inside: -1 }); const preventDefault = vi.fn(); @@ -67,6 +70,7 @@ describe('Base64Image accessible insertion defaults', () => { expect(handled).toBe(true); expect(preventDefault).toHaveBeenCalledOnce(); await waitFor(() => { + expect(prompt).toHaveBeenCalledOnce(); expect(editor.getHTML()).toContain('data:image/png;base64'); expect(editor.getHTML()).toContain('alt=""'); }); diff --git a/src/extensions/Base64ImageAltIntentIngress.test.ts b/src/extensions/Base64ImageAltIntentIngress.test.ts new file mode 100644 index 00000000..94b355cd --- /dev/null +++ b/src/extensions/Base64ImageAltIntentIngress.test.ts @@ -0,0 +1,105 @@ +import { waitFor } from '@testing-library/react'; +import { Editor } from '@tiptap/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { base64ImagePluginKey } from './Base64Image.js'; +import { buildExtensions } from './kit.js'; + +const PNG_BYTES = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, +]); + +const openEditors: Array<{ editor: Editor; element: HTMLDivElement }> = []; + +function makeEditor(): Editor { + const element = document.createElement('div'); + document.body.appendChild(element); + const editor = new Editor({ + element, + extensions: buildExtensions({ image: { maxDimension: 0 } }), + content: '

before

', + }); + openEditors.push({ editor, element }); + return editor; +} + +function pngFile(): File { + return new File([PNG_BYTES], 'chart.png', { type: 'image/png' }); +} + +function paste(editor: Editor, event: unknown): boolean { + const plugin = base64ImagePluginKey.get(editor.state)!; + return (plugin.props.handlePaste as (view: unknown, event: unknown) => boolean)( + editor.view, + event, + ); +} + +function drop(editor: Editor, event: unknown): boolean { + const plugin = base64ImagePluginKey.get(editor.state)!; + return (plugin.props.handleDrop as (view: unknown, event: unknown) => boolean)( + editor.view, + event, + ); +} + +afterEach(() => { + for (const { editor, element } of openEditors.splice(0)) { + if (!editor.isDestroyed) editor.destroy(); + element.remove(); + } + vi.restoreAllMocks(); +}); + +describe('Base64Image file-ingress alternative-text intent', () => { + it('requires explicit alternative text before a pasted image becomes document state', async () => { + const editor = makeEditor(); + const prompt = vi + .spyOn(window, 'prompt') + .mockReturnValue('Quarterly revenue chart'); + const file = pngFile(); + const preventDefault = vi.fn(); + + expect( + paste(editor, { + clipboardData: { + items: [{ kind: 'file', getAsFile: () => file }], + }, + preventDefault, + }), + ).toBe(true); + expect(preventDefault).toHaveBeenCalledOnce(); + + await waitFor(() => { + expect(prompt).toHaveBeenCalledWith( + 'Image alternative text. Leave empty only if this image is decorative.', + '', + ); + expect(editor.getHTML()).toContain('alt="Quarterly revenue chart"'); + }); + }); + + it('leaves the document unchanged when dropped-image alternative-text intent is canceled', async () => { + const editor = makeEditor(); + const before = editor.getHTML(); + const prompt = vi.spyOn(window, 'prompt').mockReturnValue(null); + vi.spyOn(editor.view, 'posAtCoords').mockReturnValue({ pos: 1, inside: -1 }); + const preventDefault = vi.fn(); + + expect( + drop(editor, { + dataTransfer: { files: [pngFile()] }, + clientX: 4, + clientY: 6, + preventDefault, + }), + ).toBe(true); + expect(preventDefault).toHaveBeenCalledOnce(); + + await waitFor(() => expect(prompt).toHaveBeenCalledTimes(1)); + expect(editor.getHTML()).toBe(before); + expect(editor.getHTML()).not.toContain('data:image/png;base64'); + }); +}); diff --git a/src/extensions/Base64ImageFileOrder.test.ts b/src/extensions/Base64ImageFileOrder.test.ts new file mode 100644 index 00000000..ed32e25f --- /dev/null +++ b/src/extensions/Base64ImageFileOrder.test.ts @@ -0,0 +1,100 @@ +import { waitFor } from '@testing-library/react'; +import { Editor } from '@tiptap/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { base64ImagePluginKey } from './Base64Image.js'; +import { buildExtensions } from './kit.js'; + +const PNG_BYTES = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, +]); +const JPEG_BYTES = new Uint8Array([0xff, 0xd8, 0xff, 0xd9]); + +const openEditors: Array<{ editor: Editor; element: HTMLDivElement }> = []; + +function makeEditor(): Editor { + const element = document.createElement('div'); + document.body.appendChild(element); + const editor = new Editor({ + element, + extensions: buildExtensions({ image: { maxDimension: 0 } }), + content: '

before

', + }); + openEditors.push({ editor, element }); + return editor; +} + +function controlledFile( + bytes: Uint8Array, + name: string, + type: string, + delayMs: number, +): File { + const part = bytes.slice() as unknown as BlobPart; + const file = new File([part], name, { type }); + Object.defineProperty(file, 'arrayBuffer', { + configurable: true, + value: () => + new Promise((resolve) => { + setTimeout(() => resolve(bytes.slice().buffer), delayMs); + }), + }); + return file; +} + +function drop(editor: Editor, event: unknown): boolean { + const plugin = base64ImagePluginKey.get(editor.state)!; + return (plugin.props.handleDrop as (view: unknown, event: unknown) => boolean)( + editor.view, + event, + ); +} + +afterEach(() => { + for (const { editor, element } of openEditors.splice(0)) { + if (!editor.isDestroyed) editor.destroy(); + element.remove(); + } + vi.restoreAllMocks(); +}); + +describe('Base64Image multi-file ingress ordering', () => { + it('preserves dropped file order and author intent when conversion resolves out of order', async () => { + const editor = makeEditor(); + const prompt = vi + .spyOn(window, 'prompt') + .mockReturnValueOnce('First image') + .mockReturnValueOnce('Second image'); + vi.spyOn(editor.view, 'posAtCoords').mockReturnValue({ pos: 1, inside: -1 }); + + const first = controlledFile(PNG_BYTES, 'first.png', 'image/png', 25); + const second = controlledFile(JPEG_BYTES, 'second.jpg', 'image/jpeg', 0); + const preventDefault = vi.fn(); + + expect( + drop(editor, { + dataTransfer: { files: [first, second] }, + clientX: 4, + clientY: 6, + preventDefault, + }), + ).toBe(true); + expect(preventDefault).toHaveBeenCalledOnce(); + + await waitFor(() => expect(prompt).toHaveBeenCalledTimes(2)); + await waitFor(() => { + const html = editor.getHTML(); + const pngIndex = html.indexOf('data:image/png;base64'); + const jpegIndex = html.indexOf('data:image/jpeg;base64'); + const firstAltIndex = html.indexOf('alt="First image"'); + const secondAltIndex = html.indexOf('alt="Second image"'); + + expect(pngIndex).toBeGreaterThanOrEqual(0); + expect(jpegIndex).toBeGreaterThan(pngIndex); + expect(firstAltIndex).toBeGreaterThanOrEqual(0); + expect(secondAltIndex).toBeGreaterThan(firstAltIndex); + }); + }); +}); diff --git a/src/extensions/Base64ImageOptionBounds.test.ts b/src/extensions/Base64ImageOptionBounds.test.ts new file mode 100644 index 00000000..46cbddcf --- /dev/null +++ b/src/extensions/Base64ImageOptionBounds.test.ts @@ -0,0 +1,82 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + downscaleDataUri, + imageFileToInlineDataUri, +} from './Base64Image.js'; + +const PNG_BYTES = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, + 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, + 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, +]); + +const INVALID_CONFIGURATION_MESSAGE = + 'Image processing options must use bounded numeric values.'; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('Base64Image public processing-option boundary', () => { + it.each([Number.NaN, -1, 1.5, Number.POSITIVE_INFINITY])( + 'rejects invalid maxSizeBytes %s before reading the source file', + async (maxSizeBytes) => { + const file = new File([PNG_BYTES], 'bounded.png', { type: 'image/png' }); + const arrayBuffer = vi.fn().mockRejectedValue(new Error('private file read')); + Object.defineProperty(file, 'arrayBuffer', { + configurable: true, + value: arrayBuffer, + }); + + await expect( + imageFileToInlineDataUri(file, { + maxSizeBytes, + maxDimension: 0, + quality: 0.85, + }), + ).rejects.toMatchObject({ + name: 'RangeError', + message: INVALID_CONFIGURATION_MESSAGE, + }); + expect(arrayBuffer).not.toHaveBeenCalled(); + }, + ); + + it.each([Number.NaN, -1, Number.POSITIVE_INFINITY])( + 'rejects invalid maxDimension %s before consulting browser image capabilities', + async (maxDimension) => { + vi.stubGlobal('Image', undefined); + + await expect( + downscaleDataUri('data:image/png;base64,AAAA', maxDimension, 0.85), + ).rejects.toMatchObject({ + name: 'RangeError', + message: INVALID_CONFIGURATION_MESSAGE, + }); + }, + ); + + it.each([Number.NaN, -0.1, 1.1, Number.POSITIVE_INFINITY])( + 'rejects invalid quality %s before consulting browser image capabilities', + async (quality) => { + vi.stubGlobal('Image', undefined); + + await expect( + downscaleDataUri('data:image/png;base64,AAAA', 100, quality), + ).rejects.toMatchObject({ + name: 'RangeError', + message: INVALID_CONFIGURATION_MESSAGE, + }); + }, + ); + + it('preserves zero maxDimension and boundary quality values', async () => { + vi.stubGlobal('Image', undefined); + const uri = 'data:image/png;base64,AAAA'; + + await expect(downscaleDataUri(uri, 0, 0)).resolves.toBe(uri); + await expect(downscaleDataUri(uri, 0, 1)).resolves.toBe(uri); + }); +}); diff --git a/src/extensions/Base64ImageReadOnlyIngress.test.ts b/src/extensions/Base64ImageReadOnlyIngress.test.ts new file mode 100644 index 00000000..72055141 --- /dev/null +++ b/src/extensions/Base64ImageReadOnlyIngress.test.ts @@ -0,0 +1,157 @@ +import { waitFor } from '@testing-library/react'; +import { Editor } from '@tiptap/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { base64ImagePluginKey } from './Base64Image.js'; +import { buildExtensions } from './kit.js'; + +const PNG_BYTES = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, +]); + +const openEditors: Array<{ editor: Editor; element: HTMLDivElement }> = []; + +function makeReadOnlyEditor(): Editor { + const element = document.createElement('div'); + document.body.appendChild(element); + const editor = new Editor({ + element, + editable: false, + extensions: buildExtensions({ image: { maxDimension: 0 } }), + content: '

protected

', + }); + openEditors.push({ editor, element }); + return editor; +} + +function pngFile(): File { + return new File([PNG_BYTES], 'readonly.png', { type: 'image/png' }); +} + +function paste(editor: Editor, event: unknown): boolean { + const plugin = base64ImagePluginKey.get(editor.state)!; + return (plugin.props.handlePaste as (view: unknown, event: unknown) => boolean)( + editor.view, + event, + ); +} + +function drop(editor: Editor, event: unknown): boolean { + const plugin = base64ImagePluginKey.get(editor.state)!; + return (plugin.props.handleDrop as (view: unknown, event: unknown) => boolean)( + editor.view, + event, + ); +} + +afterEach(() => { + for (const { editor, element } of openEditors.splice(0)) { + if (!editor.isDestroyed) editor.destroy(); + element.remove(); + } + vi.restoreAllMocks(); +}); + +describe('Base64Image read-only file ingress', () => { + it.each([ + ['paste', 'read-only'], + ['drop', 'read-only'], + ['paste', 'destroyed'], + ['drop', 'destroyed'], + ] as const)( + 'does not insert after %s alternative-text prompting leaves the editor %s', + async (ingress, state) => { + const editor = makeReadOnlyEditor(); + editor.setEditable(true); + const before = editor.getHTML(); + const prompt = vi.spyOn(window, 'prompt').mockImplementation(() => { + if (state === 'destroyed') editor.destroy(); + else editor.setEditable(false); + return 'Image description'; + }); + const event = { + clipboardData: { items: [{ kind: 'file', getAsFile: () => pngFile() }] }, + dataTransfer: { files: [pngFile()] }, + clientX: 1, + clientY: 1, + preventDefault: vi.fn(), + }; + vi.spyOn(editor.view, 'posAtCoords').mockReturnValue({ pos: 1, inside: -1 }); + + expect((ingress === 'paste' ? paste : drop)(editor, event)).toBe(true); + await waitFor(() => expect(prompt).toHaveBeenCalledOnce()); + if (state === 'destroyed') expect(editor.isDestroyed).toBe(true); + else expect(editor.isEditable).toBe(false); + expect(editor.getHTML()).toBe(before); + }, + ); + + it('does not claim or mutate pasted image input while the editor is read-only', async () => { + const editor = makeReadOnlyEditor(); + const before = editor.getHTML(); + const prompt = vi.spyOn(window, 'prompt').mockReturnValue('must not run'); + const preventDefault = vi.fn(); + + expect( + paste(editor, { + clipboardData: { + items: [{ kind: 'file', getAsFile: () => pngFile() }], + }, + preventDefault, + }), + ).toBe(false); + expect(preventDefault).not.toHaveBeenCalled(); + + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(prompt).not.toHaveBeenCalled(); + expect(editor.getHTML()).toBe(before); + }); + + it('does not claim or mutate dropped image input while the editor is read-only', async () => { + const editor = makeReadOnlyEditor(); + const before = editor.getHTML(); + const prompt = vi.spyOn(window, 'prompt').mockReturnValue('must not run'); + const preventDefault = vi.fn(); + vi.spyOn(editor.view, 'posAtCoords').mockReturnValue({ pos: 1, inside: -1 }); + + expect( + drop(editor, { + dataTransfer: { files: [pngFile()] }, + clientX: 1, + clientY: 1, + preventDefault, + }), + ).toBe(false); + expect(preventDefault).not.toHaveBeenCalled(); + + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(prompt).not.toHaveBeenCalled(); + expect(editor.getHTML()).toBe(before); + }); + + it('does not mutate after an accepted paste becomes read-only during conversion', async () => { + const editor = makeReadOnlyEditor(); + editor.setEditable(true); + const before = editor.getHTML(); + const prompt = vi.spyOn(window, 'prompt').mockReturnValue('must not run'); + const preventDefault = vi.fn(); + + expect( + paste(editor, { + clipboardData: { + items: [{ kind: 'file', getAsFile: () => pngFile() }], + }, + preventDefault, + }), + ).toBe(true); + expect(preventDefault).toHaveBeenCalledOnce(); + + editor.setEditable(false); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(prompt).not.toHaveBeenCalled(); + expect(editor.getHTML()).toBe(before); + }); +});