251 lines
10 KiB
TypeScript
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 }));
|