204 lines
7.5 KiB
TypeScript
204 lines
7.5 KiB
TypeScript
import { afterEach, describe, expect, test } from "bun:test";
|
|
import { configureConversion } from "../src/convert/config.ts";
|
|
import { handleCapabilities, handleConvert } from "../src/convert/service.ts";
|
|
import { localBinaryConverter } from "../src/convert/local.ts";
|
|
import { resetConverterProbe } from "../src/convert/client.ts";
|
|
import { viewerPlugin } from "../src/plugin.ts";
|
|
import { resolve } from "../src/resolve.ts";
|
|
import type { ViewerConverter } from "../src/convert/types.ts";
|
|
|
|
const encoder = new TextEncoder();
|
|
|
|
function fakeConverter(overrides: Partial<ViewerConverter> = {}): ViewerConverter {
|
|
return {
|
|
name: "fake",
|
|
version: "1",
|
|
supports: (_kind, ext) => ext === ".docx",
|
|
outputType: (ext) => (ext === ".docx" ? "application/pdf" : undefined),
|
|
toPreview: async () => ({ mime: "application/pdf", bytes: encoder.encode("%PDF-1.7 fake") }),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
const request = (path: string) => new Request(`https://app.test${path}`);
|
|
const convertRequest = (src: string, name: string) =>
|
|
request(`/api/viewer/preview?src=${encodeURIComponent(src)}&name=${encodeURIComponent(name)}`);
|
|
|
|
afterEach(() => {
|
|
configureConversion(undefined);
|
|
resetConverterProbe();
|
|
});
|
|
|
|
describe("conversion routes", () => {
|
|
test("everything 404s until conversion is configured", async () => {
|
|
expect(handleCapabilities().status).toBe(404);
|
|
expect((await handleConvert(convertRequest("/a.docx", "a.docx"), "preview")).status).toBe(404);
|
|
});
|
|
|
|
test("capabilities advertise only what a converter claims", async () => {
|
|
configureConversion({ converters: [fakeConverter()] });
|
|
const body = (await handleCapabilities().json()) as {
|
|
converters: string[];
|
|
formats: Record<string, string>;
|
|
};
|
|
expect(body.converters).toEqual(["fake"]);
|
|
expect(body.formats[".docx"]).toBe("application/pdf");
|
|
expect(body.formats[".psd"]).toBeUndefined();
|
|
});
|
|
|
|
test("unknown extensions and unsupported formats are refused, not attempted", async () => {
|
|
configureConversion({ converters: [fakeConverter()] });
|
|
expect((await handleConvert(convertRequest("/a.foo", "a.foo"), "preview")).status).toBe(415);
|
|
expect((await handleConvert(convertRequest("/a.psd", "a.psd"), "preview")).status).toBe(415);
|
|
expect((await handleConvert(request("/api/viewer/preview"), "preview")).status).toBe(400);
|
|
});
|
|
|
|
test("the default source policy refuses cross-origin URLs without fetching", async () => {
|
|
let fetched = false;
|
|
const original = globalThis.fetch;
|
|
globalThis.fetch = (async () => {
|
|
fetched = true;
|
|
return new Response("x");
|
|
}) as unknown as typeof fetch;
|
|
try {
|
|
configureConversion({ converters: [fakeConverter()] });
|
|
const response = await handleConvert(
|
|
convertRequest("https://evil.test/a.docx", "a.docx"),
|
|
"preview",
|
|
);
|
|
expect(response.status).toBe(403);
|
|
expect(fetched).toBe(false);
|
|
} finally {
|
|
globalThis.fetch = original;
|
|
}
|
|
});
|
|
|
|
test("a converted result is returned, then served from cache", async () => {
|
|
let conversions = 0;
|
|
configureConversion({
|
|
converters: [
|
|
fakeConverter({
|
|
toPreview: async () => {
|
|
conversions++;
|
|
return { mime: "application/pdf", bytes: encoder.encode("%PDF-1.7 fake") };
|
|
},
|
|
}),
|
|
],
|
|
resolveSource: async () => ({
|
|
body: new Response(encoder.encode("docx bytes")).body!,
|
|
size: 10,
|
|
}),
|
|
});
|
|
const first = await handleConvert(convertRequest("/files/a.docx", "a.docx"), "preview");
|
|
expect(first.status).toBe(200);
|
|
expect(first.headers.get("content-type")).toBe("application/pdf");
|
|
expect(first.headers.get("x-content-type-options")).toBe("nosniff");
|
|
expect(first.headers.get("x-viewer-cache")).toBe("miss");
|
|
expect(await first.text()).toContain("%PDF");
|
|
|
|
const second = await handleConvert(convertRequest("/files/a.docx", "a.docx"), "preview");
|
|
expect(second.headers.get("x-viewer-cache")).toBe("hit");
|
|
expect(conversions).toBe(1);
|
|
});
|
|
|
|
test("oversized sources are refused before the converter runs", async () => {
|
|
let ran = false;
|
|
configureConversion({
|
|
maxInputBytes: 8,
|
|
converters: [
|
|
fakeConverter({
|
|
toPreview: async () => {
|
|
ran = true;
|
|
return { mime: "application/pdf", bytes: new Uint8Array() };
|
|
},
|
|
}),
|
|
],
|
|
resolveSource: async () => ({ body: new Response("x").body!, size: 999 }),
|
|
});
|
|
expect((await handleConvert(convertRequest("/a.docx", "a.docx"), "preview")).status).toBe(413);
|
|
expect(ran).toBe(false);
|
|
});
|
|
|
|
test("a converter failure reports a generic error, never its own message", async () => {
|
|
configureConversion({
|
|
converters: [
|
|
fakeConverter({
|
|
toPreview: async () => {
|
|
throw new Error("soffice crashed at C:/secret/path");
|
|
},
|
|
}),
|
|
],
|
|
resolveSource: async () => ({ body: new Response("x").body! }),
|
|
});
|
|
const response = await handleConvert(convertRequest("/a.docx", "a.docx"), "preview");
|
|
expect(response.status).toBe(422);
|
|
expect(await response.text()).not.toContain("secret");
|
|
});
|
|
});
|
|
|
|
describe("plugin wiring", () => {
|
|
test("routes appear only when conversion is configured", () => {
|
|
expect(viewerPlugin().routeEntries).toBeUndefined();
|
|
const routes = viewerPlugin({ convert: { converters: [fakeConverter()] } }).routeEntries as {
|
|
path: string;
|
|
}[];
|
|
expect(routes.map((route) => route.path)).toEqual([
|
|
"/api/viewer/capabilities",
|
|
"/api/viewer/preview",
|
|
"/api/viewer/thumb",
|
|
"/api/viewer/text",
|
|
]);
|
|
// Configuring the plugin is what arms the handlers.
|
|
expect(handleCapabilities().status).toBe(200);
|
|
});
|
|
});
|
|
|
|
describe("local binary converter", () => {
|
|
test("a disabled binary makes its formats unsupported", () => {
|
|
const converter = localBinaryConverter({
|
|
binaries: { soffice: false, magick: false, ffmpeg: false, "7z": false },
|
|
});
|
|
expect(converter.supports("doc", ".docx")).toBe(false);
|
|
expect(converter.outputType(".psd")).toBeUndefined();
|
|
});
|
|
test("an unknown extension is never routed to a binary", () => {
|
|
expect(localBinaryConverter().supports("binary", ".xyz")).toBe(false);
|
|
});
|
|
test("conversion of an unsupported format rejects rather than spawning", async () => {
|
|
const converter = localBinaryConverter({ binaries: { soffice: false } });
|
|
await expect(
|
|
converter.toPreview(new Response("x").body!, {
|
|
name: "a.docx",
|
|
ext: ".docx",
|
|
kind: "doc",
|
|
signal: new AbortController().signal,
|
|
}),
|
|
).rejects.toThrow("No local converter");
|
|
});
|
|
});
|
|
|
|
describe("tier 3 planning", () => {
|
|
test("office and design files keep their extension kind despite generic bytes", async () => {
|
|
// A .docx is a ZIP and a .psd is opaque binary; neither signature identifies
|
|
// the content, so the extension must still choose the converter.
|
|
const docx = await resolve({
|
|
name: "report.docx",
|
|
blob: new Blob([new Uint8Array([0x50, 0x4b, 3, 4, 0, 0])]),
|
|
});
|
|
expect(docx.kind).toBe("doc");
|
|
const psd = await resolve({
|
|
name: "art.psd",
|
|
blob: new Blob([new Uint8Array([0x38, 0x42, 0x50, 0x53, 0, 1])]),
|
|
});
|
|
expect(psd.kind).toBe("binary");
|
|
});
|
|
test("without a reachable converter they fall back and ask for no conversion", async () => {
|
|
const plan = await resolve({
|
|
name: "report.docx",
|
|
blob: new Blob([new Uint8Array([0x50, 0x4b, 3, 4, 0, 0])]),
|
|
});
|
|
expect(plan.renderer.component).toBe("DownloadCard");
|
|
expect(plan.needsConvert).toBe(false);
|
|
});
|
|
});
|