import { afterAll, describe, expect, test } from "bun:test"; import { randomUUID } from "node:crypto"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; // Point the app at a throwaway DB + blob dir before importing it, so these // integration tests never touch the real ./db. The dynamic import has to run // after the env is set, hence the top-level await. const TMP = mkdtempSync(join(tmpdir(), "zbin-test-")); const BLOB_DIR = join(TMP, "blobs"); process.env.ZBIN_DB_PATH = join(TMP, "db.sqlite"); process.env.ZBIN_BLOB_DIR = BLOB_DIR; const { app } = await import("./index"); const { encrypt, decrypt } = await import("./crypto"); afterAll(() => rmSync(TMP, { recursive: true, force: true })); const utf8 = new TextEncoder(); const text = new TextDecoder(); // A 1x1 PNG header — enough magic for file-type to detect image/png. const PNG = new Uint8Array([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, ]); function upload( fields: Record, fileName: string, bytes: Uint8Array | string, ): Promise { const fd = new FormData(); // Non-file fields first; the "file" part must come LAST (the server relies on // the other fields being known before the bytes stream in). for (const [k, v] of Object.entries(fields)) fd.append(k, v); fd.append("file", new Blob([bytes as BlobPart]), fileName); return app.handle( new Request("http://localhost/upload", { method: "POST", body: fd }), ); } async function uploadId( fields: Record, fileName: string, bytes: Uint8Array | string, ): Promise { const res = await upload(fields, fileName, bytes); expect(res.status).toBe(303); const uuid = (res.headers.get("location") ?? "").replace("/show/", ""); expect(uuid).toMatch(/^[0-9a-fA-F-]{36}$/); return uuid; } function get(path: string, cookie?: string): Promise { const headers: Record = {}; if (cookie) headers.cookie = cookie; return app.handle(new Request(`http://localhost${path}`, { headers })); } describe("home page", () => { test("GET / serves the upload form", async () => { const res = await get("/"); expect(res.status).toBe(200); const body = await res.text(); expect(body).toContain('id="uploadForm"'); expect(body).toContain("ZBin"); }); }); describe("plaintext round-trip", () => { test("upload → show renders the content, raw returns the exact bytes", async () => { const uuid = await uploadId( { filetype: "none" }, "hello.txt", "hello world", ); const show = await get(`/show/${uuid}`); expect(show.status).toBe(200); const showBody = await show.text(); expect(showBody).toContain("hello world"); expect(showBody).toContain("hello.txt"); const raw = await get(`/raw/${uuid}`); expect(raw.status).toBe(200); expect(await raw.text()).toBe("hello world"); // Non-media plaintext is served as a defensive attachment, never inline. expect(raw.headers.get("encrypted")).toBe("false"); expect(raw.headers.get("x-content-type-options")).toBe("nosniff"); expect(raw.headers.get("content-type")).toBe("application/octet-stream"); expect(raw.headers.get("content-disposition")).toContain("attachment"); }); test("delete_in_minutes is reflected on the show page", async () => { const uuid = await uploadId( { filetype: "none", delete_in_minutes: "60" }, "t.txt", "bye", ); expect(await (await get(`/show/${uuid}`)).text()).toContain("delete at"); }); }); describe("media handling (content-type safety)", () => { test("a sniffed image is served inline with its real type", async () => { const uuid = await uploadId({ filetype: "blob" }, "pixel.png", PNG); const raw = await get(`/raw/${uuid}`); expect(raw.headers.get("content-type")).toBe("image/png"); expect(raw.headers.get("content-disposition")).toContain("inline"); expect(raw.headers.get("x-content-type-options")).toBe("nosniff"); const show = await get(`/show/${uuid}`); const body = await show.text(); expect(body).toContain(" { // Default filetype is "none", not "blob"; a big media file must still be // previewed via /raw (which streams) rather than hitting the text cap. const bigImage = new Uint8Array(1024 * 1024 + 100); bigImage.set(PNG, 0); const uuid = await uploadId({ filetype: "none" }, "big.png", bigImage); const body = await (await get(`/show/${uuid}`)).text(); expect(body).toContain(" { const PW = "hunter2"; test("show: overlay without a password, content with the right one, 403 with a wrong one", async () => { const uuid = await uploadId( { filetype: "none", password: PW }, "secret.txt", "top secret", ); const noCookie = await get(`/show/${uuid}`); expect(noCookie.status).toBe(200); expect(await noCookie.text()).toContain("decrypt-overlay"); const right = await get(`/show/${uuid}`, `password=${PW}`); expect(right.status).toBe(200); expect(await right.text()).toContain("top secret"); const wrong = await get(`/show/${uuid}`, "password=nope"); expect(wrong.status).toBe(403); expect(await wrong.text()).toContain("Incorrect password"); }); test("raw: 401 without a password, plaintext with the right one", async () => { const uuid = await uploadId( { filetype: "none", password: PW }, "secret.txt", "top secret", ); expect((await get(`/raw/${uuid}`)).status).toBe(401); const raw = await get(`/raw/${uuid}`, `password=${PW}`); expect(raw.status).toBe(200); expect(raw.headers.get("encrypted")).toBe("true"); expect(await raw.text()).toBe("top secret"); }); test("raw ?ignore_password serves the encrypted bytes, decryptable client-side", async () => { const uuid = await uploadId( { filetype: "none", password: PW }, "secret.txt", "top secret", ); const raw = await get(`/raw/${uuid}?ignore_password=true`); expect(raw.status).toBe(200); expect(raw.headers.get("encrypted")).toBe("true"); const cipher = new Uint8Array(await raw.arrayBuffer()); expect(text.decode(cipher)).not.toBe("top secret"); // The on-disk format must match the shared crypto module byte-for-byte. expect(text.decode(await decrypt(cipher, PW))).toBe("top secret"); }); }); describe("already-encrypted (client-side) uploads", () => { test("opaque bytes are stored as-is and round-trip", async () => { const cipher = await encrypt(utf8.encode("client side"), "pw"); const uuid = await uploadId( { filetype: "none", encrypted: "on" }, "blob.bin", cipher, ); // No password is known to the server, so /show defers to the client flow. expect(await (await get(`/show/${uuid}`)).text()).toContain( "decrypt-overlay", ); const raw = await get(`/raw/${uuid}?ignore_password=true`); const stored = new Uint8Array(await raw.arrayBuffer()); expect(stored).toEqual(new Uint8Array(cipher)); expect(text.decode(await decrypt(stored, "pw"))).toBe("client side"); }); }); describe("large text preview cap", () => { test("oversized text isn't rendered inline but still downloads in full", async () => { const big = "a".repeat(1024 * 1024 + 100); // just over MAX_TEXT_PREVIEW_BYTES const uuid = await uploadId({ filetype: "none" }, "big.txt", big); const show = await get(`/show/${uuid}`); expect(await show.text()).toContain("too large to preview"); const raw = await get(`/raw/${uuid}`); expect((await raw.arrayBuffer()).byteLength).toBe(big.length); }); }); describe("upload validation", () => { test("rejects a request with no file part", async () => { const fd = new FormData(); fd.append("filetype", "none"); const res = await app.handle( new Request("http://localhost/upload", { method: "POST", body: fd }), ); expect(res.status).toBe(400); expect(await res.text()).toContain("No file"); }); test("rejects a form field after the file part", async () => { const fd = new FormData(); fd.append("filetype", "none"); fd.append("file", new Blob(["x"]), "f.txt"); fd.append("late", "boom"); // arrives after the file → must be rejected const res = await app.handle( new Request("http://localhost/upload", { method: "POST", body: fd }), ); expect(res.status).toBe(400); expect(await res.text()).toContain("last form field"); }); test("rejects an unknown filetype", async () => { const res = await upload({ filetype: "not-a-language" }, "f.txt", "x"); expect(res.status).toBe(400); expect(await res.text()).toContain("filetype"); }); test("rejects an over-long filename", async () => { const res = await upload( { filetype: "none", filename: "x".repeat(256) }, "f.txt", "x", ); expect(res.status).toBe(400); expect(await res.text()).toContain("Filename too long"); }); test("rejects a non-numeric delete_in_minutes", async () => { const res = await upload( { filetype: "none", delete_in_minutes: "soon" }, "f.txt", "x", ); expect(res.status).toBe(400); expect(await res.text()).toContain("delete_in_minutes"); }); test("rejects a non-multipart body", async () => { const res = await app.handle( new Request("http://localhost/upload", { method: "POST", headers: { "content-type": "application/json" }, body: "{}", }), ); expect(res.status).toBe(400); }); }); describe("lookup failures", () => { test("unknown uuid → 404 on show and raw", async () => { const missing = randomUUID(); expect((await get(`/show/${missing}`)).status).toBe(404); expect((await get(`/raw/${missing}`)).status).toBe(404); }); test("malformed uuid is rejected by validation", async () => { expect((await get("/show/not-a-uuid")).status).toBe(422); }); test("a row whose blob is gone → 404 instead of a 500", async () => { const uuid = await uploadId({ filetype: "none" }, "f.txt", "data"); rmSync(join(BLOB_DIR, uuid)); // delete the blob out from under the row expect((await get(`/show/${uuid}`)).status).toBe(404); expect((await get(`/raw/${uuid}`)).status).toBe(404); }); });