201 lines
7.7 KiB
TypeScript
201 lines
7.7 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import { extensions, kindOfExtension } from "../src/kinds.ts";
|
|
import { RendererRegistry } from "../src/registry.ts";
|
|
import { resolve } from "../src/resolve.ts";
|
|
import { readBounded } from "../src/source.ts";
|
|
import { sniff } from "../src/sniff.ts";
|
|
|
|
const file = (name: string, bytes: string | number[], mime = "") => ({
|
|
name,
|
|
blob: new Blob([typeof bytes === "string" ? bytes : new Uint8Array(bytes)], { type: mime }),
|
|
});
|
|
|
|
describe("classification and signatures", () => {
|
|
for (const [kind, exts] of Object.entries(extensions))
|
|
for (const ext of exts) {
|
|
test(`classifies .${ext}`, () =>
|
|
expect(String(kindOfExtension(`.${ext.toUpperCase()}`))).toBe(kind));
|
|
}
|
|
const signatures: [string, string | number[], string][] = [
|
|
["png", [137, 80, 78, 71, 13, 10, 26, 10], "ImageViewer"],
|
|
["jpeg", [255, 216, 255], "ImageViewer"],
|
|
["gif", "GIF89a", "ImageViewer"],
|
|
["webp", "RIFF0000WEBP", "ImageViewer"],
|
|
["bmp", "BM00", "ImageViewer"],
|
|
["ico", [0, 0, 1, 0], "ImageViewer"],
|
|
["avif", "0000ftypavif", "ImageViewer"],
|
|
["pdf", "%PDF-1.7", "PdfViewer"],
|
|
["wav", "RIFF0000WAVE", "MediaViewer"],
|
|
["mp3", "ID3", "MediaViewer"],
|
|
["aac", [255, 241, 80, 128], "MediaViewer"],
|
|
["flac", "fLaC", "MediaViewer"],
|
|
["ogg", "OggS", "MediaViewer"],
|
|
["mp4", "0000ftypisom", "MediaViewer"],
|
|
["mov", "0000ftypqt ", "MediaViewer"],
|
|
["webm", [0x1a, 0x45, 0xdf, 0xa3], "MediaViewer"],
|
|
["txt", "hello", "CodeViewer"],
|
|
["js", "const value = 1;", "CodeViewer"],
|
|
["html", "<html><script>alert(1)</script></html>", "HtmlViewer"],
|
|
];
|
|
for (const [ext, bytes, component] of signatures)
|
|
test(`resolves ${ext}`, async () => {
|
|
expect((await resolve(file(`test.${ext}`, bytes))).renderer.component).toBe(component);
|
|
});
|
|
test("HTML masquerading as PNG is shown as inert source", async () => {
|
|
const plan = await resolve(
|
|
file("fake.png", "<!doctype html><script>alert(1)</script>", "image/png"),
|
|
);
|
|
expect(plan.kind).toBe("markup");
|
|
expect(plan.renderer.component).toBe("CodeViewer");
|
|
});
|
|
test("SVG masquerading as image never reaches native image rendering", async () => {
|
|
const plan = await resolve(file("fake.png", '<svg onload="alert(1)"></svg>', "image/png"));
|
|
expect(plan.kind).toBe("vector");
|
|
expect(plan.sandboxed).toBe(true);
|
|
expect(plan.renderer.component).toBe("SvgViewer");
|
|
});
|
|
test("binary or executables cannot become text through MIME", async () => {
|
|
expect((await resolve(file("fake.txt", [0, 1, 2], "text/plain"))).renderer.component).toBe(
|
|
"DownloadCard",
|
|
);
|
|
expect((await resolve(file("fake.png", "MZ123", "image/png"))).renderer.component).toBe(
|
|
"DownloadCard",
|
|
);
|
|
expect((await resolve(file("fake.exe", "hello"))).renderer.component).toBe("DownloadCard");
|
|
});
|
|
test("UTF-8 truncated at sniff boundary is still text", () => {
|
|
expect(sniff(new Uint8Array([97, 0xe2, 0x82])).kind).toBe("text");
|
|
});
|
|
});
|
|
|
|
describe("degradation and network limits", () => {
|
|
test("missing optional dependencies fall through to native renderer", async () => {
|
|
const registry = new RendererRegistry();
|
|
registry.register({
|
|
kind: "pdf",
|
|
exts: [".pdf"],
|
|
tier: 2,
|
|
component: "RichPdf",
|
|
load: async () => {
|
|
throw new Error("missing");
|
|
},
|
|
});
|
|
expect((await resolve(file("test.pdf", "%PDF-1.7"), { registry })).renderer.component).toBe(
|
|
"PdfViewer",
|
|
);
|
|
});
|
|
test("overrides can be removed and conflicting signatures bypass them", async () => {
|
|
const registry = new RendererRegistry();
|
|
const unregister = registry.register({
|
|
kind: "text",
|
|
exts: ["txt"],
|
|
tier: 2,
|
|
component: "Custom",
|
|
});
|
|
expect((await resolve(file("test.txt", "hi"), { registry })).renderer.component).toBe("Custom");
|
|
expect((await resolve(file("test.txt", "%PDF-1.7"), { registry })).renderer.component).toBe(
|
|
"PdfViewer",
|
|
);
|
|
unregister();
|
|
expect((await resolve(file("test.txt", "hi"), { registry })).renderer.component).toBe(
|
|
"CodeViewer",
|
|
);
|
|
});
|
|
test("unavailable converters degrade", async () => {
|
|
const registry = new RendererRegistry();
|
|
registry.register({ kind: "binary", exts: [".psd"], tier: 3, component: "Convert" });
|
|
expect((await resolve(file("image.psd", [0, 0, 0]), { registry })).renderer.component).toBe(
|
|
"DownloadCard",
|
|
);
|
|
});
|
|
test("oversized HEAD prevents body fetch", async () => {
|
|
const methods: string[] = [];
|
|
const request = (async (_url, init) => {
|
|
methods.push(init?.method ?? "GET");
|
|
return new Response(null, { headers: { "content-length": "400000000" } });
|
|
}) as typeof fetch;
|
|
const plan = await resolve(
|
|
{ name: "large.mp4", src: "https://example.test/file" },
|
|
{ fetch: request },
|
|
);
|
|
expect(plan.renderer.component).toBe("DownloadCard");
|
|
expect(methods).toEqual(["HEAD"]);
|
|
});
|
|
test("unknown-length or ignored Range stream is cancelled at prefix cap", async () => {
|
|
let cancelled = false;
|
|
const bytes = await readBounded(
|
|
new Response(
|
|
new ReadableStream({
|
|
start(c) {
|
|
c.enqueue(new Uint8Array(8192));
|
|
},
|
|
cancel() {
|
|
cancelled = true;
|
|
},
|
|
}),
|
|
),
|
|
4096,
|
|
true,
|
|
);
|
|
expect(bytes.length).toBe(4096);
|
|
expect(cancelled).toBe(true);
|
|
});
|
|
test("full reads enforce actual bytes despite dishonest metadata", async () => {
|
|
await expect(
|
|
readBounded(new Response("123456", { headers: { "content-length": "1" } }), 3),
|
|
).rejects.toThrow("limit");
|
|
});
|
|
test("HEAD without support still performs bounded sniff", async () => {
|
|
const request = (async (_url, init) =>
|
|
init?.method === "HEAD"
|
|
? new Response(null, { status: 405 })
|
|
: new Response("hello")) as typeof fetch;
|
|
expect(
|
|
(await resolve({ name: "text.txt", src: "/file" }, { fetch: request })).renderer.component,
|
|
).toBe("CodeViewer");
|
|
});
|
|
test("invalid URLs, rejected fetches and key-only sources degrade", async () => {
|
|
expect((await resolve({ name: "a.txt", src: "javascript:alert(1)" })).renderer.component).toBe(
|
|
"DownloadCard",
|
|
);
|
|
expect((await resolve({ name: "a.txt", key: "private" })).renderer.component).toBe(
|
|
"DownloadCard",
|
|
);
|
|
const request = (async () => {
|
|
throw new Error("offline");
|
|
}) as unknown as typeof fetch;
|
|
expect(
|
|
(await resolve({ name: "a.txt", src: "/file" }, { fetch: request })).renderer.component,
|
|
).toBe("DownloadCard");
|
|
});
|
|
});
|
|
|
|
describe("fallback reasons and unrenderable signatures", () => {
|
|
test("a kind with no renderer reports why, not a size limit", async () => {
|
|
const request = (async () =>
|
|
new Response("PK", {
|
|
headers: { "content-length": "512", "content-type": "application/zip" },
|
|
})) as unknown as typeof fetch;
|
|
const plan = await resolve(
|
|
{ name: "bundle.zip", src: "https://example.test/bundle.zip" },
|
|
{ fetch: request },
|
|
);
|
|
expect(plan.kind).toBe("archive");
|
|
expect(plan.reason).toBe("Preview unavailable for this file");
|
|
});
|
|
test("design formats with ASCII-clean headers are not mistaken for text", async () => {
|
|
const ascii = (signature: number[]) => {
|
|
const bytes = new Uint8Array(4096).fill(0x41);
|
|
bytes.set(signature);
|
|
return bytes;
|
|
};
|
|
expect(sniff(ascii([0x38, 0x42, 0x50, 0x53])).kind).toBe("binary");
|
|
expect(sniff(ascii([0x49, 0x49, 0x2a, 0x00])).kind).toBe("binary");
|
|
expect(sniff(ascii([0x4d, 0x4d, 0x00, 0x2a])).kind).toBe("binary");
|
|
expect(
|
|
(await resolve({ name: "art.psd", blob: new Blob([ascii([0x38, 0x42, 0x50, 0x53])]) }))
|
|
.renderer.component,
|
|
).toBe("DownloadCard");
|
|
});
|
|
});
|