routes.test.ts
| 1 | import { afterAll, describe, expect, test } from "bun:test"; |
| 2 | import { randomUUID } from "node:crypto"; |
| 3 | import { mkdtempSync, rmSync } from "node:fs"; |
| 4 | import { tmpdir } from "node:os"; |
| 5 | import { join } from "node:path"; |
| 6 | |
| 7 | // Point the app at a throwaway DB + blob dir before importing it, so these |
| 8 | // integration tests never touch the real ./db. The dynamic import has to run |
| 9 | // after the env is set, hence the top-level await. |
| 10 | const TMP = mkdtempSync(join(tmpdir(), "zbin-test-")); |
| 11 | const BLOB_DIR = join(TMP, "blobs"); |
| 12 | process.env.ZBIN_DB_PATH = join(TMP, "db.sqlite"); |
| 13 | process.env.ZBIN_BLOB_DIR = BLOB_DIR; |
| 14 | |
| 15 | const { app } = await import("./index"); |
| 16 | const { encrypt, decrypt } = await import("./crypto"); |
| 17 | |
| 18 | afterAll(() => rmSync(TMP, { recursive: true, force: true })); |
| 19 | |
| 20 | const utf8 = new TextEncoder(); |
| 21 | const text = new TextDecoder(); |
| 22 | |
| 23 | // A 1x1 PNG header — enough magic for file-type to detect image/png. |
| 24 | const PNG = new Uint8Array([ |
| 25 | 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, |
| 26 | 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, |
| 27 | 0x00, 0x00, 0x00, |
| 28 | ]); |
| 29 | |
| 30 | function upload( |
| 31 | fields: Record<string, string>, |
| 32 | fileName: string, |
| 33 | bytes: Uint8Array | string, |
| 34 | ): Promise<Response> { |
| 35 | const fd = new FormData(); |
| 36 | // Non-file fields first; the "file" part must come LAST (the server relies on |
| 37 | // the other fields being known before the bytes stream in). |
| 38 | for (const [k, v] of Object.entries(fields)) fd.append(k, v); |
| 39 | fd.append("file", new Blob([bytes as BlobPart]), fileName); |
| 40 | return app.handle( |
| 41 | new Request("http://localhost/upload", { method: "POST", body: fd }), |
| 42 | ); |
| 43 | } |
| 44 | |
| 45 | async function uploadId( |
| 46 | fields: Record<string, string>, |
| 47 | fileName: string, |
| 48 | bytes: Uint8Array | string, |
| 49 | ): Promise<string> { |
| 50 | const res = await upload(fields, fileName, bytes); |
| 51 | expect(res.status).toBe(303); |
| 52 | const uuid = (res.headers.get("location") ?? "").replace("/show/", ""); |
| 53 | expect(uuid).toMatch(/^[0-9a-fA-F-]{36}$/); |
| 54 | return uuid; |
| 55 | } |
| 56 | |
| 57 | function get(path: string, cookie?: string): Promise<Response> { |
| 58 | const headers: Record<string, string> = {}; |
| 59 | if (cookie) headers.cookie = cookie; |
| 60 | return app.handle(new Request(`http://localhost${path}`, { headers })); |
| 61 | } |
| 62 | |
| 63 | describe("home page", () => { |
| 64 | test("GET / serves the upload form", async () => { |
| 65 | const res = await get("/"); |
| 66 | expect(res.status).toBe(200); |
| 67 | const body = await res.text(); |
| 68 | expect(body).toContain('id="uploadForm"'); |
| 69 | expect(body).toContain("ZBin"); |
| 70 | }); |
| 71 | }); |
| 72 | |
| 73 | describe("plaintext round-trip", () => { |
| 74 | test("upload → show renders the content, raw returns the exact bytes", async () => { |
| 75 | const uuid = await uploadId( |
| 76 | { filetype: "none" }, |
| 77 | "hello.txt", |
| 78 | "hello world", |
| 79 | ); |
| 80 | |
| 81 | const show = await get(`/show/${uuid}`); |
| 82 | expect(show.status).toBe(200); |
| 83 | const showBody = await show.text(); |
| 84 | expect(showBody).toContain("hello world"); |
| 85 | expect(showBody).toContain("hello.txt"); |
| 86 | |
| 87 | const raw = await get(`/raw/${uuid}`); |
| 88 | expect(raw.status).toBe(200); |
| 89 | expect(await raw.text()).toBe("hello world"); |
| 90 | // Non-media plaintext is served as a defensive attachment, never inline. |
| 91 | expect(raw.headers.get("encrypted")).toBe("false"); |
| 92 | expect(raw.headers.get("x-content-type-options")).toBe("nosniff"); |
| 93 | expect(raw.headers.get("content-type")).toBe("application/octet-stream"); |
| 94 | expect(raw.headers.get("content-disposition")).toContain("attachment"); |
| 95 | }); |
| 96 | |
| 97 | test("delete_in_minutes is reflected on the show page", async () => { |
| 98 | const uuid = await uploadId( |
| 99 | { filetype: "none", delete_in_minutes: "60" }, |
| 100 | "t.txt", |
| 101 | "bye", |
| 102 | ); |
| 103 | expect(await (await get(`/show/${uuid}`)).text()).toContain("delete at"); |
| 104 | }); |
| 105 | }); |
| 106 | |
| 107 | describe("media handling (content-type safety)", () => { |
| 108 | test("a sniffed image is served inline with its real type", async () => { |
| 109 | const uuid = await uploadId({ filetype: "blob" }, "pixel.png", PNG); |
| 110 | |
| 111 | const raw = await get(`/raw/${uuid}`); |
| 112 | expect(raw.headers.get("content-type")).toBe("image/png"); |
| 113 | expect(raw.headers.get("content-disposition")).toContain("inline"); |
| 114 | expect(raw.headers.get("x-content-type-options")).toBe("nosniff"); |
| 115 | |
| 116 | const show = await get(`/show/${uuid}`); |
| 117 | const body = await show.text(); |
| 118 | expect(body).toContain("<img"); |
| 119 | expect(body).toContain(`/raw/${uuid}`); |
| 120 | }); |
| 121 | |
| 122 | test("oversized media (non-blob filetype) still previews, not 'too large'", async () => { |
| 123 | // Default filetype is "none", not "blob"; a big media file must still be |
| 124 | // previewed via /raw (which streams) rather than hitting the text cap. |
| 125 | const bigImage = new Uint8Array(1024 * 1024 + 100); |
| 126 | bigImage.set(PNG, 0); |
| 127 | const uuid = await uploadId({ filetype: "none" }, "big.png", bigImage); |
| 128 | |
| 129 | const body = await (await get(`/show/${uuid}`)).text(); |
| 130 | expect(body).toContain("<img"); |
| 131 | expect(body).not.toContain("too large to preview"); |
| 132 | }); |
| 133 | }); |
| 134 | |
| 135 | describe("server-side encryption", () => { |
| 136 | const PW = "hunter2"; |
| 137 | |
| 138 | test("show: overlay without a password, content with the right one, 403 with a wrong one", async () => { |
| 139 | const uuid = await uploadId( |
| 140 | { filetype: "none", password: PW }, |
| 141 | "secret.txt", |
| 142 | "top secret", |
| 143 | ); |
| 144 | |
| 145 | const noCookie = await get(`/show/${uuid}`); |
| 146 | expect(noCookie.status).toBe(200); |
| 147 | expect(await noCookie.text()).toContain("decrypt-overlay"); |
| 148 | |
| 149 | const right = await get(`/show/${uuid}`, `password=${PW}`); |
| 150 | expect(right.status).toBe(200); |
| 151 | expect(await right.text()).toContain("top secret"); |
| 152 | |
| 153 | const wrong = await get(`/show/${uuid}`, "password=nope"); |
| 154 | expect(wrong.status).toBe(403); |
| 155 | expect(await wrong.text()).toContain("Incorrect password"); |
| 156 | }); |
| 157 | |
| 158 | test("raw: 401 without a password, plaintext with the right one", async () => { |
| 159 | const uuid = await uploadId( |
| 160 | { filetype: "none", password: PW }, |
| 161 | "secret.txt", |
| 162 | "top secret", |
| 163 | ); |
| 164 | |
| 165 | expect((await get(`/raw/${uuid}`)).status).toBe(401); |
| 166 | |
| 167 | const raw = await get(`/raw/${uuid}`, `password=${PW}`); |
| 168 | expect(raw.status).toBe(200); |
| 169 | expect(raw.headers.get("encrypted")).toBe("true"); |
| 170 | expect(await raw.text()).toBe("top secret"); |
| 171 | }); |
| 172 | |
| 173 | test("raw ?ignore_password serves the encrypted bytes, decryptable client-side", async () => { |
| 174 | const uuid = await uploadId( |
| 175 | { filetype: "none", password: PW }, |
| 176 | "secret.txt", |
| 177 | "top secret", |
| 178 | ); |
| 179 | |
| 180 | const raw = await get(`/raw/${uuid}?ignore_password=true`); |
| 181 | expect(raw.status).toBe(200); |
| 182 | expect(raw.headers.get("encrypted")).toBe("true"); |
| 183 | const cipher = new Uint8Array(await raw.arrayBuffer()); |
| 184 | expect(text.decode(cipher)).not.toBe("top secret"); |
| 185 | // The on-disk format must match the shared crypto module byte-for-byte. |
| 186 | expect(text.decode(await decrypt(cipher, PW))).toBe("top secret"); |
| 187 | }); |
| 188 | }); |
| 189 | |
| 190 | describe("already-encrypted (client-side) uploads", () => { |
| 191 | test("opaque bytes are stored as-is and round-trip", async () => { |
| 192 | const cipher = await encrypt(utf8.encode("client side"), "pw"); |
| 193 | const uuid = await uploadId( |
| 194 | { filetype: "none", encrypted: "on" }, |
| 195 | "blob.bin", |
| 196 | cipher, |
| 197 | ); |
| 198 | |
| 199 | // No password is known to the server, so /show defers to the client flow. |
| 200 | expect(await (await get(`/show/${uuid}`)).text()).toContain( |
| 201 | "decrypt-overlay", |
| 202 | ); |
| 203 | |
| 204 | const raw = await get(`/raw/${uuid}?ignore_password=true`); |
| 205 | const stored = new Uint8Array(await raw.arrayBuffer()); |
| 206 | expect(stored).toEqual(new Uint8Array(cipher)); |
| 207 | expect(text.decode(await decrypt(stored, "pw"))).toBe("client side"); |
| 208 | }); |
| 209 | }); |
| 210 | |
| 211 | describe("large text preview cap", () => { |
| 212 | test("oversized text isn't rendered inline but still downloads in full", async () => { |
| 213 | const big = "a".repeat(1024 * 1024 + 100); // just over MAX_TEXT_PREVIEW_BYTES |
| 214 | const uuid = await uploadId({ filetype: "none" }, "big.txt", big); |
| 215 | |
| 216 | const show = await get(`/show/${uuid}`); |
| 217 | expect(await show.text()).toContain("too large to preview"); |
| 218 | |
| 219 | const raw = await get(`/raw/${uuid}`); |
| 220 | expect((await raw.arrayBuffer()).byteLength).toBe(big.length); |
| 221 | }); |
| 222 | }); |
| 223 | |
| 224 | describe("upload validation", () => { |
| 225 | test("rejects a request with no file part", async () => { |
| 226 | const fd = new FormData(); |
| 227 | fd.append("filetype", "none"); |
| 228 | const res = await app.handle( |
| 229 | new Request("http://localhost/upload", { method: "POST", body: fd }), |
| 230 | ); |
| 231 | expect(res.status).toBe(400); |
| 232 | expect(await res.text()).toContain("No file"); |
| 233 | }); |
| 234 | |
| 235 | test("rejects a form field after the file part", async () => { |
| 236 | const fd = new FormData(); |
| 237 | fd.append("filetype", "none"); |
| 238 | fd.append("file", new Blob(["x"]), "f.txt"); |
| 239 | fd.append("late", "boom"); // arrives after the file → must be rejected |
| 240 | const res = await app.handle( |
| 241 | new Request("http://localhost/upload", { method: "POST", body: fd }), |
| 242 | ); |
| 243 | expect(res.status).toBe(400); |
| 244 | expect(await res.text()).toContain("last form field"); |
| 245 | }); |
| 246 | |
| 247 | test("rejects an unknown filetype", async () => { |
| 248 | const res = await upload({ filetype: "not-a-language" }, "f.txt", "x"); |
| 249 | expect(res.status).toBe(400); |
| 250 | expect(await res.text()).toContain("filetype"); |
| 251 | }); |
| 252 | |
| 253 | test("rejects an over-long filename", async () => { |
| 254 | const res = await upload( |
| 255 | { filetype: "none", filename: "x".repeat(256) }, |
| 256 | "f.txt", |
| 257 | "x", |
| 258 | ); |
| 259 | expect(res.status).toBe(400); |
| 260 | expect(await res.text()).toContain("Filename too long"); |
| 261 | }); |
| 262 | |
| 263 | test("rejects a non-numeric delete_in_minutes", async () => { |
| 264 | const res = await upload( |
| 265 | { filetype: "none", delete_in_minutes: "soon" }, |
| 266 | "f.txt", |
| 267 | "x", |
| 268 | ); |
| 269 | expect(res.status).toBe(400); |
| 270 | expect(await res.text()).toContain("delete_in_minutes"); |
| 271 | }); |
| 272 | |
| 273 | test("rejects a non-multipart body", async () => { |
| 274 | const res = await app.handle( |
| 275 | new Request("http://localhost/upload", { |
| 276 | method: "POST", |
| 277 | headers: { "content-type": "application/json" }, |
| 278 | body: "{}", |
| 279 | }), |
| 280 | ); |
| 281 | expect(res.status).toBe(400); |
| 282 | }); |
| 283 | }); |
| 284 | |
| 285 | describe("lookup failures", () => { |
| 286 | test("unknown uuid → 404 on show and raw", async () => { |
| 287 | const missing = randomUUID(); |
| 288 | expect((await get(`/show/${missing}`)).status).toBe(404); |
| 289 | expect((await get(`/raw/${missing}`)).status).toBe(404); |
| 290 | }); |
| 291 | |
| 292 | test("malformed uuid is rejected by validation", async () => { |
| 293 | expect((await get("/show/not-a-uuid")).status).toBe(422); |
| 294 | }); |
| 295 | |
| 296 | test("a row whose blob is gone → 404 instead of a 500", async () => { |
| 297 | const uuid = await uploadId({ filetype: "none" }, "f.txt", "data"); |
| 298 | rmSync(join(BLOB_DIR, uuid)); // delete the blob out from under the row |
| 299 | expect((await get(`/show/${uuid}`)).status).toBe(404); |
| 300 | expect((await get(`/raw/${uuid}`)).status).toBe(404); |
| 301 | }); |
| 302 | }); |
| 303 |