import { describe, expect, it, vi } from 'vitest'; import { getFilePreviewKind, imageBlobUrl, inertBlobType, isFilePreviewable, isMimeTypeSafeForInlinePreview, previewBlobType, toInertBlob } from '../file-preview'; describe('file preview detection', () => { it('detects browser-renderable image attachments', () => { expect(getFilePreviewKind('photo.avif', 'image/avif')).toBe('image'); expect(getFilePreviewKind('vector.svg')).toBe('image'); }); it('detects html attachments', () => { expect(getFilePreviewKind('message.html', 'text/html; charset=utf-8')).toBe('html'); expect(getFilePreviewKind('index.htm')).toBe('html'); }); it('detects text and markdown attachments', () => { expect(getFilePreviewKind('notes.txt', 'text/plain')).toBe('text'); expect(getFilePreviewKind('README.md', 'text/markdown')).toBe('markdown'); expect(getFilePreviewKind('payload.json', 'application/json')).toBe('text'); }); it('detects pdf, audio, and video attachments', () => { expect(getFilePreviewKind('doc.pdf')).toBe('pdf'); expect(getFilePreviewKind('audio.m4a')).toBe('audio'); expect(getFilePreviewKind('movie.webm')).toBe('video'); }); it('rejects unsupported attachment types', () => { expect(getFilePreviewKind('archive.zip', 'application/zip')).toBe('unsupported'); expect(isFilePreviewable('archive.zip', 'application/zip')).toBe(false); }); }); describe('inert blob typing for cid: parts (GHSA-xvjh-v9c6-qcvc)', () => { it('keeps inert media types so inline images and media still render', () => { expect(inertBlobType('image/png')).toBe('image/png'); expect(inertBlobType('image/jpeg; name=a.jpg')).toBe('image/jpeg; name=a.jpg'); expect(inertBlobType('video/mp4')).toBe('video/mp4'); expect(inertBlobType('application/pdf')).toBe('application/pdf'); }); it('neutralises every sender-declared type that could execute as our origin', () => { for (const type of ['text/html', 'TEXT/HTML; charset=utf-8', 'application/xhtml+xml', 'image/svg+xml', 'application/xml', 'text/xml', 'application/javascript']) { expect(inertBlobType(type)).toBe('application/octet-stream'); } expect(inertBlobType(undefined)).toBe('application/octet-stream'); expect(inertBlobType('')).toBe('application/octet-stream'); }); it('refuses a type the Blob constructor would drop to an empty, sniffed type', () => { // A TNEF/winmail.dat MIME tag is free text. new Blob() empties any type // with a character outside U+0020-U+007E, and Firefox sniffs an untyped // blob: document, so such a type must not count as inert. for (const type of ['image/png\u0000', 'image/pngé', 'video/mp4\t', 'application/pdf\u0085']) { expect(new Blob([], { type }).type).toBe(''); expect(isMimeTypeSafeForInlinePreview(type)).toBe(false); expect(inertBlobType(type)).toBe('application/octet-stream'); } }); it('re-types a fetched Blob without touching its bytes', async () => { const html = new Blob([''], { type: 'text/html' }); const inert = toInertBlob(html); expect(inert.type).toBe('application/octet-stream'); expect(inert.size).toBe(html.size); expect(await inert.text()).toBe(''); const png = new Blob([new Uint8Array([137, 80, 78, 71])], { type: 'image/png' }); expect(toInertBlob(png)).toBe(png); }); }); describe('image thumbnails that can be opened on their own', () => { const svg = ''; it('hands an SVG over as a data: URL, never a same-origin object URL', async () => { const url = await imageBlobUrl(new Blob([svg], { type: 'image/svg+xml' })); expect(url.startsWith('data:image/svg+xml;base64,')).toBe(true); }); it('goes by the declared type when the bytes carry none', async () => { const url = await imageBlobUrl(new Blob([svg]), 'image/svg+xml'); expect(url.startsWith('data:image/svg+xml')).toBe(true); }); it('keeps raster images as object URLs', async () => { const created: Blob[] = []; const spy = vi.spyOn(URL, 'createObjectURL').mockImplementation((blob) => { created.push(blob as Blob); return 'blob:test'; }); expect(await imageBlobUrl(new Blob([new Uint8Array([137, 80, 78, 71])]), 'image/png')).toBe('blob:test'); expect(await imageBlobUrl(new Blob(['

x

']), 'text/html')).toBe('blob:test'); expect(created.map((b) => b.type)).toEqual(['image/png', 'application/octet-stream']); spy.mockRestore(); }); }); describe('previewBlobType', () => { // Browsers pick the viewer from the blob's type; anything they cannot show // is downloaded under the blob's UUID instead of previewed. it('shows a PDF that an IBM i system declared with its catch-all type', () => { expect(previewBlobType('Avis_30_281900.pdf', 'application/x-as400attachment', 'application/x-as400attachment')) .toBe('application/pdf'); }); it('keeps the octet-stream correction and ignores casing and parameters', () => { expect(previewBlobType('doc.pdf', 'application/octet-stream', 'application/octet-stream')).toBe('application/pdf'); expect(previewBlobType('doc.pdf', 'Application/Octet-Stream', '')).toBe('application/pdf'); expect(previewBlobType('scan.pdf', 'application/pdf; name="scan.pdf"', 'application/octet-stream')).toBe('application/pdf'); }); it('prefers a declared or served type the viewer can show over the name', () => { expect(previewBlobType('photo.jpg', 'image/png', '')).toBe('image/png'); expect(previewBlobType('clip.mp4', 'application/octet-stream', 'video/mp4')).toBe('video/mp4'); expect(previewBlobType('photo.jpg', 'application/x-as400attachment', '')).toBe('image/jpeg'); }); it('derives a viewable type from every image, audio and video extension but SVG', () => { const kinds: Record = { image: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'bmp', 'ico'], audio: ['mp3', 'wav', 'ogg', 'm4a', 'flac', 'aac', 'opus'], video: ['mp4', 'webm', 'ogv', 'mov', 'm4v', 'avi', 'mkv'], }; for (const [kind, extensions] of Object.entries(kinds)) { for (const ext of extensions) { const name = `file.${ext}`; expect(getFilePreviewKind(name)).toBe(kind); expect(previewBlobType(name, 'application/x-as400attachment', '')).toMatch(new RegExp(`^${kind}/`)); } } }); it('hands the unsandboxed PDF viewer nothing but application/pdf', () => { // The PDF preview iframe cannot be sandboxed (the browser's PDF viewer // refuses to run there), so XML or XHTML declared on a .pdf must not // reach it as a parseable document. expect(previewBlobType('invoice.pdf', 'text/xml', 'text/xml')).toBe('application/pdf'); expect(previewBlobType('invoice.pdf', 'application/xml', '')).toBe('application/pdf'); }); it('never derives a script-capable type from the file name', () => { expect(previewBlobType('drawing.svg', 'application/x-as400attachment', '')).toBe('application/x-as400attachment'); }); it('types a document attachment so that a blob: URL of it would never render as the webmail', () => { // HTML, .eml, text and markdown are previewed from their text in a srcdoc // frame; a text/html blob: URL of the sender's bytes was a document of // the webmail origin (GHSA-xvjh-v9c6-qcvc, on the attachment preview). expect(previewBlobType('invoice.html', 'text/html', 'text/html')).toBe('application/octet-stream'); expect(previewBlobType('page.htm', 'application/octet-stream', '')).toBe('application/octet-stream'); expect(previewBlobType('page', 'application/xhtml+xml', '')).toBe('application/octet-stream'); expect(previewBlobType('bounce.eml', 'message/rfc822', '')).toBe('application/octet-stream'); expect(previewBlobType('notes.txt', 'text/plain', '')).toBe('application/octet-stream'); }); it('leaves previews that do not render a blob by type as before', () => { expect(previewBlobType('archive.zip', 'application/zip', '')).toBe('application/zip'); expect(previewBlobType('archive.zip', 'application/octet-stream', 'application/octet-stream')).toBe('application/octet-stream'); }); });