// #495 — file download authorization (P0 staged hotfix, PR #500). // // Before this fix, /api/files/:file_id only checked authentication. // Any authenticated principal could download any file cross-network by // guessing / obtaining a file_id. // // This suite spawns a real Bun.serve hub in-process and validates the // allow-list (owner + admin + legacy-master + null-owner-DEV_OPEN). // Same-network non-owner is EXPLICITLY out of scope in this stage — // see PR body access matrix + follow-up #503. That "same-network peer // returns 404" is an intentional carve-out here, not a bug. // // The DEV_OPEN branch is verified in a sibling test file // (file-download-authz-dev-open.test.ts) because DEV_OPEN is captured // at server.ts module-load time and can't be flipped mid-suite. // // Skip discipline: master-token tests use `test.skipIf(!masterTokenActive)` // so aggregate `bun test src/` reports "skipped" (not "passed") when // the loaded server module's frozen AUTH_TOKEN binding predates our // env setup. `if (!x) return` was previously used and Bun records // that as PASS — which is exactly the "skip-as-pass" trap that let // an earlier round claim coverage it never had. (See project memory // `feedback_skip_via_early_return_shows_as_pass`.) import { describe, expect, test, beforeAll, afterAll } from "bun:test"; import { mkdtempSync, rmSync, writeFileSync, existsSync } from "fs"; import { join } from "path"; import { tmpdir } from "os"; const SERVER_DB = mkdtempSync(join(tmpdir(), "anet-495-db-")) + "/commhub.db"; const UPLOADS_DIR = mkdtempSync(join(tmpdir(), "anet-495-fs-")); const MASTER_TOKEN = process.env.COMMHUB_AUTH_TOKEN ?? `master-495-${Date.now()}-${Math.floor(Math.random() * 100000)}`; // Set env BEFORE importing anything that pulls in db-adapter.ts (which // refuses to open a default DB under NODE_ENV=test) or server.ts (which // captures DEV_OPEN + AUTH_TOKEN at module load). process.env.COMMHUB_DB ||= SERVER_DB; process.env.COMMHUB_UPLOADS_DIR = UPLOADS_DIR; process.env.HOST = "127.0.0.1"; process.env.COMMHUB_AUTH_TOKEN = MASTER_TOKEN; delete process.env.COMMHUB_DEV_OPEN; const { register, addNetworkMember, getUserAllNetworks } = await import("./auth.js"); const { db } = await import("./db.js"); // Seed accounts. register()'s "first-user-is-admin" is not reliable in // aggregate (earlier test files may seed users), so we promote via SQL. const adminName = `admin_495_${Date.now()}_${Math.floor(Math.random() * 1000)}`; const rAdmin = register(adminName, "BootstrapPw123Aa!", undefined, "seed"); if (!rAdmin.ok) throw new Error(`admin register failed: ${rAdmin.error}`); const adminToken = rAdmin.token!; db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [rAdmin.user!.user_id]); const nameA = `useraaa_495_${Date.now()}_${Math.floor(Math.random() * 1000)}`; const rA = register(nameA, "BootstrapPw123Aa!", undefined, "userA"); if (!rA.ok) throw new Error(`userA register failed: ${rA.error}`); const userAToken = rA.token!; const userAUserId = rA.user!.user_id; const userANetworkId = rA.network_id!; // #503 — userA joins userB's network below, which makes userA a // SAME-network peer. Cross-network denial needs a principal who is in // neither, so userC exists purely to be an outsider. const nameC = `userccc_495_${Date.now()}_${Math.floor(Math.random() * 1000)}`; const rC = register(nameC, "BootstrapPw123Aa!", undefined, "userC"); if (!rC.ok) throw new Error(`userC register failed: ${rC.error}`); const userCToken = rC.token!; const userCUserId = rC.user!.user_id; const nameB = `userbbb_495_${Date.now()}_${Math.floor(Math.random() * 1000)}`; const rB = register(nameB, "BootstrapPw123Aa!", undefined, "userB"); if (!rB.ok) throw new Error(`userB register failed: ${rB.error}`); const userBToken = rB.token!; const userBUserId = rB.user!.user_id; const userBNetworkToken = rB.network_token!; const userBNetworkId = getUserAllNetworks(userBUserId)[0]?.network_id!; if (!userBNetworkId) throw new Error("userB default network not found"); // #500 CR2 DEV_OPEN 假门 fix — assert userB is NOT admin at fixture // setup time. If fixture ordering ever changes (e.g. auth.ts flips // "first user auto-admin" logic, or an earlier test file promotes // userB), the assertion catches it before Test A/B can silently // short-circuit through the admin branch of authorizeFileDownload // and hide a broken DEV_OPEN gate. Independent审员 caught this // class of假门 in the prior CR round — the assertion writes the // "userB is non-admin" invariant into the test itself so a future // fixture drift cannot re-open the hole. { const userBRoleRow = db.get<{ role: string }>( "SELECT role FROM users WHERE user_id = ?1", userBUserId, ); if (!userBRoleRow) throw new Error("userB not found in users table after register"); if (userBRoleRow.role === "admin") { throw new Error( `[#500 CR2 fixture-drift guard] userB was auto-promoted to admin ` + `(role=${userBRoleRow.role}). Refusing to run: the DEV_OPEN and ` + `cross-owner tests would short-circuit through the admin allow-list ` + `branch and validate nothing.`, ); } } // Add userA as a member of userB's default network. This is what makes // the "same-network non-owner" test cell meaningful — without it, that // test would only prove cross-network denial, which is a weaker claim. const addMember = addNetworkMember(userBNetworkId, userAUserId, "member", userBUserId); if (!addMember.ok) throw new Error(`add userA to userB network failed: ${addMember.error}`); const { bootServer } = await import("./server.js"); const server = bootServer({ port: 0, hostname: "127.0.0.1" }); const BASE = `http://127.0.0.1:${server.port}`; await new Promise((r) => setTimeout(r, 100)); // Probe the loaded server's frozen AUTH_TOKEN binding. If master token // works we can exercise the legacy branch; if not (aggregate load-order // effect), `test.skipIf` reports "skipped" (NOT "passed"). const _probeMaster = await fetch(`${BASE}/api/files/00000000000000000000000000000000`, { headers: { Authorization: `Bearer ${MASTER_TOKEN}` }, }); const masterTokenActive = _probeMaster.status === 404; // Probe the loaded server's frozen DEV_OPEN binding. This file is // meant to run against a DEV_OPEN=false server. In aggregate mode, // if file-download-authz-dev-open.test.ts loaded server.ts first // with DEV_OPEN=true, the singleton wins — we can't flip it. Skip // the whole production-mode suite via `describe.skipIf` (NOT // early-return in each test body) so the runner reports "skipped". async function _uploadProbe(): Promise { const form = new FormData(); form.append("file", new Blob([new TextEncoder().encode("p")], { type: "application/octet-stream" }), "p"); const r = await fetch(`${BASE}/api/upload`, { method: "POST", body: form }); return r.status; } const devOpenSuspected = (await _uploadProbe()) === 200; const isProdMode = !devOpenSuspected; afterAll(() => { try { server?.stop?.(true); } catch {} try { rmSync(UPLOADS_DIR, { recursive: true, force: true }); } catch {} try { rmSync(SERVER_DB, { recursive: true, force: true }); } catch {} delete process.env.COMMHUB_AUTH_TOKEN; }); // #503 — a utok_ holder in more than one network must name the target // network; the server refuses to guess ("first network" is not assumed). // Callers using a single-network token can still omit it. async function uploadAs(token: string, content: Uint8Array, filename: string, networkId?: string): Promise { const form = new FormData(); form.append("file", new Blob([content], { type: "application/octet-stream" }), filename); const url = networkId ? `${BASE}/api/upload?network_id=${encodeURIComponent(networkId)}` : `${BASE}/api/upload`; const res = await fetch(url, { method: "POST", body: form, headers: { Authorization: `Bearer ${token}` }, }); expect(res.status).toBe(200); const body: any = await res.json(); expect(typeof body.file_id).toBe("string"); return body.file_id; } async function downloadAs(token: string | null, fileId: string): Promise { const headers: Record = {}; if (token) headers.Authorization = `Bearer ${token}`; return fetch(`${BASE}/api/files/${fileId}`, { headers }); } async function downloadWithQueryToken(token: string, fileId: string): Promise { return fetch(`${BASE}/api/files/${fileId}?token=${encodeURIComponent(token)}`); } describe.skipIf(!isProdMode)("#495 — GET /api/files/:file_id authorization (owner-only staged)", () => { let userBFileId = ""; beforeAll(async () => { userBFileId = await uploadAs(userBToken, new TextEncoder().encode("secret-from-B"), "b-secret.txt"); expect(userBFileId.length).toBe(32); }); test("owner (userB) via utok_ downloads own file → 200 (normal path)", async () => { const res = await downloadAs(userBToken, userBFileId); expect(res.status).toBe(200); expect(await res.text()).toBe("secret-from-B"); }); test("owner (userB) via ntok_ downloads own file → 200 (agent self-upload+self-download)", async () => { const agentFileId = await uploadAs(userBNetworkToken, new TextEncoder().encode("agent-blob"), "agent.bin"); const res = await downloadAs(userBNetworkToken, agentFileId); expect(res.status).toBe(200); expect(await res.text()).toBe("agent-blob"); }); test("🔴 cross-network non-owner (userC) downloads userB's file → 404 (was 200 pre-#495)", async () => { // Precondition — userC is in NEITHER of userB's networks, so the 404 // is cross-network denial. This row used to use userA, but #503 made // userA a legitimate same-network reader; keeping userA here would // have turned #495's guarantee into a test that asserts nothing. const rows = db.all<{ network_id: string }>( "SELECT network_id FROM network_members WHERE user_id = ?1 AND network_id = ?2", userCUserId, userBNetworkId, ); expect(rows.length).toBe(0); const res = await downloadAs(userCToken, userBFileId); expect(res.status).toBe(404); const body: any = await res.json(); expect(body.error).toBe("not_found"); }); test("🔴 same-network non-owner (userA is a member of userB's network) → 200 (#503 lifted the #495 staged carve-out)", async () => { // Precondition — userA really is a member of userB's default network // (added in module setup), so the 200 represents "same-network peer // allowed" rather than an owner match or an admin bypass. const rows = db.all<{ network_id: string }>( "SELECT network_id FROM network_members WHERE user_id = ?1 AND network_id = ?2", userAUserId, userBNetworkId, ); expect(rows.length).toBe(1); expect(db.get<{ role: string }>("SELECT role FROM users WHERE user_id = ?1", userAUserId)?.role) .not.toBe("admin"); const res = await downloadAs(userAToken, userBFileId); expect(res.status).toBe(200); expect(await res.text()).toBe("secret-from-B"); }); test("admin caller downloads any file → 200 (operational access preserved)", async () => { const res = await downloadAs(adminToken, userBFileId); expect(res.status).toBe(200); expect(await res.text()).toBe("secret-from-B"); }); test.skipIf(!masterTokenActive)("legacy master AUTH_TOKEN downloads any file → 200 (single-tenant deployments)", async () => { const res = await downloadAs(MASTER_TOKEN, userBFileId); expect(res.status).toBe(200); expect(await res.text()).toBe("secret-from-B"); }); test("anonymous request → 401 (auth still required)", async () => { const res = await downloadAs(null, userBFileId); expect(res.status).toBe(401); }); test("🔴 valid query token is refused on file GET (credential must stay out of URLs)", async () => { const res = await downloadWithQueryToken(userBToken, userBFileId); expect(res.status).toBe(401); const body: any = await res.json(); expect(body.error).toBe("unauthorized"); }); test("non-owner request for an UNKNOWN file_id → 404 with same shape as owner-denied (no enumeration signal)", async () => { const res = await downloadAs(userAToken, "0123456789abcdef0123456789abcdef"); expect(res.status).toBe(404); const body: any = await res.json(); expect(body.error).toBe("not_found"); }); }); // #500 CR2 ① — HEAD verb explicit authz coverage. Pre-CR2, HEAD fell // through to a fallback 200 page; body was byte-identical to unknown // file_id so no oracle formed by accident, but it was coincidence, not // a gate. This suite asserts HEAD routes through authorizeFileDownload // per RFC 9110 §9.3.2: same status/headers as GET, body omitted. async function headAs(token: string | null, fileId: string): Promise { const headers: Record = {}; if (token) headers.Authorization = `Bearer ${token}`; return fetch(`${BASE}/api/files/${fileId}`, { method: "HEAD", headers }); } async function headWithQueryToken(token: string, fileId: string): Promise { return fetch(`${BASE}/api/files/${fileId}?token=${encodeURIComponent(token)}`, { method: "HEAD" }); } describe.skipIf(!isProdMode)("#500 CR2 — HEAD /api/files/:file_id authorization (parity with GET)", () => { let userBFileId = ""; beforeAll(async () => { userBFileId = await uploadAs(userBToken, new TextEncoder().encode("head-scenario-payload"), "b-head.txt"); }); test("owner (userB) HEAD own file → 200 + content-length header (body omitted per HEAD)", async () => { const res = await headAs(userBToken, userBFileId); expect(res.status).toBe(200); expect(res.headers.get("content-length")).toBe(String("head-scenario-payload".length)); expect(res.headers.get("content-disposition")).toContain("attachment"); expect(res.headers.get("x-content-type-options")).toBe("nosniff"); }); test("🔴 cross-network HEAD (userC HEAD userB's file) → 404 via helper (was fallback 200 pre-CR2)", async () => { // userC, not userA — #503 makes userA a same-network reader, so the // GET/HEAD parity claim needs a principal who is genuinely denied. const res = await headAs(userCToken, userBFileId); expect(res.status).toBe(404); // 404 must originate from authorizeFileDownload, not the fallback // page. Body is application/json ({"ok":false,"error":"not_found"}) // — check Content-Type so a future fallback rewrite (e.g. adding // "requested resource type: X") does not silently form an oracle // while HEAD still returns 200 from that page. expect(res.headers.get("content-type")?.toLowerCase()).toContain("application/json"); }); test("HEAD unknown file_id → 404 with same shape as cross-owner (no enumeration signal)", async () => { const res = await headAs(userAToken, "0123456789abcdef0123456789abcdef"); expect(res.status).toBe(404); expect(res.headers.get("content-type")?.toLowerCase()).toContain("application/json"); }); test("HEAD anonymous → 401 (auth still required, symmetric with GET)", async () => { const res = await headAs(null, userBFileId); expect(res.status).toBe(401); }); test("🔴 valid query token is refused on file HEAD (same gate as GET)", async () => { const res = await headWithQueryToken(userBToken, userBFileId); expect(res.status).toBe(401); }); test("admin HEAD any file → 200 (operational access preserved on HEAD)", async () => { const res = await headAs(adminToken, userBFileId); expect(res.status).toBe(200); expect(res.headers.get("content-length")).toBe(String("head-scenario-payload".length)); }); }); // #500 CR2 ④ — Upload owner persistence. Prove that new authenticated // uploads always write a truthy owner_id to the on-disk index entry. // If the upload handler ever regresses to writing owner_id=null in // requireAuth-guarded paths, D1 turns red — the invariant that // "production uploads never produce null-owner blobs" is documented // in the authorizeFileDownload comment and this test enforces it. describe.skipIf(!isProdMode)("#500 CR2 — upload persists truthy owner_id (D1)", () => { test("D1: userA authenticated upload → index entry owner_id === userA.userId (never null)", async () => { // userA belongs to two networks (own default + userB's), so #503 // requires the target network to be named explicitly. const fileId = await uploadAs(userAToken, new TextEncoder().encode("owner-persistence-check"), "d1.bin", userANetworkId); const { readFileSync } = await import("fs"); const entryPath = join(UPLOADS_DIR, ".index", `${fileId}.json`); expect(existsSync(entryPath)).toBe(true); const entry = JSON.parse(readFileSync(entryPath, "utf-8")); expect(entry.owner_id).not.toBeNull(); expect(entry.owner_id).toBe(userAUserId); expect(entry.owner).toBe(nameA); // owner username stays consistent with the token that uploaded }); test("D1b: userB authenticated upload via utok_ → owner_id === userB.userId", async () => { const fileId = await uploadAs(userBToken, new TextEncoder().encode("owner-persistence-check-b"), "d1b.bin"); const { readFileSync } = await import("fs"); const entryPath = join(UPLOADS_DIR, ".index", `${fileId}.json`); expect(existsSync(entryPath)).toBe(true); const entry = JSON.parse(readFileSync(entryPath, "utf-8")); expect(entry.owner_id).not.toBeNull(); expect(entry.owner_id).toBe(userBUserId); }); }); describe.skipIf(!isProdMode)("#495 — null-owner policy in production (DEV_OPEN=off, non-admin/non-legacy)", () => { let nullOwnerFileId = ""; beforeAll(async () => { // Simulate a legacy null-owner index entry by uploading normally // then rewriting the on-disk index to owner_id=null. This precisely // reproduces the shape a DEV_OPEN or legacy master upload would // create, without needing to spin up a second server in a // different mode. nullOwnerFileId = await uploadAs(userAToken, new TextEncoder().encode("legacy-blob"), "legacy.txt", userANetworkId); const { readFileSync: rfs, writeFileSync: wfs } = await import("fs"); const entryFile = join(UPLOADS_DIR, ".index", `${nullOwnerFileId}.json`); if (!existsSync(entryFile)) throw new Error(`index entry not found: ${entryFile}`); const entry = JSON.parse(rfs(entryFile, "utf-8")); entry.owner = null; entry.owner_id = null; // #503 — a true legacy entry predates network attribution entirely. // Deleting the key (rather than nulling it) matches what the writer // emits when there is no attribution, so this fixture exercises the // legacy compatibility branch instead of the network branch. delete entry.network_id; wfs(entryFile, JSON.stringify(entry, null, 2)); }); test.skipIf(!masterTokenActive)("null-owner + legacy master → 200 (RFC-001 read-only backward-compat)", async () => { const res = await downloadAs(MASTER_TOKEN, nullOwnerFileId); expect(res.status).toBe(200); }); test("null-owner + admin utok_ → 200 (operational access preserved)", async () => { const res = await downloadAs(adminToken, nullOwnerFileId); expect(res.status).toBe(200); }); test("🔴 Test A (production default) — null-owner + normal user + DEV_OPEN off → 404 fail-closed", async () => { // Runtime invariant: this test file explicitly deletes COMMHUB_DEV_OPEN // at module setup, so the DEV_OPEN branch in authorizeFileDownload // MUST NOT execute here. If someone weakens the guard so that // null-owner is granted even without DEV_OPEN, this assertion turns // red. That IS the mutation self-check for the production carve-out. expect(process.env.COMMHUB_DEV_OPEN).toBeUndefined(); const res = await downloadAs(userAToken, nullOwnerFileId); expect(res.status).toBe(404); const body: any = await res.json(); expect(body.error).toBe("not_found"); }); });