diff --git a/src/components/Toolbar.test.tsx b/src/components/Toolbar.test.tsx index aeaf6895..8300110a 100644 --- a/src/components/Toolbar.test.tsx +++ b/src/components/Toolbar.test.tsx @@ -112,7 +112,7 @@ describe('Toolbar', () => { const italic = screen.getByRole('button', { name: /Italic/ }); const insertTable = screen.getByRole('button', { name: /^Insert table$/ }); const insertImage = screen.getByRole('button', { - name: /Insert inline \(base64\) image/, + name: /Insert inline image/, }); const enabledButtons = ( screen.getAllByRole('button') as HTMLButtonElement[] @@ -149,7 +149,7 @@ describe('Toolbar', () => { const bold = screen.getByRole('button', { name: /Bold/ }); const insertImage = screen.getByRole('button', { - name: /Insert inline \(base64\) image/, + name: /Insert inline image/, }); fireEvent.focus(insertImage); expect(insertImage).toHaveAttribute('tabindex', '0'); diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 55136e55..60da7703 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -8,7 +8,9 @@ import { type FocusEvent, type KeyboardEvent, } from 'react'; +import { Base64SizeError } from '../converter/base64.js'; import { imageFileToInlineDataUri } from '../extensions/Base64Image.js'; +import { isSafeLinkHref } from '../extensions/SafeLink.js'; import type { ImageConfig } from '../types.js'; interface ToolbarProps { @@ -30,6 +32,27 @@ interface ButtonProps { const TOOLBAR_ITEM_SELECTOR = 'button[data-cwl-toolbar-item="true"]'; +/** Read a genuine Blob's byte length without invoking caller-owned accessors. */ +function intrinsicBlobSize(blob: Blob): number { + const sizeGetter = Object.getOwnPropertyDescriptor( + globalThis.Blob.prototype, + 'size', + )!.get!; + return Reflect.apply(sizeGetter, blob, []) as number; +} + +/** Report an image failure without allowing host observer code to alter toolbar control flow. */ +function reportImageError( + onImageError: ((error: unknown) => void) | undefined, + error: unknown, +): void { + try { + onImageError?.(error); + } catch { + // Host presentation or telemetry observers are best-effort only. + } +} + /** Return every toolbar button in visual and DOM navigation order. */ function getToolbarButtons(toolbar: HTMLDivElement): HTMLButtonElement[] { return Array.from( @@ -173,6 +196,7 @@ export function Toolbar({ editor, image, onImageError }: ToolbarProps) { editor.chain().focus().extendMarkRange('link').unsetLink().run(); return; } + if (!isSafeLinkHref(url)) return; editor .chain() .focus() @@ -203,18 +227,30 @@ export function Toolbar({ editor, image, onImageError }: ToolbarProps) { event.target.value = ''; if (!file) return; + const maxSizeBytes = image?.maxSizeBytes ?? 10 * 1024 * 1024; + const sourceBytes = intrinsicBlobSize(file); + if (maxSizeBytes > 0 && sourceBytes > maxSizeBytes) { + reportImageError( + onImageError, + new Base64SizeError(sourceBytes, maxSizeBytes), + ); + return; + } + let src: string; try { src = await imageFileToInlineDataUri(file, { - maxSizeBytes: image?.maxSizeBytes ?? 10 * 1024 * 1024, + maxSizeBytes, maxDimension: image?.maxDimension ?? 1600, quality: image?.quality ?? 0.85, }); - } catch (err) { - onImageError?.(err); + } catch { + reportImageError(onImageError, new Error('Image processing failed.')); return; } + if (editor.isDestroyed || !editor.isEditable) return; + const alternativeText = window.prompt( 'Image alternative text. Leave empty only if this image is decorative.', '', @@ -368,7 +404,7 @@ export function Toolbar({ editor, image, onImageError }: ToolbarProps) { onClick={() => editor.chain().focus().deleteTable().run()} /> fileInputRef.current?.click()} /> diff --git a/src/components/ToolbarCustomerCopy.test.tsx b/src/components/ToolbarCustomerCopy.test.tsx new file mode 100644 index 00000000..4e56612b --- /dev/null +++ b/src/components/ToolbarCustomerCopy.test.tsx @@ -0,0 +1,34 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import { Editor } from '@tiptap/react'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { buildExtensions } from '../extensions/kit.js'; +import { Toolbar } from './Toolbar.js'; + +let editor: Editor | undefined; + +afterEach(() => { + cleanup(); + if (editor && !editor.isDestroyed) editor.destroy(); + editor = undefined; +}); + +describe('Toolbar customer-facing image action copy', () => { + it('names the image action without exposing base64 implementation jargon', () => { + const element = document.createElement('div'); + editor = new Editor({ + element, + extensions: buildExtensions({ image: { maxDimension: 0 } }), + content: '

before

', + }); + + render(); + + expect( + screen.getByRole('button', { name: 'Insert inline image' }), + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /base64/i }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/ToolbarImageLifecycle.test.tsx b/src/components/ToolbarImageLifecycle.test.tsx new file mode 100644 index 00000000..8f43f24c --- /dev/null +++ b/src/components/ToolbarImageLifecycle.test.tsx @@ -0,0 +1,166 @@ +import { act, cleanup, fireEvent, render } from '@testing-library/react'; +import { Editor } from '@tiptap/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { buildExtensions } from '../extensions/kit.js'; +import { Toolbar } from './Toolbar.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 delayedPngFile(delayMs = 25): File { + const file = new File([PNG_BYTES], 'slow.png', { type: 'image/png' }); + Object.defineProperty(file, 'arrayBuffer', { + configurable: true, + value: () => + new Promise((resolve) => { + setTimeout(() => resolve(PNG_BYTES.slice().buffer), delayMs); + }), + }); + return file; +} + +function fileInput(): HTMLInputElement { + return document.querySelector('input[type="file"]') as HTMLInputElement; +} + +async function settleConversion(): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 45)); + }); +} + +afterEach(() => { + cleanup(); + for (const { editor, element } of openEditors.splice(0)) { + if (!editor.isDestroyed) editor.destroy(); + element.remove(); + } + vi.restoreAllMocks(); +}); + +describe('Toolbar asynchronous image-upload lifecycle boundary', () => { + it('does not prompt or mutate after the editor becomes read-only', async () => { + const editor = makeEditor(); + const before = editor.getHTML(); + const prompt = vi.spyOn(window, 'prompt').mockReturnValue('stale image'); + render(); + + fireEvent.change(fileInput(), { target: { files: [delayedPngFile()] } }); + editor.setEditable(false); + await settleConversion(); + + expect(prompt).not.toHaveBeenCalled(); + expect(editor.getHTML()).toBe(before); + expect(editor.getHTML()).not.toContain('data:image/png;base64'); + }); + + it('does not prompt after the editor is destroyed during conversion', async () => { + const editor = makeEditor(); + const prompt = vi.spyOn(window, 'prompt').mockReturnValue('stale image'); + render(); + + fireEvent.change(fileInput(), { target: { files: [delayedPngFile()] } }); + editor.destroy(); + await settleConversion(); + + expect(prompt).not.toHaveBeenCalled(); + expect(editor.isDestroyed).toBe(true); + }); + + it('does not expose hostile conversion throw values to the host error callback', async () => { + const editor = makeEditor(); + const before = editor.getHTML(); + const privateSentinel = new Error('private toolbar conversion sentinel'); + const getPrototypeOf = vi.fn(() => { + throw privateSentinel; + }); + const hostileThrownValue = new Proxy({}, { getPrototypeOf }); + const hostileValues = new WeakSet([hostileThrownValue]); + const file = new File([PNG_BYTES], 'hostile.png', { type: 'image/png' }); + Object.defineProperty(file, 'arrayBuffer', { + configurable: true, + value: vi.fn().mockRejectedValue(hostileThrownValue), + }); + + let leakedHostileValue = false; + let observedError: unknown; + const onImageError = vi.fn((error: unknown) => { + observedError = error; + if ( + typeof error === 'object' && + error !== null && + hostileValues.has(error) + ) { + leakedHostileValue = true; + } + }); + const prompt = vi.spyOn(window, 'prompt').mockReturnValue('should not run'); + render( + , + ); + + fireEvent.change(fileInput(), { target: { files: [file] } }); + await settleConversion(); + + expect(onImageError).toHaveBeenCalledOnce(); + expect(leakedHostileValue).toBe(false); + expect(getPrototypeOf).not.toHaveBeenCalled(); + expect(observedError).toBeInstanceOf(Error); + expect((observedError as Error).message).toBe('Image processing failed.'); + expect(prompt).not.toHaveBeenCalled(); + expect(editor.getHTML()).toBe(before); + expect(editor.getHTML()).not.toContain('data:image'); + }); + + it('contains host image-error observer failures after conversion rejection', async () => { + const editor = makeEditor(); + const before = editor.getHTML(); + const privateSentinel = new Error('private toolbar observer sentinel'); + const failedFile = new File([PNG_BYTES], 'failed.png', { type: 'image/png' }); + Object.defineProperty(failedFile, 'arrayBuffer', { + configurable: true, + value: vi.fn().mockRejectedValue(new Error('private conversion failure')), + }); + const onImageError = vi.fn(() => { + throw privateSentinel; + }); + const prompt = vi.spyOn(window, 'prompt').mockReturnValue('should not run'); + + render( + , + ); + + fireEvent.change(fileInput(), { target: { files: [failedFile] } }); + await settleConversion(); + + expect(onImageError).toHaveBeenCalledOnce(); + expect(prompt).not.toHaveBeenCalled(); + expect(editor.getHTML()).toBe(before); + }); +}); diff --git a/src/components/ToolbarLinkPolicy.test.tsx b/src/components/ToolbarLinkPolicy.test.tsx new file mode 100644 index 00000000..fee56eee --- /dev/null +++ b/src/components/ToolbarLinkPolicy.test.tsx @@ -0,0 +1,50 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { Editor } from '@tiptap/react'; +import StarterKit from '@tiptap/starter-kit'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { Toolbar } from './Toolbar.js'; + +const openEditors: Editor[] = []; + +function makeEditor(): Editor { + const element = document.createElement('div'); + document.body.appendChild(element); + const editor = new Editor({ + element, + extensions: [StarterKit], + content: '

link target

', + }); + openEditors.push(editor); + return editor; +} + +afterEach(() => { + cleanup(); + for (const editor of openEditors.splice(0)) { + if (!editor.isDestroyed) editor.destroy(); + } + vi.restoreAllMocks(); +}); + +describe('Toolbar link policy boundary', () => { + it('rejects an executable URL before issuing an editor command', () => { + const editor = makeEditor(); + const commandChain = { + focus: vi.fn(() => commandChain), + extendMarkRange: vi.fn(() => commandChain), + setLink: vi.fn(() => commandChain), + unsetLink: vi.fn(() => commandChain), + run: vi.fn(() => true), + }; + vi.spyOn(editor, 'chain').mockReturnValue( + commandChain as unknown as ReturnType, + ); + vi.spyOn(window, 'prompt').mockReturnValue('javascript:alert(1)'); + + render(); + fireEvent.click(screen.getByRole('button', { name: /Insert\/edit link/ })); + + expect(commandChain.setLink).not.toHaveBeenCalled(); + expect(commandChain.run).not.toHaveBeenCalled(); + }); +});