Files

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");
});
});