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,
26 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
27 0x08, 0x06, 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({ filetype: "none" }, "hello.txt", "hello world");
76
77 const show = await get(`/show/${uuid}`);
78 expect(show.status).toBe(200);
79 const showBody = await show.text();
80 expect(showBody).toContain("hello world");
81 expect(showBody).toContain("hello.txt");
82
83 const raw = await get(`/raw/${uuid}`);
84 expect(raw.status).toBe(200);
85 expect(await raw.text()).toBe("hello world");
86 // Non-media plaintext is served as a defensive attachment, never inline.
87 expect(raw.headers.get("encrypted")).toBe("false");
88 expect(raw.headers.get("x-content-type-options")).toBe("nosniff");
89 expect(raw.headers.get("content-type")).toBe("application/octet-stream");
90 expect(raw.headers.get("content-disposition")).toContain("attachment");
91 });
92
93 test("delete_in_minutes is reflected on the show page", async () => {
94 const uuid = await uploadId(
95 { filetype: "none", delete_in_minutes: "60" },
96 "t.txt",
97 "bye",
98 );
99 expect(await (await get(`/show/${uuid}`)).text()).toContain("delete at");
100 });
101});
102
103describe("media handling (content-type safety)", () => {
104 test("a sniffed image is served inline with its real type", async () => {
105 const uuid = await uploadId({ filetype: "blob" }, "pixel.png", PNG);
106
107 const raw = await get(`/raw/${uuid}`);
108 expect(raw.headers.get("content-type")).toBe("image/png");
109 expect(raw.headers.get("content-disposition")).toContain("inline");
110 expect(raw.headers.get("x-content-type-options")).toBe("nosniff");
111
112 const show = await get(`/show/${uuid}`);
113 const body = await show.text();
114 expect(body).toContain("<img");
115 expect(body).toContain(`/raw/${uuid}`);
116 });
117
118 test("oversized media (non-blob filetype) still previews, not 'too large'", async () => {
119 // Default filetype is "none", not "blob"; a big media file must still be
120 // previewed via /raw (which streams) rather than hitting the text cap.
121 const bigImage = new Uint8Array(1024 * 1024 + 100);
122 bigImage.set(PNG, 0);
123 const uuid = await uploadId({ filetype: "none" }, "big.png", bigImage);
124
125 const body = await (await get(`/show/${uuid}`)).text();
126 expect(body).toContain("<img");
127 expect(body).not.toContain("too large to preview");
128 });
129});
130
131describe("server-side encryption", () => {
132 const PW = "hunter2";
133
134 test("show: overlay without a password, content with the right one, 403 with a wrong one", async () => {
135 const uuid = await uploadId(
136 { filetype: "none", password: PW },
137 "secret.txt",
138 "top secret",
139 );
140
141 const noCookie = await get(`/show/${uuid}`);
142 expect(noCookie.status).toBe(200);
143 expect(await noCookie.text()).toContain("decrypt-overlay");
144
145 const right = await get(`/show/${uuid}`, `password=${PW}`);
146 expect(right.status).toBe(200);
147 expect(await right.text()).toContain("top secret");
148
149 const wrong = await get(`/show/${uuid}`, "password=nope");
150 expect(wrong.status).toBe(403);
151 expect(await wrong.text()).toContain("Incorrect password");
152 });
153
154 test("raw: 401 without a password, plaintext with the right one", async () => {
155 const uuid = await uploadId(
156 { filetype: "none", password: PW },
157 "secret.txt",
158 "top secret",
159 );
160
161 expect((await get(`/raw/${uuid}`)).status).toBe(401);
162
163 const raw = await get(`/raw/${uuid}`, `password=${PW}`);
164 expect(raw.status).toBe(200);
165 expect(raw.headers.get("encrypted")).toBe("true");
166 expect(await raw.text()).toBe("top secret");
167 });
168
169 test("raw ?ignore_password serves the encrypted bytes, decryptable client-side", async () => {
170 const uuid = await uploadId(
171 { filetype: "none", password: PW },
172 "secret.txt",
173 "top secret",
174 );
175
176 const raw = await get(`/raw/${uuid}?ignore_password=true`);
177 expect(raw.status).toBe(200);
178 expect(raw.headers.get("encrypted")).toBe("true");
179 const cipher = new Uint8Array(await raw.arrayBuffer());
180 expect(text.decode(cipher)).not.toBe("top secret");
181 // The on-disk format must match the shared crypto module byte-for-byte.
182 expect(text.decode(await decrypt(cipher, PW))).toBe("top secret");
183 });
184});
185
186describe("already-encrypted (client-side) uploads", () => {
187 test("opaque bytes are stored as-is and round-trip", async () => {
188 const cipher = await encrypt(utf8.encode("client side"), "pw");
189 const uuid = await uploadId(
190 { filetype: "none", encrypted: "on" },
191 "blob.bin",
192 cipher,
193 );
194
195 // No password is known to the server, so /show defers to the client flow.
196 expect(await (await get(`/show/${uuid}`)).text()).toContain("decrypt-overlay");
197
198 const raw = await get(`/raw/${uuid}?ignore_password=true`);
199 const stored = new Uint8Array(await raw.arrayBuffer());
200 expect(stored).toEqual(new Uint8Array(cipher));
201 expect(text.decode(await decrypt(stored, "pw"))).toBe("client side");
202 });
203});
204
205describe("large text preview cap", () => {
206 test("oversized text isn't rendered inline but still downloads in full", async () => {
207 const big = "a".repeat(1024 * 1024 + 100); // just over MAX_TEXT_PREVIEW_BYTES
208 const uuid = await uploadId({ filetype: "none" }, "big.txt", big);
209
210 const show = await get(`/show/${uuid}`);
211 expect(await show.text()).toContain("too large to preview");
212
213 const raw = await get(`/raw/${uuid}`);
214 expect((await raw.arrayBuffer()).byteLength).toBe(big.length);
215 });
216});
217
218describe("upload validation", () => {
219 test("rejects a request with no file part", async () => {
220 const fd = new FormData();
221 fd.append("filetype", "none");
222 const res = await app.handle(
223 new Request("http://localhost/upload", { method: "POST", body: fd }),
224 );
225 expect(res.status).toBe(400);
226 expect(await res.text()).toContain("No file");
227 });
228
229 test("rejects a form field after the file part", async () => {
230 const fd = new FormData();
231 fd.append("filetype", "none");
232 fd.append("file", new Blob(["x"]), "f.txt");
233 fd.append("late", "boom"); // arrives after the file → must be rejected
234 const res = await app.handle(
235 new Request("http://localhost/upload", { method: "POST", body: fd }),
236 );
237 expect(res.status).toBe(400);
238 expect(await res.text()).toContain("last form field");
239 });
240
241 test("rejects an unknown filetype", async () => {
242 const res = await upload({ filetype: "not-a-language" }, "f.txt", "x");
243 expect(res.status).toBe(400);
244 expect(await res.text()).toContain("filetype");
245 });
246
247 test("rejects an over-long filename", async () => {
248 const res = await upload(
249 { filetype: "none", filename: "x".repeat(256) },
250 "f.txt",
251 "x",
252 );
253 expect(res.status).toBe(400);
254 expect(await res.text()).toContain("Filename too long");
255 });
256
257 test("rejects a non-numeric delete_in_minutes", async () => {
258 const res = await upload(
259 { filetype: "none", delete_in_minutes: "soon" },
260 "f.txt",
261 "x",
262 );
263 expect(res.status).toBe(400);
264 expect(await res.text()).toContain("delete_in_minutes");
265 });
266
267 test("rejects a non-multipart body", async () => {
268 const res = await app.handle(
269 new Request("http://localhost/upload", {
270 method: "POST",
271 headers: { "content-type": "application/json" },
272 body: "{}",
273 }),
274 );
275 expect(res.status).toBe(400);
276 });
277});
278
279describe("lookup failures", () => {
280 test("unknown uuid → 404 on show and raw", async () => {
281 const missing = randomUUID();
282 expect((await get(`/show/${missing}`)).status).toBe(404);
283 expect((await get(`/raw/${missing}`)).status).toBe(404);
284 });
285
286 test("malformed uuid is rejected by validation", async () => {
287 expect((await get("/show/not-a-uuid")).status).toBe(422);
288 });
289
290 test("a row whose blob is gone → 404 instead of a 500", async () => {
291 const uuid = await uploadId({ filetype: "none" }, "f.txt", "data");
292 rmSync(join(BLOB_DIR, uuid)); // delete the blob out from under the row
293 expect((await get(`/show/${uuid}`)).status).toBe(404);
294 expect((await get(`/raw/${uuid}`)).status).toBe(404);
295 });
296});
297