mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 00:14:12 +00:00
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.
147 lines
7.8 KiB
TypeScript
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/);
|
|
});
|
|
});
|