@sleep2agi/commhub-server 0.9.0-preview.22 → 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.
- package/package.json +1 -1
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/db.ts +32 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +349 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +213 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/server.ts +199 -10
- package/src/tools.ts +18 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
3
|
+
"version": "0.9.0-preview.23",
|
|
4
4
|
"description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and 17 MCP tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { db } from "./db.js";
|
|
3
|
+
import { parseStoredTags } from "./node-attrs-validate.js";
|
|
3
4
|
|
|
4
5
|
// Regression test for SELECT * fragility on GET /api/nodes.
|
|
5
6
|
//
|
|
@@ -41,7 +42,12 @@ function mapRow(r: Record<string, unknown>): Record<string, unknown> {
|
|
|
41
42
|
catch { /* malformed */ }
|
|
42
43
|
}
|
|
43
44
|
const { config_snapshot, ...rest } = r;
|
|
44
|
-
return {
|
|
45
|
+
return {
|
|
46
|
+
...rest,
|
|
47
|
+
tags: parseStoredTags(r.tags),
|
|
48
|
+
attrs_revision: Number(r.attrs_revision ?? 0),
|
|
49
|
+
role,
|
|
50
|
+
};
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", () => {
|
|
@@ -66,7 +72,7 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
|
|
|
66
72
|
`SELECT node_id, node_name, alias, runtime, model,
|
|
67
73
|
config_path, channels, server, hostname,
|
|
68
74
|
network_id, created_at, updated_at,
|
|
69
|
-
config_snapshot
|
|
75
|
+
config_snapshot, display_name, team, tags, attrs_revision
|
|
70
76
|
FROM nodes WHERE node_id = ?1`,
|
|
71
77
|
TEST_NODE,
|
|
72
78
|
);
|
|
@@ -85,11 +91,17 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
|
|
|
85
91
|
expect(row).not.toHaveProperty("config_snapshot");
|
|
86
92
|
// role is EXTRACTED from config_snapshot JSON, not the raw blob
|
|
87
93
|
expect(row.role).toBe("host_supervisor");
|
|
88
|
-
// Belt:
|
|
94
|
+
// Belt: the key set is exactly the dashboard-facing contract.
|
|
95
|
+
// NOTE: this file MIRRORS the handler's SQL rather than calling it, so
|
|
96
|
+
// it can drift — it already lagged behind `lifecycle_state` /
|
|
97
|
+
// `avatar_url`, which the handler selects but this mirror does not.
|
|
98
|
+
// Treat a diff here as "re-read the handler", not as proof of the wire
|
|
99
|
+
// shape; node-attrs.test.ts asserts the real HTTP response.
|
|
89
100
|
expect(Object.keys(row).sort()).toEqual([
|
|
90
|
-
"alias", "
|
|
91
|
-
"
|
|
92
|
-
"server", "
|
|
101
|
+
"alias", "attrs_revision", "channels", "config_path", "created_at",
|
|
102
|
+
"display_name", "hostname", "model", "network_id", "node_id",
|
|
103
|
+
"node_name", "role", "runtime", "server", "tags", "team",
|
|
104
|
+
"updated_at",
|
|
93
105
|
]);
|
|
94
106
|
});
|
|
95
107
|
|
|
@@ -102,7 +114,8 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
|
|
|
102
114
|
);
|
|
103
115
|
let row = mapRow(db.get<Record<string, unknown>>(
|
|
104
116
|
`SELECT node_id, node_name, alias, runtime, model, config_path, channels,
|
|
105
|
-
server, hostname, network_id, created_at, updated_at, config_snapshot
|
|
117
|
+
server, hostname, network_id, created_at, updated_at, config_snapshot,
|
|
118
|
+
display_name, team, tags, attrs_revision
|
|
106
119
|
FROM nodes WHERE node_id = ?1`,
|
|
107
120
|
TEST_NODE,
|
|
108
121
|
)!);
|
|
@@ -113,7 +126,8 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
|
|
|
113
126
|
db.run(`UPDATE nodes SET config_snapshot = ?1 WHERE node_id = ?2`, ["not-json{", TEST_NODE]);
|
|
114
127
|
row = mapRow(db.get<Record<string, unknown>>(
|
|
115
128
|
`SELECT node_id, node_name, alias, runtime, model, config_path, channels,
|
|
116
|
-
server, hostname, network_id, created_at, updated_at, config_snapshot
|
|
129
|
+
server, hostname, network_id, created_at, updated_at, config_snapshot,
|
|
130
|
+
display_name, team, tags, attrs_revision
|
|
117
131
|
FROM nodes WHERE node_id = ?1`,
|
|
118
132
|
TEST_NODE,
|
|
119
133
|
)!);
|
|
@@ -124,7 +138,8 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
|
|
|
124
138
|
[JSON.stringify({ model: "x", flags: {} }), TEST_NODE]);
|
|
125
139
|
row = mapRow(db.get<Record<string, unknown>>(
|
|
126
140
|
`SELECT node_id, node_name, alias, runtime, model, config_path, channels,
|
|
127
|
-
server, hostname, network_id, created_at, updated_at, config_snapshot
|
|
141
|
+
server, hostname, network_id, created_at, updated_at, config_snapshot,
|
|
142
|
+
display_name, team, tags, attrs_revision
|
|
128
143
|
FROM nodes WHERE node_id = ?1`,
|
|
129
144
|
TEST_NODE,
|
|
130
145
|
)!);
|
|
@@ -141,7 +156,8 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
|
|
|
141
156
|
);
|
|
142
157
|
const rows = db.all<Record<string, unknown>>(
|
|
143
158
|
`SELECT node_id, node_name, alias, runtime, model, config_path, channels,
|
|
144
|
-
server, hostname, network_id, created_at, updated_at, config_snapshot
|
|
159
|
+
server, hostname, network_id, created_at, updated_at, config_snapshot,
|
|
160
|
+
display_name, team, tags, attrs_revision
|
|
145
161
|
FROM nodes WHERE network_id = ?1`,
|
|
146
162
|
TEST_NETWORK,
|
|
147
163
|
).map(mapRow);
|
package/src/db.ts
CHANGED
|
@@ -348,6 +348,38 @@ try {
|
|
|
348
348
|
if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
|
|
349
349
|
}
|
|
350
350
|
|
|
351
|
+
// Node DISPLAY attributes (Vincent 目标第 3 条 — dashboard/app 多维度编辑).
|
|
352
|
+
// Same posture as avatar_url above: hub-side presentation only, NOT part of
|
|
353
|
+
// the RFC-024 config-apply pipeline (agent-node consumes none of them, so a
|
|
354
|
+
// doorbell/ack round-trip would be meaningless and could wedge the config
|
|
355
|
+
// revision). Written only via PUT /api/nodes/:ref/attrs.
|
|
356
|
+
//
|
|
357
|
+
// display_name — human label shown in the UI. Distinct from `alias`,
|
|
358
|
+
// which is the ROUTING key (messages address the alias)
|
|
359
|
+
// and from `node_name`, which is an addressing key
|
|
360
|
+
// (`WHERE node_id OR node_name OR alias`). Renaming
|
|
361
|
+
// either of those must go through the rename 2PC
|
|
362
|
+
// (/api/node-rename/*), never through an attrs write.
|
|
363
|
+
// team, tags — organisation metadata; tags stored as JSON array text.
|
|
364
|
+
// attrs_revision — optimistic-lock counter for these display fields ONLY.
|
|
365
|
+
// Deliberately separate from config_revision so that
|
|
366
|
+
// editing a tag never bumps the node's *config* revision
|
|
367
|
+
// (which would perturb the config ack protocol).
|
|
368
|
+
//
|
|
369
|
+
// All nullable / defaulted so the ~100 rows already in flight keep reading.
|
|
370
|
+
for (const ddl of [
|
|
371
|
+
`ALTER TABLE nodes ADD COLUMN display_name TEXT`,
|
|
372
|
+
`ALTER TABLE nodes ADD COLUMN team TEXT`,
|
|
373
|
+
`ALTER TABLE nodes ADD COLUMN tags TEXT`,
|
|
374
|
+
`ALTER TABLE nodes ADD COLUMN attrs_revision INTEGER NOT NULL DEFAULT 0`,
|
|
375
|
+
]) {
|
|
376
|
+
try {
|
|
377
|
+
db.exec(ddl);
|
|
378
|
+
} catch (e: any) {
|
|
379
|
+
if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
351
383
|
// RFC-027 §2 — stop/delete request envelope. Mirrors
|
|
352
384
|
// node_create_requests structure so the daemon-side pull/ack pattern
|
|
353
385
|
// is symmetric with create_node. action='stop'|'delete' picks which
|
|
@@ -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
|
+
});
|