Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/components/Toolbar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down Expand Up @@ -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');
Expand Down
44 changes: 40 additions & 4 deletions src/components/Toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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(
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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.',
'',
Expand Down Expand Up @@ -368,7 +404,7 @@ export function Toolbar({ editor, image, onImageError }: ToolbarProps) {
onClick={() => editor.chain().focus().deleteTable().run()}
/>
<ToolbarButton
title="Insert inline (base64) image"
title="Insert inline image"
label="🖼"
onClick={() => fileInputRef.current?.click()}
/>
Expand Down
34 changes: 34 additions & 0 deletions src/components/ToolbarCustomerCopy.test.tsx
Original file line number Diff line number Diff line change
@@ -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: '<p>before</p>',
});

render(<Toolbar editor={editor} />);

expect(
screen.getByRole('button', { name: 'Insert inline image' }),
).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /base64/i }),
).not.toBeInTheDocument();
});
});
166 changes: 166 additions & 0 deletions src/components/ToolbarImageLifecycle.test.tsx
Original file line number Diff line number Diff line change
@@ -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: '<p>before</p>',
});
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<ArrayBuffer>((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<void> {
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(<Toolbar editor={editor} image={{ maxDimension: 0 }} />);

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(<Toolbar editor={editor} image={{ maxDimension: 0 }} />);

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<object>([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(
<Toolbar
editor={editor}
image={{ maxSizeBytes: 1024 * 1024, maxDimension: 0 }}
onImageError={onImageError}
/>,
);

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(
<Toolbar
editor={editor}
image={{ maxSizeBytes: 1024 * 1024, maxDimension: 0 }}
onImageError={onImageError}
/>,
);

fireEvent.change(fileInput(), { target: { files: [failedFile] } });
await settleConversion();

expect(onImageError).toHaveBeenCalledOnce();
expect(prompt).not.toHaveBeenCalled();
expect(editor.getHTML()).toBe(before);
});
});
50 changes: 50 additions & 0 deletions src/components/ToolbarLinkPolicy.test.tsx
Original file line number Diff line number Diff line change
@@ -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: '<p>link target</p>',
});
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<Editor['chain']>,
);
vi.spyOn(window, 'prompt').mockReturnValue('javascript:alert(1)');

render(<Toolbar editor={editor} />);
fireEvent.click(screen.getByRole('button', { name: /Insert\/edit link/ }));

expect(commandChain.setLink).not.toHaveBeenCalled();
expect(commandChain.run).not.toHaveBeenCalled();
});
});
Loading