Files
WRNexusJS/packages/viewer/test/convert.test.ts
T

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