Files
webmail/lib/__tests__/plugin-host-attachment-image.test.ts
T
Linus Rath f7a7828bfa fix: check an image attachment's size before decoding it, and decode on older browsers
The host reads the size a PNG, GIF, JPEG, WebP or BMP declares in its
header first: over 50 megapixels it answers too_big, trackers and tiny
images are never decoded, a format whose header it can't read is
unsupported, and large images are decoded at the target width. A browser
that throws a TypeError on createImageBitmap's options decodes without
them.

The tests' stand-in pictures are now PNG headers instead of JSON (the
code reads the header), so image-shrink.test.ts and
plugin-host-attachment-image.test.ts build them that way, and the first
shrink test expects the resize options on createImageBitmap.
2026-11-03 00:42:53 +01:00

147 lines
7.8 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { InstalledPlugin } from '../plugin-types';
import type { IJMAPClient } from '../jmap/client-interface';
import type { Email } from '../jmap/types';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
vi.mock('@/stores/toast-store', () => ({
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() },
}));
const exif = vi.hoisted(() => ({ read: vi.fn(() => ({ taken: '2026-10-31T14:05:30', lat: 52.52, lon: 13.4 })) }));
vi.mock('@/lib/exif', () => ({ readPhotoExif: exif.read }));
import { dispatchApiCall, PERM_PER_METHOD } from '../plugin-sandbox/host-api';
import { resetPluginRateLimits } from '../plugin-sandbox/host-api-input';
import { snapshotHostFeatures } from '../plugin-sandbox/host-features';
import { withAssistantRuntime } from '../plugin-sandbox/assistant/runtime';
// jsdom can't decode or draw images: createImageBitmap and OffscreenCanvas
// are stood in for. A "picture" is a PNG header declaring its size.
function plugin(perms: string[], id = 'ai-assistant'): InstalledPlugin {
return {
id, name: 'AI Assistant', version: '2.2.0', author: 'test', description: '',
type: 'hook', permissions: perms, grantedPermissions: perms, entrypoint: 'index.js',
enabled: true, status: 'running', settings: {},
};
}
const ASSISTANT = plugin(['email:read', 'email:blob-read']);
const call = (p: InstalledPlugin, ...args: unknown[]) => dispatchApiCall(p, 'email.getAttachmentImage', args);
const parts = {
photo: { partId: '2', blobId: 'blob-photo', name: 'whiteboard.jpg', type: 'image/jpeg', size: 30 },
logo: { partId: '3', blobId: 'blob-logo', name: 'logo.png', type: 'image/png', size: 30, disposition: 'inline', cid: '<logo1@aa>' },
pixel: { partId: '4', blobId: 'blob-pixel', name: 'p.gif', type: 'image/gif', size: 30, disposition: 'inline', cid: 'px@aa' },
pdf: { partId: '5', blobId: 'blob-pdf', name: 'agenda.pdf', type: 'application/pdf', size: 30 },
svg: { partId: '6', blobId: 'blob-svg', name: 'chart.svg', type: 'image/svg+xml', size: 30 },
huge: { partId: '7', blobId: 'blob-huge', name: 'raw.tif', type: 'image/tiff', size: 21 * 1024 * 1024 },
};
const SIZES: Record<string, { w: number; h: number }> = {
'blob-photo': { w: 3000, h: 2000 }, 'blob-logo': { w: 240, h: 160 }, 'blob-pixel': { w: 1, h: 1 },
};
function pngHeader({ w, h }: { w: number; h: number }): Uint8Array {
const b = new Uint8Array(33);
b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52]);
new DataView(b.buffer).setUint32(16, w);
new DataView(b.buffer).setUint32(20, h);
b[24] = 8;
b[25] = 2;
return b;
}
const message = {
id: 'm1', threadId: 't1', mailboxIds: { inbox: true }, keywords: {}, subject: 'Logo', receivedAt: '2026-05-01T10:00:00Z',
htmlBody: [{ partId: '1', blobId: 'b-html', size: 1, type: 'text/html' }],
bodyValues: { 1: { value: '<p><img src="cid:logo1@aa" alt="Orange logo with a stripe"><img src="cid:px@aa"><img src="https://tracker.example.com/t.gif"></p>' } },
attachments: Object.values(parts),
} as unknown as Email;
class FakeCanvas {
constructor(public width: number, public height: number) {}
getContext() {
return { drawImage() {}, fillRect() {}, getImageData: (_x: number, _y: number, w: number, h: number) => ({ data: new Uint8ClampedArray(w * h * 4).fill(255) }) };
}
async convertToBlob({ type }: { type: string }) { return new Blob([new Uint8Array([104, 105])], { type }); }
}
let client: Record<string, ReturnType<typeof vi.fn>>;
beforeEach(() => {
resetPluginRateLimits();
exif.read.mockClear();
vi.stubGlobal('OffscreenCanvas', FakeCanvas);
vi.stubGlobal('createImageBitmap', vi.fn(async (blob: Blob, options?: { resizeWidth?: number }) => {
const view = new DataView(await blob.arrayBuffer());
const w = view.getUint32(16);
const h = view.getUint32(20);
const width = options?.resizeWidth ?? w;
return { width, height: Math.round((h * width) / w), close() {} };
}));
client = {
getEmail: vi.fn(async (id: string) => (id === 'm1' ? message : null)),
fetchBlobArrayBuffer: vi.fn(async (blobId: string) => pngHeader(SIZES[blobId] ?? { w: 0, h: 0 }).buffer),
getAccountId: vi.fn(() => 'acc1'),
getMailboxes: vi.fn(async () => []),
};
useAuthStore.setState({ client: client as unknown as IJMAPClient });
useEmailStore.setState({ selectedEmail: null, emails: [], mailboxes: [], selectedMailbox: '' });
});
afterEach(() => {
vi.unstubAllGlobals();
useAuthStore.setState({ client: null });
});
describe('api.email.getAttachmentImage', () => {
it('is behind email:blob-read, offered in the runtime and announced in the host features', async () => {
expect(PERM_PER_METHOD['email.getAttachmentImage']).toBe('email:blob-read');
await expect(call(plugin(['email:read']), 'm1', 'blob-photo')).rejects.toThrow(/lacks permission "email:blob-read"/);
expect(client.fetchBlobArrayBuffer).not.toHaveBeenCalled();
expect(snapshotHostFeatures()).toMatchObject({ attachmentImages: true });
const calls: unknown[][] = [];
const api = withAssistantRuntime({} as { email?: { getAttachmentImage?: (...a: unknown[]) => unknown } }, async (method, args) => { calls.push([method, args]); return null; });
await api.email?.getAttachmentImage?.('m1', 'blob-photo', { maxSide: 512 });
expect(calls).toEqual([['email.getAttachmentImage', ['m1', 'blob-photo', { maxSide: 512 }]]]);
});
it('returns the photo shrunk to 1568 px as base64 JPEG, without EXIF unless asked', async () => {
expect(await call(ASSISTANT, 'm1', 'blob-photo')).toEqual({
name: 'whiteboard.jpg', type: 'image/jpeg', width: 1568, height: 1045, data: 'aGk=', status: 'ok',
});
expect(exif.read).not.toHaveBeenCalled();
expect(await call(ASSISTANT, 'm1', 'blob-photo', { maxSide: 600, exif: true })).toMatchObject({
width: 600, height: 400, exif: { taken: '2026-10-31T14:05:30', lat: 52.52, lon: 13.4 },
});
expect(exif.read).toHaveBeenCalledTimes(1);
});
it('gives an inline image its alt text and skips a tracking pixel', async () => {
expect(await call(ASSISTANT, 'm1', 'blob-logo')).toMatchObject({ status: 'ok', alt: 'Orange logo with a stripe', width: 240, height: 160 });
expect(await call(ASSISTANT, 'm1', 'blob-pixel')).toEqual({ name: 'p.gif', type: 'image/gif', width: 1, height: 1, data: '', status: 'tracker' });
});
it('answers unsupported for what is not a picture, SVG included, and too_big without downloading', async () => {
expect(await call(ASSISTANT, 'm1', 'blob-pdf')).toMatchObject({ status: 'unsupported', data: '' });
expect(await call(ASSISTANT, 'm1', 'blob-svg')).toMatchObject({ status: 'unsupported' });
expect(await call(ASSISTANT, 'm1', 'blob-huge')).toMatchObject({ status: 'too_big' });
expect(client.fetchBlobArrayBuffer).not.toHaveBeenCalled();
});
it('refuses a blob that is not an attachment of that message', async () => {
await expect(call(ASSISTANT, 'm1', 'b-html')).rejects.toThrow(/not an attachment of this message/);
await expect(call(ASSISTANT, 'missing', 'blob-photo')).rejects.toThrow(/no message with id "missing"/);
expect(client.fetchBlobArrayBuffer).not.toHaveBeenCalled();
});
it('checks its options and allows 30 reads a minute', async () => {
await expect(call(ASSISTANT, 'm1', 'blob-photo', { maxSide: 8000 })).rejects.toThrow(/maxSide must be a whole number from 64 to 2576/);
await expect(call(ASSISTANT, 'm1', 'blob-photo', { exif: 'yes' })).rejects.toThrow(/exif must be true or false/);
await expect(call(ASSISTANT, 'm1', 'blob-photo', { url: 'https://x' })).rejects.toThrow(/unknown property "url"/);
for (let i = 0; i < 30; i++) await call(ASSISTANT, 'm1', 'blob-pdf');
await expect(call(ASSISTANT, 'm1', 'blob-pdf')).rejects.toThrow(/too many reads/);
});
});