Ship file viewer, editor improvements, and private package releases
This commit is contained in:
1 parent
645914955c
commit
a4a31032b9
158 files changed
+10523
-137
No files matched your search
@@ -0,0 +1,203 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user