mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
163 lines
8.1 KiB
TypeScript
163 lines
8.1 KiB
TypeScript
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(['<script>alert(1)</script>'], { 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('<script>alert(1)</script>');
|
|
|
|
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 = '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(document.domain)</script></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(['<p>x</p>']), '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<string, string[]> = {
|
|
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');
|
|
});
|
|
});
|