routes.test.ts
Raw
1import { afterAll, describe, expect, test } from "bun:test";
2import { randomUUID } from "node:crypto";
3import { mkdtempSync, rmSync } from "node:fs";
4import { tmpdir } from "node:os";
5import { 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.
10const TMP = mkdtempSync(join(tmpdir(), "zbin-test-"));
11const BLOB_DIR = join(TMP, "blobs");
12process.env.ZBIN_DB_PATH = join(TMP, "db.sqlite");
13process.env.ZBIN_BLOB_DIR = BLOB_DIR;
14
15const { app } = await import("./index");
16const { encrypt, decrypt } = await import("./crypto");
17
18afterAll(() => rmSync(TMP, { recursive: true, force: true }));
19
20const utf8 = new TextEncoder();
21const text = new TextDecoder();
22
23// A 1x1 PNG header — enough magic for file-type to detect image/png.
24const 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
30function 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
45async 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
57function 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
63describe("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
73describe("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
107describe("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
135describe("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
190describe("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
211describe("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
224describe("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
285describe("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