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

251 lines
10 KiB
TypeScript

import { afterEach, describe, expect, test } from "bun:test";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { configureStorage } from "@wrnexus/uploader";
import { configureUploads } from "../src/uploads/config.ts";
import { handleFile, handleSource, uploadedConversionSource } from "../src/uploads/service.ts";
import { viewerPlugin } from "../src/plugin.ts";
import { Window } from "happy-dom";
import { mountViewer } from "../src/runtime.ts";
const SECRET = "test-secret-value-32-chars-minimum!!";
const root = mkdtempSync(join(tmpdir(), "wrn-viewer-uploads-"));
function storage() {
for (const dir of ["public", "private"])
mkdirSync(join(root, "uploads", dir), { recursive: true });
writeFileSync(join(root, "uploads", "public", "logo.png"), Buffer.from([137, 80, 78, 71]));
writeFileSync(join(root, "uploads", "private", "report.pdf"), Buffer.from("%PDF-1.7 private"));
configureStorage(
{
default: "public",
stores: {
public: { driver: "local", access: "public", dir: "uploads/public" },
private: { driver: "local", access: "private", dir: "uploads/private" },
secret: { driver: "local", access: "private", dir: "uploads/private" },
},
},
root,
);
}
storage();
const ask = (query: string) => new Request(`https://app.test/api/viewer/source?${query}`);
const body = async (response: Response) =>
(await response.json()) as { src: string; name: string; mime?: string };
afterEach(() => configureUploads(undefined));
describe("resolving a storage key", () => {
test("nothing resolves until uploads are enabled", async () => {
expect((await handleSource(ask("key=logo.png"))).status).toBe(404);
});
test("a public object answers with its own URL, no token", async () => {
configureUploads({ stores: ["public"] });
const response = await handleSource(ask("key=logo.png&store=public"));
expect(response.status).toBe(200);
const descriptor = await body(response);
expect(descriptor.src).toContain("logo.png");
expect(descriptor.src).not.toContain("token=");
expect(descriptor.name).toBe("logo.png");
// The MIME comes from the uploader's table, not a second copy of it.
expect(descriptor.mime).toBe("image/png");
});
test("stores outside the allow-list are refused", async () => {
configureUploads({ stores: ["public"], secret: SECRET, authorize: () => true });
expect((await handleSource(ask("key=report.pdf&store=private"))).status).toBe(403);
});
test("keys that try to escape the store are refused", async () => {
configureUploads({ stores: ["public"] });
for (const key of ["../secrets.env", "/etc/passwd", "a/../../b"])
expect((await handleSource(ask(`key=${encodeURIComponent(key)}&store=public`))).status).toBe(
400,
);
});
test("a private object needs both a secret and an authorize hook", async () => {
configureUploads({ stores: ["private"] });
expect((await handleSource(ask("key=report.pdf&store=private"))).status).toBe(403);
configureUploads({ stores: ["private"], secret: SECRET });
expect((await handleSource(ask("key=report.pdf&store=private"))).status).toBe(403);
});
test("authorize decides, and a refusal mints nothing", async () => {
const seen: { store: string; key: string }[] = [];
configureUploads({
stores: ["private"],
secret: SECRET,
authorize: (_request, params) => {
seen.push(params);
return false;
},
});
expect((await handleSource(ask("key=report.pdf&store=private"))).status).toBe(403);
expect(seen).toEqual([{ store: "private", key: "report.pdf" }]);
});
});
describe("serving a signed link", () => {
const signedFor = async (key: string, store = "private") => {
configureUploads({ stores: [store], secret: SECRET, authorize: () => true });
const descriptor = await body(await handleSource(ask(`key=${key}&store=${store}`)));
return new URL(descriptor.src, "https://app.test").searchParams.get("token")!;
};
const fetchFile = (token: string, init: RequestInit = {}) =>
handleFile(
new Request(`https://app.test/api/viewer/file?token=${encodeURIComponent(token)}`, init),
);
test("a valid link streams the object, uncacheable and inline", async () => {
const response = await fetchFile(await signedFor("report.pdf"));
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("private, no-store");
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
expect(response.headers.get("content-disposition")).toBe("inline");
expect(await response.text()).toContain("%PDF");
});
test("HEAD answers with the same headers and no body", async () => {
const response = await fetchFile(await signedFor("report.pdf"), { method: "HEAD" });
expect(response.status).toBe(200);
expect(response.headers.get("content-length")).toBe("16");
expect(await response.text()).toBe("");
});
test("ranges are served so media can seek", async () => {
const response = await fetchFile(await signedFor("report.pdf"), {
headers: { range: "bytes=0-3" },
});
expect(response.status).toBe(206);
expect(response.headers.get("content-range")).toBe("bytes 0-3/16");
expect(await response.text()).toBe("%PDF");
});
test("a tampered, unsigned or expired link is indistinguishable from a miss", async () => {
const token = await signedFor("report.pdf");
expect((await fetchFile(token.slice(0, -2) + "xx")).status).toBe(404);
expect((await fetchFile("not-a-token")).status).toBe(404);
expect((await handleFile(new Request("https://app.test/api/viewer/file"))).status).toBe(400);
// A link signed with a different secret must not open under this one.
configureUploads({
stores: ["private"],
secret: "another-secret-entirely-x",
authorize: () => true,
});
expect((await fetchFile(token)).status).toBe(404);
});
test("a link for one store cannot be replayed after that store is withdrawn", async () => {
const token = await signedFor("report.pdf");
configureUploads({ stores: ["public"], secret: SECRET, authorize: () => true });
expect((await fetchFile(token)).status).toBe(404);
});
});
describe("conversion inherits the same check", () => {
test("only a valid signed link opens an object for a converter", async () => {
configureUploads({ stores: ["private"], secret: SECRET, authorize: () => true });
const descriptor = await body(await handleSource(ask("key=report.pdf&store=private")));
const open = uploadedConversionSource();
const request = new Request("https://app.test/api/viewer/preview");
const allowed = await open(request, { src: descriptor.src, name: "report.pdf" });
expect(allowed).toBeDefined();
expect(allowed!.size).toBe(16);
// A bare key, a foreign URL, or a forged token opens nothing.
expect(await open(request, { src: "report.pdf", name: "report.pdf" })).toBeUndefined();
expect(
await open(request, { src: "https://evil.test/x?token=abc", name: "report.pdf" }),
).toBeUndefined();
});
});
describe("plugin wiring", () => {
test("uploader routes appear only when uploads are configured", () => {
const paths = (plugin: ReturnType<typeof viewerPlugin>) =>
((plugin.routeEntries ?? []) as { path: string }[]).map((route) => route.path);
expect(paths(viewerPlugin())).toEqual([]);
expect(paths(viewerPlugin({ uploads: { stores: ["public"] } }))).toEqual([
"/api/viewer/source",
"/api/viewer/file",
]);
// Enabling one feature must not publish the other's endpoints.
expect(paths(viewerPlugin({ uploads: { stores: ["public"] } }))).not.toContain(
"/api/viewer/preview",
);
});
});
describe("the browser side of a key", () => {
async function mountKeyed(descriptor: unknown, body: string, componentName = "") {
const window = new Window({ url: "https://app.test/" });
const host = window.document.createElement("section") as unknown as HTMLElement;
window.document.body.append(host as never);
const asked: string[] = [];
const original = globalThis.fetch;
globalThis.fetch = (async (input: string | URL | Request) => {
const url = String(input);
asked.push(url);
if (url.includes("/api/viewer/source"))
return descriptor ? Response.json(descriptor) : new Response("no", { status: 403 });
return new Response(body, { headers: { "content-type": "text/csv" } });
}) as unknown as typeof fetch;
try {
const dispose = mountViewer(host, {
key: "regional-figures.csv",
store: "reports",
name: componentName,
});
for (let i = 0; i < 20; i++) await new Promise((done) => setTimeout(done, 5));
return { host, asked, dispose, window };
} finally {
globalThis.fetch = original;
}
}
test("a key is exchanged for a link, and the store travels with it", async () => {
const { host, asked, dispose, window } = await mountKeyed(
{ src: "/api/viewer/file?token=abc", name: "regional-figures.csv", mime: "text/csv" },
"region,units\nNorth,120\n",
);
const source = asked.find((url) => url.includes("/api/viewer/source"))!;
expect(source).toContain("key=regional-figures.csv");
expect(source).toContain("store=reports");
// The descriptor's filename must reach classification: without the extension
// the renderer for this kind cannot be found at all.
expect(host.querySelector("table")).not.toBeNull();
expect(host.querySelector(".wrn-viewer-toolbar span")!.textContent).toBe(
"regional-figures.csv",
);
dispose();
await window.happyDOM.close();
});
test("an explicit name on the component still wins", async () => {
const { host, dispose, window } = await mountKeyed(
{ src: "/api/viewer/file?token=abc", name: "regional-figures.csv", mime: "text/csv" },
"region,units\nNorth,120\n",
"Q3 figures.csv",
);
expect(host.querySelector(".wrn-viewer-toolbar span")!.textContent).toBe("Q3 figures.csv");
dispose();
await window.happyDOM.close();
});
test("a refused key shows the fallback, and no file is fetched", async () => {
const { host, asked, dispose, window } = await mountKeyed(undefined, "");
expect(host.textContent).toContain("This file is not available to preview");
expect(asked.every((url) => url.includes("/api/viewer/source"))).toBe(true);
dispose();
await window.happyDOM.close();
});
});
process.on("exit", () => rmSync(root, { recursive: true, force: true }));