@sleep2agi/commhub-server 0.9.0-preview.21 → 0.9.0-preview.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,135 @@
1
+ // #495 — DEV_OPEN mode null-owner allowance (Test B from PR #500 CR
2
+ // checklist). Sibling to file-download-authz.test.ts.
3
+ //
4
+ // Why a separate file: DEV_OPEN is captured at server.ts module-load
5
+ // time (`const DEV_OPEN = process.argv.includes("--dev-open") ||
6
+ // process.env.COMMHUB_DEV_OPEN === "1"`). Once the singleton is
7
+ // loaded with DEV_OPEN=false, no `process.env` change can flip it
8
+ // back inside the same Bun process. So the DEV_OPEN test lives in
9
+ // its own file that sets `COMMHUB_DEV_OPEN=1` BEFORE importing.
10
+ //
11
+ // Skip discipline: if this file runs in aggregate `bun test src/`
12
+ // AFTER file-download-authz.test.ts has already loaded server.ts
13
+ // with DEV_OPEN=false, our env change is ignored. We detect that at
14
+ // module load via a boot probe and `test.skipIf` the whole file so
15
+ // the runner reports "skipped" (NOT "passed"). Standalone run
16
+ // (`bun test src/file-download-authz-dev-open.test.ts`) always
17
+ // works — CI must include a standalone target for this file to
18
+ // guarantee it actually executes.
19
+
20
+ import { describe, expect, test, afterAll } from "bun:test";
21
+ import { mkdtempSync, rmSync, writeFileSync, existsSync } from "fs";
22
+ import { join } from "path";
23
+ import { tmpdir } from "os";
24
+
25
+ const SERVER_DB = mkdtempSync(join(tmpdir(), "anet-495-devopen-db-")) + "/commhub.db";
26
+ const UPLOADS_DIR = mkdtempSync(join(tmpdir(), "anet-495-devopen-fs-"));
27
+
28
+ // Set env BEFORE importing anything that touches db-adapter.ts or
29
+ // server.ts (both capture env at module load).
30
+ process.env.COMMHUB_DB = SERVER_DB;
31
+ process.env.COMMHUB_UPLOADS_DIR = UPLOADS_DIR;
32
+ process.env.HOST = "127.0.0.1";
33
+ process.env.COMMHUB_DEV_OPEN = "1";
34
+ delete process.env.COMMHUB_AUTH_TOKEN;
35
+
36
+ const { register } = await import("./auth.js");
37
+ const { db } = await import("./db.js");
38
+
39
+ // #500 CR2 DEV_OPEN 假门 fix — register a FIRST user (userA) so any
40
+ // "first-user-auto-admin" logic falls on userA. Then register userB
41
+ // as the non-admin download-attempt actor. Without this two-user
42
+ // split, the download test would short-circuit through the admin
43
+ // allow-list branch instead of exercising the DEV_OPEN branch — the
44
+ // mutation test (remove DEV_OPEN branch → expect RED) would silently
45
+ // still pass.
46
+ const nameA = `useraaa_devopen_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
47
+ const rA = register(nameA, "BootstrapPw123Aa!", undefined, "userA");
48
+ if (!rA.ok) throw new Error(`userA register failed: ${rA.error}`);
49
+
50
+ const nameB = `userbbb_devopen_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
51
+ const rB = register(nameB, "BootstrapPw123Aa!", undefined, "userB");
52
+ if (!rB.ok) throw new Error(`userB register failed: ${rB.error}`);
53
+ const userBToken = rB.token!;
54
+ const userBUserId = rB.user!.user_id;
55
+
56
+ // Fixture-drift guard: userB must NOT be admin. If a future auth.ts
57
+ // change grants admin to more than the first user (or the DB seeder
58
+ // promotes both), the DEV_OPEN test would silently short-circuit
59
+ // through the admin branch of authorizeFileDownload. Fail loudly.
60
+ {
61
+ const roleRow = db.get<{ role: string }>(
62
+ "SELECT role FROM users WHERE user_id = ?1",
63
+ userBUserId,
64
+ );
65
+ if (!roleRow) throw new Error("userB not found in users table after register");
66
+ if (roleRow.role === "admin") {
67
+ throw new Error(
68
+ `[#500 CR2 fixture-drift guard] userB was auto-promoted to admin ` +
69
+ `(role=${roleRow.role}) in DEV_OPEN sibling test. Refusing to run: ` +
70
+ `the DEV_OPEN branch would not actually be exercised — the download ` +
71
+ `would short-circuit through the admin allow-list and validate nothing.`,
72
+ );
73
+ }
74
+ }
75
+
76
+ const { bootServer } = await import("./server.js");
77
+ const server = bootServer({ port: 0, hostname: "127.0.0.1" });
78
+ const BASE = `http://127.0.0.1:${server.port}`;
79
+ await new Promise((r) => setTimeout(r, 100));
80
+
81
+ // Probe: is DEV_OPEN active in the loaded server module? Upload a file
82
+ // with no auth — DEV_OPEN allows anon; production would 401.
83
+ async function probeDevOpen(): Promise<boolean> {
84
+ const form = new FormData();
85
+ form.append("file", new Blob([new TextEncoder().encode("probe")], { type: "application/octet-stream" }), "probe.bin");
86
+ const res = await fetch(`${BASE}/api/upload`, { method: "POST", body: form });
87
+ return res.status === 200;
88
+ }
89
+ const devOpenActive = await probeDevOpen();
90
+
91
+ afterAll(() => {
92
+ try { server?.stop?.(true); } catch {}
93
+ try { rmSync(UPLOADS_DIR, { recursive: true, force: true }); } catch {}
94
+ try { rmSync(SERVER_DB, { recursive: true, force: true }); } catch {}
95
+ delete process.env.COMMHUB_DEV_OPEN;
96
+ });
97
+
98
+ async function uploadAnon(content: Uint8Array, filename: string): Promise<string> {
99
+ const form = new FormData();
100
+ form.append("file", new Blob([content], { type: "application/octet-stream" }), filename);
101
+ const res = await fetch(`${BASE}/api/upload`, { method: "POST", body: form });
102
+ expect(res.status).toBe(200);
103
+ const body: any = await res.json();
104
+ expect(typeof body.file_id).toBe("string");
105
+ return body.file_id;
106
+ }
107
+
108
+ async function downloadAs(token: string | null, fileId: string): Promise<Response> {
109
+ const headers: Record<string, string> = {};
110
+ if (token) headers.Authorization = `Bearer ${token}`;
111
+ return fetch(`${BASE}/api/files/${fileId}`, { headers });
112
+ }
113
+
114
+ describe.skipIf(!devOpenActive)("#495 — DEV_OPEN null-owner allowance (Test B)", () => {
115
+ test("null-owner uploaded via anonymous DEV_OPEN request → non-admin userB can download (200) via DEV_OPEN branch", async () => {
116
+ const devOpenFileId = await uploadAnon(new TextEncoder().encode("dev-open-blob"), "dev.bin");
117
+
118
+ // Verify the upload really produced null-owner (matches the shape
119
+ // production callers might see if DEV_OPEN were misconfigured).
120
+ const { readFileSync } = await import("fs");
121
+ const entryPath = join(UPLOADS_DIR, ".index", `${devOpenFileId}.json`);
122
+ if (existsSync(entryPath)) {
123
+ const entry = JSON.parse(readFileSync(entryPath, "utf-8"));
124
+ expect(entry.owner_id).toBeNull();
125
+ }
126
+
127
+ // userB is non-admin (guarded at module load). If DEV_OPEN branch
128
+ // is removed from authorizeFileDownload, this returns 404 instead
129
+ // of 200 — the assertion below turns red, catching the regression
130
+ // structurally rather than via admin-branch short-circuit.
131
+ const res = await downloadAs(userBToken, devOpenFileId);
132
+ expect(res.status).toBe(200);
133
+ expect(await res.text()).toBe("dev-open-blob");
134
+ });
135
+ });
@@ -0,0 +1,349 @@
1
+ // #495 — file download authorization (P0 staged hotfix, PR #500).
2
+ //
3
+ // Before this fix, /api/files/:file_id only checked authentication.
4
+ // Any authenticated principal could download any file cross-network by
5
+ // guessing / obtaining a file_id.
6
+ //
7
+ // This suite spawns a real Bun.serve hub in-process and validates the
8
+ // allow-list (owner + admin + legacy-master + null-owner-DEV_OPEN).
9
+ // Same-network non-owner is EXPLICITLY out of scope in this stage —
10
+ // see PR body access matrix + follow-up #503. That "same-network peer
11
+ // returns 404" is an intentional carve-out here, not a bug.
12
+ //
13
+ // The DEV_OPEN branch is verified in a sibling test file
14
+ // (file-download-authz-dev-open.test.ts) because DEV_OPEN is captured
15
+ // at server.ts module-load time and can't be flipped mid-suite.
16
+ //
17
+ // Skip discipline: master-token tests use `test.skipIf(!masterTokenActive)`
18
+ // so aggregate `bun test src/` reports "skipped" (not "passed") when
19
+ // the loaded server module's frozen AUTH_TOKEN binding predates our
20
+ // env setup. `if (!x) return` was previously used and Bun records
21
+ // that as PASS — which is exactly the "skip-as-pass" trap that let
22
+ // an earlier round claim coverage it never had. (See project memory
23
+ // `feedback_skip_via_early_return_shows_as_pass`.)
24
+
25
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
26
+ import { mkdtempSync, rmSync, writeFileSync, existsSync } from "fs";
27
+ import { join } from "path";
28
+ import { tmpdir } from "os";
29
+
30
+ const SERVER_DB = mkdtempSync(join(tmpdir(), "anet-495-db-")) + "/commhub.db";
31
+ const UPLOADS_DIR = mkdtempSync(join(tmpdir(), "anet-495-fs-"));
32
+ const MASTER_TOKEN = process.env.COMMHUB_AUTH_TOKEN ?? `master-495-${Date.now()}-${Math.floor(Math.random() * 100000)}`;
33
+
34
+ // Set env BEFORE importing anything that pulls in db-adapter.ts (which
35
+ // refuses to open a default DB under NODE_ENV=test) or server.ts (which
36
+ // captures DEV_OPEN + AUTH_TOKEN at module load).
37
+ process.env.COMMHUB_DB = SERVER_DB;
38
+ process.env.COMMHUB_UPLOADS_DIR = UPLOADS_DIR;
39
+ process.env.HOST = "127.0.0.1";
40
+ process.env.COMMHUB_AUTH_TOKEN = MASTER_TOKEN;
41
+ delete process.env.COMMHUB_DEV_OPEN;
42
+
43
+ const { register, addNetworkMember, getUserAllNetworks } = await import("./auth.js");
44
+ const { db } = await import("./db.js");
45
+
46
+ // Seed accounts. register()'s "first-user-is-admin" is not reliable in
47
+ // aggregate (earlier test files may seed users), so we promote via SQL.
48
+ const adminName = `admin_495_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
49
+ const rAdmin = register(adminName, "BootstrapPw123Aa!", undefined, "seed");
50
+ if (!rAdmin.ok) throw new Error(`admin register failed: ${rAdmin.error}`);
51
+ const adminToken = rAdmin.token!;
52
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [rAdmin.user!.user_id]);
53
+
54
+ const nameA = `useraaa_495_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
55
+ const rA = register(nameA, "BootstrapPw123Aa!", undefined, "userA");
56
+ if (!rA.ok) throw new Error(`userA register failed: ${rA.error}`);
57
+ const userAToken = rA.token!;
58
+ const userAUserId = rA.user!.user_id;
59
+
60
+ const nameB = `userbbb_495_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
61
+ const rB = register(nameB, "BootstrapPw123Aa!", undefined, "userB");
62
+ if (!rB.ok) throw new Error(`userB register failed: ${rB.error}`);
63
+ const userBToken = rB.token!;
64
+ const userBUserId = rB.user!.user_id;
65
+ const userBNetworkToken = rB.network_token!;
66
+ const userBNetworkId = getUserAllNetworks(userBUserId)[0]?.network_id!;
67
+ if (!userBNetworkId) throw new Error("userB default network not found");
68
+
69
+ // #500 CR2 DEV_OPEN 假门 fix — assert userB is NOT admin at fixture
70
+ // setup time. If fixture ordering ever changes (e.g. auth.ts flips
71
+ // "first user auto-admin" logic, or an earlier test file promotes
72
+ // userB), the assertion catches it before Test A/B can silently
73
+ // short-circuit through the admin branch of authorizeFileDownload
74
+ // and hide a broken DEV_OPEN gate. Independent审员 caught this
75
+ // class of假门 in the prior CR round — the assertion writes the
76
+ // "userB is non-admin" invariant into the test itself so a future
77
+ // fixture drift cannot re-open the hole.
78
+ {
79
+ const userBRoleRow = db.get<{ role: string }>(
80
+ "SELECT role FROM users WHERE user_id = ?1",
81
+ userBUserId,
82
+ );
83
+ if (!userBRoleRow) throw new Error("userB not found in users table after register");
84
+ if (userBRoleRow.role === "admin") {
85
+ throw new Error(
86
+ `[#500 CR2 fixture-drift guard] userB was auto-promoted to admin ` +
87
+ `(role=${userBRoleRow.role}). Refusing to run: the DEV_OPEN and ` +
88
+ `cross-owner tests would short-circuit through the admin allow-list ` +
89
+ `branch and validate nothing.`,
90
+ );
91
+ }
92
+ }
93
+
94
+ // Add userA as a member of userB's default network. This is what makes
95
+ // the "same-network non-owner" test cell meaningful — without it, that
96
+ // test would only prove cross-network denial, which is a weaker claim.
97
+ const addMember = addNetworkMember(userBNetworkId, userAUserId, "member", userBUserId);
98
+ if (!addMember.ok) throw new Error(`add userA to userB network failed: ${addMember.error}`);
99
+
100
+ const { bootServer } = await import("./server.js");
101
+ const server = bootServer({ port: 0, hostname: "127.0.0.1" });
102
+ const BASE = `http://127.0.0.1:${server.port}`;
103
+ await new Promise((r) => setTimeout(r, 100));
104
+
105
+ // Probe the loaded server's frozen AUTH_TOKEN binding. If master token
106
+ // works we can exercise the legacy branch; if not (aggregate load-order
107
+ // effect), `test.skipIf` reports "skipped" (NOT "passed").
108
+ const _probeMaster = await fetch(`${BASE}/api/files/00000000000000000000000000000000`, {
109
+ headers: { Authorization: `Bearer ${MASTER_TOKEN}` },
110
+ });
111
+ const masterTokenActive = _probeMaster.status === 404;
112
+
113
+ // Probe the loaded server's frozen DEV_OPEN binding. This file is
114
+ // meant to run against a DEV_OPEN=false server. In aggregate mode,
115
+ // if file-download-authz-dev-open.test.ts loaded server.ts first
116
+ // with DEV_OPEN=true, the singleton wins — we can't flip it. Skip
117
+ // the whole production-mode suite via `describe.skipIf` (NOT
118
+ // early-return in each test body) so the runner reports "skipped".
119
+ async function _uploadProbe(): Promise<number> {
120
+ const form = new FormData();
121
+ form.append("file", new Blob([new TextEncoder().encode("p")], { type: "application/octet-stream" }), "p");
122
+ const r = await fetch(`${BASE}/api/upload`, { method: "POST", body: form });
123
+ return r.status;
124
+ }
125
+ const devOpenSuspected = (await _uploadProbe()) === 200;
126
+ const isProdMode = !devOpenSuspected;
127
+
128
+ afterAll(() => {
129
+ try { server?.stop?.(true); } catch {}
130
+ try { rmSync(UPLOADS_DIR, { recursive: true, force: true }); } catch {}
131
+ try { rmSync(SERVER_DB, { recursive: true, force: true }); } catch {}
132
+ delete process.env.COMMHUB_AUTH_TOKEN;
133
+ });
134
+
135
+ async function uploadAs(token: string, content: Uint8Array, filename: string): Promise<string> {
136
+ const form = new FormData();
137
+ form.append("file", new Blob([content], { type: "application/octet-stream" }), filename);
138
+ const res = await fetch(`${BASE}/api/upload`, {
139
+ method: "POST",
140
+ body: form,
141
+ headers: { Authorization: `Bearer ${token}` },
142
+ });
143
+ expect(res.status).toBe(200);
144
+ const body: any = await res.json();
145
+ expect(typeof body.file_id).toBe("string");
146
+ return body.file_id;
147
+ }
148
+
149
+ async function downloadAs(token: string | null, fileId: string): Promise<Response> {
150
+ const headers: Record<string, string> = {};
151
+ if (token) headers.Authorization = `Bearer ${token}`;
152
+ return fetch(`${BASE}/api/files/${fileId}`, { headers });
153
+ }
154
+
155
+ describe.skipIf(!isProdMode)("#495 — GET /api/files/:file_id authorization (owner-only staged)", () => {
156
+ let userBFileId = "";
157
+
158
+ beforeAll(async () => {
159
+ userBFileId = await uploadAs(userBToken, new TextEncoder().encode("secret-from-B"), "b-secret.txt");
160
+ expect(userBFileId.length).toBe(32);
161
+ });
162
+
163
+ test("owner (userB) via utok_ downloads own file → 200 (normal path)", async () => {
164
+ const res = await downloadAs(userBToken, userBFileId);
165
+ expect(res.status).toBe(200);
166
+ expect(await res.text()).toBe("secret-from-B");
167
+ });
168
+
169
+ test("owner (userB) via ntok_ downloads own file → 200 (agent self-upload+self-download)", async () => {
170
+ const agentFileId = await uploadAs(userBNetworkToken, new TextEncoder().encode("agent-blob"), "agent.bin");
171
+ const res = await downloadAs(userBNetworkToken, agentFileId);
172
+ expect(res.status).toBe(200);
173
+ expect(await res.text()).toBe("agent-blob");
174
+ });
175
+
176
+ test("🔴 non-owner (userA) downloads userB's file → 404 (was 200 pre-#495)", async () => {
177
+ const res = await downloadAs(userAToken, userBFileId);
178
+ expect(res.status).toBe(404);
179
+ const body: any = await res.json();
180
+ expect(body.error).toBe("not_found");
181
+ });
182
+
183
+ test("🔴 same-network non-owner (userA is a member of userB's network) → 404 (STAGED carve-out; follow-up #503)", async () => {
184
+ // Precondition — userA really is a member of userB's default
185
+ // network (added in module setup). Guard the invariant so the
186
+ // 404 result actually represents "same-network peer denied",
187
+ // not "cross-network denied".
188
+ const rows = db.all<{ network_id: string }>(
189
+ "SELECT network_id FROM network_members WHERE user_id = ?1 AND network_id = ?2",
190
+ userAUserId, userBNetworkId,
191
+ );
192
+ expect(rows.length).toBe(1);
193
+ const res = await downloadAs(userAToken, userBFileId);
194
+ expect(res.status).toBe(404);
195
+ const body: any = await res.json();
196
+ expect(body.error).toBe("not_found");
197
+ });
198
+
199
+ test("admin caller downloads any file → 200 (operational access preserved)", async () => {
200
+ const res = await downloadAs(adminToken, userBFileId);
201
+ expect(res.status).toBe(200);
202
+ expect(await res.text()).toBe("secret-from-B");
203
+ });
204
+
205
+ test.skipIf(!masterTokenActive)("legacy master AUTH_TOKEN downloads any file → 200 (single-tenant deployments)", async () => {
206
+ const res = await downloadAs(MASTER_TOKEN, userBFileId);
207
+ expect(res.status).toBe(200);
208
+ expect(await res.text()).toBe("secret-from-B");
209
+ });
210
+
211
+ test("anonymous request → 401 (auth still required)", async () => {
212
+ const res = await downloadAs(null, userBFileId);
213
+ expect(res.status).toBe(401);
214
+ });
215
+
216
+ test("non-owner request for an UNKNOWN file_id → 404 with same shape as owner-denied (no enumeration signal)", async () => {
217
+ const res = await downloadAs(userAToken, "0123456789abcdef0123456789abcdef");
218
+ expect(res.status).toBe(404);
219
+ const body: any = await res.json();
220
+ expect(body.error).toBe("not_found");
221
+ });
222
+ });
223
+
224
+ // #500 CR2 ① — HEAD verb explicit authz coverage. Pre-CR2, HEAD fell
225
+ // through to a fallback 200 page; body was byte-identical to unknown
226
+ // file_id so no oracle formed by accident, but it was coincidence, not
227
+ // a gate. This suite asserts HEAD routes through authorizeFileDownload
228
+ // per RFC 9110 §9.3.2: same status/headers as GET, body omitted.
229
+ async function headAs(token: string | null, fileId: string): Promise<Response> {
230
+ const headers: Record<string, string> = {};
231
+ if (token) headers.Authorization = `Bearer ${token}`;
232
+ return fetch(`${BASE}/api/files/${fileId}`, { method: "HEAD", headers });
233
+ }
234
+
235
+ describe.skipIf(!isProdMode)("#500 CR2 — HEAD /api/files/:file_id authorization (parity with GET)", () => {
236
+ let userBFileId = "";
237
+
238
+ beforeAll(async () => {
239
+ userBFileId = await uploadAs(userBToken, new TextEncoder().encode("head-scenario-payload"), "b-head.txt");
240
+ });
241
+
242
+ test("owner (userB) HEAD own file → 200 + content-length header (body omitted per HEAD)", async () => {
243
+ const res = await headAs(userBToken, userBFileId);
244
+ expect(res.status).toBe(200);
245
+ expect(res.headers.get("content-length")).toBe(String("head-scenario-payload".length));
246
+ expect(res.headers.get("content-disposition")).toContain("attachment");
247
+ expect(res.headers.get("x-content-type-options")).toBe("nosniff");
248
+ });
249
+
250
+ test("🔴 cross-owner HEAD (userA HEAD userB's file) → 404 via helper (was fallback 200 pre-CR2)", async () => {
251
+ const res = await headAs(userAToken, userBFileId);
252
+ expect(res.status).toBe(404);
253
+ // 404 must originate from authorizeFileDownload, not the fallback
254
+ // page. Body is application/json ({"ok":false,"error":"not_found"})
255
+ // — check Content-Type so a future fallback rewrite (e.g. adding
256
+ // "requested resource type: X") does not silently form an oracle
257
+ // while HEAD still returns 200 from that page.
258
+ expect(res.headers.get("content-type")?.toLowerCase()).toContain("application/json");
259
+ });
260
+
261
+ test("HEAD unknown file_id → 404 with same shape as cross-owner (no enumeration signal)", async () => {
262
+ const res = await headAs(userAToken, "0123456789abcdef0123456789abcdef");
263
+ expect(res.status).toBe(404);
264
+ expect(res.headers.get("content-type")?.toLowerCase()).toContain("application/json");
265
+ });
266
+
267
+ test("HEAD anonymous → 401 (auth still required, symmetric with GET)", async () => {
268
+ const res = await headAs(null, userBFileId);
269
+ expect(res.status).toBe(401);
270
+ });
271
+
272
+ test("admin HEAD any file → 200 (operational access preserved on HEAD)", async () => {
273
+ const res = await headAs(adminToken, userBFileId);
274
+ expect(res.status).toBe(200);
275
+ expect(res.headers.get("content-length")).toBe(String("head-scenario-payload".length));
276
+ });
277
+ });
278
+
279
+ // #500 CR2 ④ — Upload owner persistence. Prove that new authenticated
280
+ // uploads always write a truthy owner_id to the on-disk index entry.
281
+ // If the upload handler ever regresses to writing owner_id=null in
282
+ // requireAuth-guarded paths, D1 turns red — the invariant that
283
+ // "production uploads never produce null-owner blobs" is documented
284
+ // in the authorizeFileDownload comment and this test enforces it.
285
+ describe.skipIf(!isProdMode)("#500 CR2 — upload persists truthy owner_id (D1)", () => {
286
+ test("D1: userA authenticated upload → index entry owner_id === userA.userId (never null)", async () => {
287
+ const fileId = await uploadAs(userAToken, new TextEncoder().encode("owner-persistence-check"), "d1.bin");
288
+ const { readFileSync } = await import("fs");
289
+ const entryPath = join(UPLOADS_DIR, ".index", `${fileId}.json`);
290
+ expect(existsSync(entryPath)).toBe(true);
291
+ const entry = JSON.parse(readFileSync(entryPath, "utf-8"));
292
+ expect(entry.owner_id).not.toBeNull();
293
+ expect(entry.owner_id).toBe(userAUserId);
294
+ expect(entry.owner).toBe(nameA); // owner username stays consistent with the token that uploaded
295
+ });
296
+
297
+ test("D1b: userB authenticated upload via utok_ → owner_id === userB.userId", async () => {
298
+ const fileId = await uploadAs(userBToken, new TextEncoder().encode("owner-persistence-check-b"), "d1b.bin");
299
+ const { readFileSync } = await import("fs");
300
+ const entryPath = join(UPLOADS_DIR, ".index", `${fileId}.json`);
301
+ expect(existsSync(entryPath)).toBe(true);
302
+ const entry = JSON.parse(readFileSync(entryPath, "utf-8"));
303
+ expect(entry.owner_id).not.toBeNull();
304
+ expect(entry.owner_id).toBe(userBUserId);
305
+ });
306
+ });
307
+
308
+ describe.skipIf(!isProdMode)("#495 — null-owner policy in production (DEV_OPEN=off, non-admin/non-legacy)", () => {
309
+ let nullOwnerFileId = "";
310
+
311
+ beforeAll(async () => {
312
+ // Simulate a legacy null-owner index entry by uploading normally
313
+ // then rewriting the on-disk index to owner_id=null. This precisely
314
+ // reproduces the shape a DEV_OPEN or legacy master upload would
315
+ // create, without needing to spin up a second server in a
316
+ // different mode.
317
+ nullOwnerFileId = await uploadAs(userAToken, new TextEncoder().encode("legacy-blob"), "legacy.txt");
318
+ const { readFileSync: rfs, writeFileSync: wfs } = await import("fs");
319
+ const entryFile = join(UPLOADS_DIR, ".index", `${nullOwnerFileId}.json`);
320
+ if (!existsSync(entryFile)) throw new Error(`index entry not found: ${entryFile}`);
321
+ const entry = JSON.parse(rfs(entryFile, "utf-8"));
322
+ entry.owner = null;
323
+ entry.owner_id = null;
324
+ wfs(entryFile, JSON.stringify(entry, null, 2));
325
+ });
326
+
327
+ test.skipIf(!masterTokenActive)("null-owner + legacy master → 200 (RFC-001 read-only backward-compat)", async () => {
328
+ const res = await downloadAs(MASTER_TOKEN, nullOwnerFileId);
329
+ expect(res.status).toBe(200);
330
+ });
331
+
332
+ test("null-owner + admin utok_ → 200 (operational access preserved)", async () => {
333
+ const res = await downloadAs(adminToken, nullOwnerFileId);
334
+ expect(res.status).toBe(200);
335
+ });
336
+
337
+ test("🔴 Test A (production default) — null-owner + normal user + DEV_OPEN off → 404 fail-closed", async () => {
338
+ // Runtime invariant: this test file explicitly deletes COMMHUB_DEV_OPEN
339
+ // at module setup, so the DEV_OPEN branch in authorizeFileDownload
340
+ // MUST NOT execute here. If someone weakens the guard so that
341
+ // null-owner is granted even without DEV_OPEN, this assertion turns
342
+ // red. That IS the mutation self-check for the production carve-out.
343
+ expect(process.env.COMMHUB_DEV_OPEN).toBeUndefined();
344
+ const res = await downloadAs(userAToken, nullOwnerFileId);
345
+ expect(res.status).toBe(404);
346
+ const body: any = await res.json();
347
+ expect(body.error).toBe("not_found");
348
+ });
349
+ });
@@ -0,0 +1,176 @@
1
+ // #473 — /health information-leak redaction.
2
+ //
3
+ // Public-hub audit (通信龙, 2026-07-30) showed anonymous GET /health
4
+ // returning the full SSE key breakdown: `{networkId}:{alias}` for every
5
+ // live connection — network id + all 95 agent aliases on the audited
6
+ // hub. The fix keeps /health anonymous (HARD CONSTRAINT: the central
7
+ // watchdog curls it every minute; auth here would blind it) but strips
8
+ // the per-key detail down to aggregate counts; the detail moved behind
9
+ // auth at GET /api/stats/sse (master token or admin only).
10
+ //
11
+ // Witnessed-red: on pre-fix main (8c11edf5) the "no leak" test below is
12
+ // red — the /health body contains the connected alias and network id.
13
+
14
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
15
+ import { mkdtempSync, rmSync } from "fs";
16
+ import { join } from "path";
17
+ import { tmpdir } from "os";
18
+ import { register } from "./auth.js";
19
+ import { db } from "./db.js";
20
+
21
+ const SERVER_DB = mkdtempSync(join(tmpdir(), "anet-health-red-db-")) + "/commhub.db";
22
+
23
+ let BASE = "";
24
+ let server: any = null;
25
+ let sseReader: ReadableStreamDefaultReader<Uint8Array> | null = null;
26
+
27
+ let userToken = "";
28
+ let userNetworkId = "";
29
+ let userName = "";
30
+ let adminToken = "";
31
+
32
+ beforeAll(async () => {
33
+ process.env.COMMHUB_DB = process.env.COMMHUB_DB || SERVER_DB;
34
+ process.env.HOST = "127.0.0.1";
35
+
36
+ const suffix = `${Date.now()}_${Math.floor(Math.random() * 1000)}`;
37
+ const pw = "BootstrapPw123Aa!";
38
+
39
+ // Burn a seed registration first so our member is NOT auto-admin (the
40
+ // "first registered user → admin" rule only bites when this suite owns
41
+ // the DB; per-file runs need this guard). We set roles explicitly below.
42
+ register(`health_seed_${suffix}`, pw, undefined, "seed");
43
+
44
+ userName = `health_user_${suffix}`;
45
+ const r = register(userName, pw, undefined, "seed");
46
+ if (!r.ok || !r.token) throw new Error("register failed: " + JSON.stringify(r));
47
+ userToken = r.token;
48
+ userNetworkId = r.network_id ?? "";
49
+ if (!userNetworkId) throw new Error("no network for user");
50
+ // Pin to plain member — defend against any residual auto-admin.
51
+ db.run("UPDATE users SET role = 'user' WHERE username = ?1", [userName]);
52
+
53
+ const adminName = `health_admin_${suffix}`;
54
+ const a = register(adminName, pw, undefined, "seed");
55
+ if (!a.ok || !a.token) throw new Error("admin register failed: " + JSON.stringify(a));
56
+ adminToken = a.token;
57
+ // Explicit promotion — "first registered user is auto-admin" does not
58
+ // hold in aggregate runs (see api-host-supervisors-fallback.test.ts).
59
+ db.run("UPDATE users SET role = 'admin' WHERE username = ?1", [adminName]);
60
+
61
+ const { bootServer } = await import("./server.js");
62
+ server = bootServer({ port: 0, hostname: "127.0.0.1" });
63
+ BASE = `http://127.0.0.1:${server.port}`;
64
+
65
+ // Open a real SSE connection so the stats have a key that WOULD leak:
66
+ // the dashboard-user channel (sessionName === own username, Path 3
67
+ // gate 4a) registers `${networkId}:${username}` in the clients map.
68
+ const res = await fetch(`${BASE}/events/${encodeURIComponent(userName)}?network_id=${userNetworkId}`, {
69
+ headers: { Authorization: `Bearer ${userToken}` },
70
+ });
71
+ if (res.status !== 200) throw new Error("SSE subscribe failed: " + res.status);
72
+ sseReader = res.body!.getReader();
73
+ await sseReader.read(); // consume the connected frame → registration done
74
+ });
75
+
76
+ afterAll(() => {
77
+ try { sseReader?.cancel(); } catch {}
78
+ try { server?.stop?.(true); } catch {}
79
+ try { rmSync(SERVER_DB, { recursive: true, force: true }); } catch {}
80
+ });
81
+
82
+ describe("#473 anonymous /health — watchdog contract intact, no topology leak", () => {
83
+ test("stays anonymous 200 with parseable aggregates (watchdog judge: curl -sf gets 2xx)", async () => {
84
+ const res = await fetch(`${BASE}/health`);
85
+ expect(res.status).toBe(200);
86
+ const body = await res.json() as any;
87
+ expect(body.ok).toBe(true);
88
+ expect(typeof body.version).toBe("string");
89
+ expect(typeof body.sessions_count).toBe("number");
90
+ expect(typeof body.sse_connections).toBe("number");
91
+ expect(body.sse_connections).toBeGreaterThanOrEqual(1);
92
+ });
93
+
94
+ test("CLI count source stays truthful: health.sse_connections == /api/stats/sse total (no fake 0)", async () => {
95
+ // The CLI's `anet doctor` line reads health.sse_connections now
96
+ // (was Object.keys(sse_sessions).length). This pins that aggregate to
97
+ // the same number the auth-gated detail endpoint reports — so a live
98
+ // hub with N connections shows N, never the "0 active" regression the
99
+ // review caught. There is exactly one live SSE connection in setup.
100
+ const health = await (await fetch(`${BASE}/health`)).json() as any;
101
+ const detail = await (await fetch(`${BASE}/api/stats/sse`, { headers: { Authorization: `Bearer ${adminToken}` } })).json() as any;
102
+ expect(health.sse_connections).toBe(detail.total);
103
+ expect(health.sse_connections).toBeGreaterThanOrEqual(1);
104
+ });
105
+
106
+ test("does NOT expose SSE key detail: no sse_sessions field, no alias, no network id", async () => {
107
+ const res = await fetch(`${BASE}/health`);
108
+ const text = await res.text();
109
+ const body = JSON.parse(text);
110
+ expect("sse_sessions" in body).toBe(false);
111
+ // The live connection's identifying strings must not appear anywhere
112
+ // in the anonymous body.
113
+ expect(text).not.toContain(userName);
114
+ expect(text).not.toContain(userNetworkId);
115
+ });
116
+ });
117
+
118
+ describe("#473 GET /api/stats/sse — detail survives, behind ops auth", () => {
119
+ test("anonymous → 401", async () => {
120
+ const res = await fetch(`${BASE}/api/stats/sse`);
121
+ expect(res.status).toBe(401);
122
+ });
123
+
124
+ test("regular member token → 403 (detail spans every network)", async () => {
125
+ const res = await fetch(`${BASE}/api/stats/sse`, { headers: { Authorization: `Bearer ${userToken}` } });
126
+ expect(res.status).toBe(403);
127
+ });
128
+
129
+ test("admin → 200 with the full key breakdown (ops capability preserved)", async () => {
130
+ const res = await fetch(`${BASE}/api/stats/sse`, { headers: { Authorization: `Bearer ${adminToken}` } });
131
+ expect(res.status).toBe(200);
132
+ const body = await res.json() as any;
133
+ expect(body.ok).toBe(true);
134
+ expect(typeof body.total).toBe("number");
135
+ expect(body.sessions[`${userNetworkId}:${userName}`]).toBeGreaterThanOrEqual(1);
136
+ });
137
+
138
+ test("legacy master token → 200 (ops path — restAuth null, isLegacyAuthToken)", () => {
139
+ // COMMHUB_AUTH_TOKEN is captured at module import, so this path needs
140
+ // a fresh process with the env set before boot (aggregate order can't
141
+ // guarantee this file imports server.ts first). Subprocess, like the
142
+ // patrol test. Reviewer flagged this path had code but no test.
143
+ const script = `
144
+ process.env.COMMHUB_AUTH_TOKEN = "master-secret-xyz";
145
+ const { startHub } = await import("./src/server.js");
146
+ const hub = startHub({ port: 0, hostname: "127.0.0.1" });
147
+ const base = "http://127.0.0.1:" + hub.port;
148
+ const anon = await fetch(base + "/api/stats/sse");
149
+ const master = await fetch(base + "/api/stats/sse", { headers: { Authorization: "Bearer master-secret-xyz" } });
150
+ const health = await fetch(base + "/health");
151
+ const hb = await health.json();
152
+ console.log("RESULT:" + JSON.stringify({
153
+ anon: anon.status,
154
+ master: master.status,
155
+ masterOk: (await master.json()).ok,
156
+ healthHasSseSessions: "sse_sessions" in hb,
157
+ }));
158
+ hub.stop(true);
159
+ process.exit(0);
160
+ `;
161
+ const child = Bun.spawnSync({
162
+ cmd: ["bun", "-e", script],
163
+ cwd: `${import.meta.dir}/..`,
164
+ env: { ...process.env, COMMHUB_DB: process.env.COMMHUB_DB || "/tmp/anet-473-master.db" },
165
+ timeout: 15_000,
166
+ });
167
+ const out = new TextDecoder().decode(child.stdout);
168
+ const m = out.match(/RESULT:(\{.*\})/);
169
+ expect(m).not.toBeNull();
170
+ const r = JSON.parse(m![1]);
171
+ expect(r.anon).toBe(401); // token configured → anonymous rejected
172
+ expect(r.master).toBe(200); // legacy master token → ops access
173
+ expect(r.masterOk).toBe(true);
174
+ expect(r.healthHasSseSessions).toBe(false); // /health still redacted with a token set
175
+ }, 20_000);
176
+ });