// #221 — integration smoke for /api/upload + /api/files/:file_id + /api/task // attachments. Spawns the real Bun.serve hub in a child process, hits it // with real fetch() calls, asserts on the 6-item security checklist. // // Runs entirely under bun test — no Docker needed for the in-repo // signal (Docker harness is documented in #221 for 测试马's UAT, but // this file already exercises the same code path). The hub binds to // an ephemeral port so multiple test runs don't collide. import { describe, expect, test, beforeAll, afterAll } from "bun:test"; import "./require-explicit-test-db.js"; import { mkdtempSync, rmSync, existsSync } from "fs"; import { join } from "path"; import { tmpdir } from "os"; import { register, login } from "./auth.js"; const UPLOADS_DIR = mkdtempSync(join(tmpdir(), "anet-upload-http-fs-")); // #438 corrective: private bootServer({ port: 0 }) instance — the OS // assigns the port and BASE is derived from the ACTUAL bound port, so // this suite is immune to both the parent-side random-port TOCTOU and // the aggregate module-cache no-op (import no longer boots anything). let BASE = ""; let server: any; let userToken = ""; let userNetworkId = ""; beforeAll(async () => { // Bootstrap a real admin + token on the temp DB so the server can // resolve it via requireAuth (we don't want to short-circuit auth — // we want to validate the production code path). process.env.COMMHUB_UPLOADS_DIR = UPLOADS_DIR; process.env.HOST = "127.0.0.1"; // Use a unique username + strong password each run so we don't trip // over "already taken" from a prior test in the same DB. Strong // password keeps us past validatePasswordStrength even when we're // not the first user in the DB. const username = `upload_admin_${Date.now()}_${Math.floor(Math.random() * 1000)}`; const password = "BootstrapPw123Aa!"; let r = register(username, password, undefined, "seed"); if (!r.ok) { console.error("[test bootstrap] register failed:", r.error); } if (r.token) { userToken = r.token; userNetworkId = r.network_id ?? ""; } else { const lr = login(username, password); if (lr.token) userToken = lr.token; if (lr.network_id) userNetworkId = lr.network_id; } expect(userToken).toBeTruthy(); // Importing is side-effect-free now; boot a private instance and // read the real port back. const { bootServer } = await import("./server.js"); server = bootServer({ port: 0, hostname: "127.0.0.1" }); BASE = `http://127.0.0.1:${server.port}`; // Tiny settle so the listener is ready. await new Promise((r) => setTimeout(r, 100)); }); afterAll(() => { try { server?.stop?.(true); } catch {} try { rmSync(UPLOADS_DIR, { recursive: true, force: true }); } catch {} }); function authHeaders(): Record { return { "Authorization": `Bearer ${userToken}` }; } // #503 — the bootstrap user is the first account in this suite's fresh // DB, so it is a system admin, and admins must name the target network // explicitly (attribution is never guessed). uploadNetworkId() keeps // that detail in one place. function uploadNetworkId(): string { if (!userNetworkId) throw new Error("test bootstrap: userNetworkId missing"); return `?network_id=${encodeURIComponent(userNetworkId)}`; } async function uploadFile(content: Uint8Array, filename: string, mime: string, opts: { skipAuth?: boolean; overrideContentLength?: string | null } = {}): Promise { const form = new FormData(); form.append("file", new Blob([content], { type: mime }), filename); const headers: Record = opts.skipAuth ? {} : { ...authHeaders() }; // Note: fetch sets Content-Length automatically; only override when // testing the missing-header path. const res = await fetch(`${BASE}/api/upload${uploadNetworkId()}`, { method: "POST", body: form, headers }); return res; } describe("POST /api/upload — 6-item security checklist (HTTP integration)", () => { test("(item 5) anonymous request → 401", async () => { const res = await uploadFile(new Uint8Array([1, 2, 3]), "tiny.bin", "application/octet-stream", { skipAuth: true }); expect(res.status).toBe(401); }); test("(item 5) bad bearer token → 401", async () => { const form = new FormData(); form.append("file", new Blob([new Uint8Array([1, 2, 3])]), "tiny.bin"); const res = await fetch(`${BASE}/api/upload`, { method: "POST", body: form, headers: { "Authorization": "Bearer this-is-not-a-real-token" }, }); expect(res.status).toBe(401); }); test("(items 1+2) successful upload returns server-generated file_id + url, blob lands inside uploads root", async () => { const content = new Uint8Array(1024).fill(0xab); const res = await uploadFile(content, "client-supplied-name.bin", "application/octet-stream"); expect(res.status).toBe(200); const body = await res.json() as any; expect(body.ok).toBe(true); expect(typeof body.file_id).toBe("string"); expect(body.file_id.length).toBe(32); expect(body.url).toBe(`/api/files/${body.file_id}`); expect(body.size).toBe(1024); // The path MUST live inside the configured uploads root. expect(String(body.path).startsWith(UPLOADS_DIR)).toBe(true); // The path MUST NOT contain the client-supplied filename. expect(String(body.path).includes("client-supplied-name")).toBe(false); // Blob really exists on disk. expect(existsSync(body.path)).toBe(true); }); test("(item 4 stage-2) 12 MiB cap — slightly over the limit is rejected with 413", async () => { const overLimit = new Uint8Array(12 * 1024 * 1024 + 16).fill(0xcd); const res = await uploadFile(overLimit, "over.bin", "application/octet-stream"); expect(res.status).toBe(413); const body = await res.json() as any; expect(body.ok).toBe(false); expect(body.error).toBe("payload_too_large"); expect(body.limit_bytes).toBe(12 * 1024 * 1024); }); test("(item 4 stage-1) missing Content-Length → 411 Length Required", async () => { // Forge a raw request with a body but no Content-Length. The // simplest way to do this in Bun is to construct a Request with a // streaming ReadableStream body — Bun then doesn't set // Content-Length on outgoing fetch. const stream = new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode("not actually multipart")); controller.close(); }, }); const res = await fetch(`${BASE}/api/upload${uploadNetworkId()}`, { method: "POST", body: stream, headers: { ...authHeaders(), "Content-Type": "multipart/form-data; boundary=xxx", "Transfer-Encoding": "chunked" }, // @ts-expect-error — Bun-specific to force streaming upload duplex: "half", }); expect(res.status).toBe(411); }); test("(extra defense) non-multipart body is rejected (415 or 400)", async () => { // We accept either 415 (Content-Type !multipart) or 400 (body // failed to parse as multipart). Both mean "we refused to accept // this body" which is the security property we care about; the // exact code depends on whether Bun's multipart parser bails before // or after our Content-Type pre-check. const res = await fetch(`${BASE}/api/upload${uploadNetworkId()}`, { method: "POST", body: JSON.stringify({ nope: 1 }), headers: { ...authHeaders(), "Content-Type": "application/json" }, }); expect([400, 415]).toContain(res.status); }); }); describe("GET /api/files/:file_id — download safety", () => { let fileId = ""; let rangeFileId = ""; const rangeContent = Uint8Array.from({ length: 64 }, (_, i) => i); beforeAll(async () => { const content = new TextEncoder().encode("hello-world-from-#221-integration-test"); const res = await uploadFile(content, "greeting.txt", "text/plain"); const body = await res.json() as any; fileId = body.file_id; const rangeRes = await uploadFile(rangeContent, "range.bin", "application/octet-stream"); const rangeBody = await rangeRes.json() as any; rangeFileId = rangeBody.file_id; }); test("(item 3) download returns the blob with Content-Disposition: attachment + X-Content-Type-Options: nosniff", async () => { const res = await fetch(`${BASE}/api/files/${fileId}`, { headers: authHeaders() }); expect(res.status).toBe(200); expect(res.headers.get("Content-Disposition") ?? "").toMatch(/^attachment(;|$)/i); expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff"); expect(res.headers.get("Content-Type")).toBe("application/octet-stream"); expect(res.headers.get("Accept-Ranges")).toBe("bytes"); const text = await res.text(); expect(text).toBe("hello-world-from-#221-integration-test"); }); test("#514 Range bytes=a-b returns exact 206 partial content, not a tolerant 200/full body", async () => { const res = await fetch(`${BASE}/api/files/${rangeFileId}`, { headers: { ...authHeaders(), Range: "bytes=3-8" }, }); expect(res.status).toBe(206); expect(res.headers.get("Content-Range")).toBe("bytes 3-8/64"); expect(res.headers.get("Content-Length")).toBe("6"); expect(res.headers.get("Accept-Ranges")).toBe("bytes"); const body = new Uint8Array(await res.arrayBuffer()); expect(body.length).toBe(6); expect([...body]).toEqual([...rangeContent.slice(3, 9)]); }); test("#514 Range bytes=N- returns the exact open-ended tail", async () => { const res = await fetch(`${BASE}/api/files/${rangeFileId}`, { headers: { ...authHeaders(), Range: "bytes=60-" }, }); expect(res.status).toBe(206); expect(res.headers.get("Content-Range")).toBe("bytes 60-63/64"); expect(res.headers.get("Content-Length")).toBe("4"); const body = new Uint8Array(await res.arrayBuffer()); expect(body.length).toBe(4); expect([...body]).toEqual([...rangeContent.slice(60)]); }); test("#514 Range bytes=-N returns the exact suffix", async () => { const res = await fetch(`${BASE}/api/files/${rangeFileId}`, { headers: { ...authHeaders(), Range: "bytes=-7" }, }); expect(res.status).toBe(206); expect(res.headers.get("Content-Range")).toBe("bytes 57-63/64"); expect(res.headers.get("Content-Length")).toBe("7"); const body = new Uint8Array(await res.arrayBuffer()); expect(body.length).toBe(7); expect([...body]).toEqual([...rangeContent.slice(57)]); }); test("#514 unsatisfiable Range returns 416 with concrete Content-Range size", async () => { const res = await fetch(`${BASE}/api/files/${rangeFileId}`, { headers: { ...authHeaders(), Range: "bytes=64-70" }, }); expect(res.status).toBe(416); expect(res.headers.get("Content-Range")).toBe("bytes */64"); expect(res.headers.get("Content-Length")).toBe("0"); const body = new Uint8Array(await res.arrayBuffer()); expect(body.length).toBe(0); }); test("#514 HEAD Range mirrors GET status and range headers without a body", async () => { const res = await fetch(`${BASE}/api/files/${rangeFileId}`, { method: "HEAD", headers: { ...authHeaders(), Range: "bytes=3-8" }, }); expect(res.status).toBe(206); expect(res.headers.get("Content-Range")).toBe("bytes 3-8/64"); expect(res.headers.get("Content-Length")).toBe("6"); expect(res.headers.get("Accept-Ranges")).toBe("bytes"); const body = new Uint8Array(await res.arrayBuffer()); expect(body.length).toBe(0); }); test("anonymous download is 401", async () => { const res = await fetch(`${BASE}/api/files/${fileId}`); expect(res.status).toBe(401); }); test("traversal attempt in path is rejected at the regex layer with 400", async () => { const res = await fetch(`${BASE}/api/files/..%2Fetc%2Fpasswd`, { headers: authHeaders() }); expect(res.status).toBe(400); const body = await res.json() as any; expect(body.error).toBe("bad_file_id"); }); test("unknown file_id is 404", async () => { const res = await fetch(`${BASE}/api/files/0123456789abcdef0123456789abcdef`, { headers: authHeaders() }); expect(res.status).toBe(404); }); }); // NOTE on /api/task attachments coverage: the validation logic // (validateAttachments) is exercised end-to-end in // uploads.test.ts with 6 cases covering happy path / too-many / // bad type / bad file_id / size cap / non-array. The HTTP wire-up // (server/src/index.ts inside the /api/task handler) is a one-line // merge that surfaces `validateAttachments`'s structured error as a // 400 `bad_attachments` to the caller — see the #221 contract in // the issue comment for the full request/response shape that N站马 // APP will consume.