@sleep2agi/commhub-server 0.9.0-preview.8 → 0.9.0-preview.80
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/README.md +130 -10
- package/bin/commhub.ts +92 -11
- package/package.json +5 -5
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +5 -5
- package/src/admin-delete-network-http.test.ts +228 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/agent-access-batch.test.ts +210 -0
- package/src/agent-access.ts +478 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +520 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +190 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -0
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/api-host-supervisors-fallback.test.ts +271 -0
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth.ts +450 -31
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/commhub-bin-args.test.ts +85 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +89 -1
- package/src/create-node-validate.ts +89 -7
- package/src/create-node-workdir.test.ts +255 -0
- package/src/create-node.ts +46 -1
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +148 -0
- package/src/db-adapter-translate.test.ts +128 -0
- package/src/db-adapter.ts +323 -87
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +805 -33
- package/src/delivered-without-push.test.ts +140 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -0
- package/src/dm-file-scope-http.test.ts +178 -0
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +321 -0
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +327 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +404 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +732 -0
- package/src/health-redaction.test.ts +642 -0
- package/src/http-gzip.test.ts +94 -0
- package/src/http-gzip.ts +110 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +215 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2331
- package/src/lifecycle-guard.ts +50 -0
- package/src/mcp-route-label-http.test.ts +120 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/member-presence-http.test.ts +206 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/migrate-sqlite-to-pg.test.ts +47 -0
- package/src/migrate-sqlite-to-pg.ts +263 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +224 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-create-request-status-http.test.ts +92 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-id-alias.test.ts +188 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +51 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +120 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-privilege-http.test.ts +149 -0
- package/src/observer-avatar-http.test.ts +429 -0
- package/src/observer-push.test.ts +252 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/pg-worker.ts +87 -0
- package/src/push.ts +284 -7
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/rename.ts +7 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-host-schema.test.ts +211 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/requirement-events-http.test.ts +257 -0
- package/src/requirement-events.test.ts +45 -0
- package/src/requirement-events.ts +143 -0
- package/src/requirement-notify.ts +134 -0
- package/src/requirement-participant-notify-http.test.ts +309 -0
- package/src/requirement-tag-ops-http.test.ts +266 -0
- package/src/requirement-tags.ts +89 -0
- package/src/requirements-completed-at-migration.test.ts +70 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +709 -0
- package/src/requirements-list-slim-http.test.ts +315 -0
- package/src/requirements-mcp.test.ts +163 -0
- package/src/requirements-migrate.ts +225 -0
- package/src/requirements-people-http.test.ts +114 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-search-http.test.ts +182 -0
- package/src/requirements-search.test.ts +32 -0
- package/src/requirements-search.ts +138 -0
- package/src/requirements-seq-http.test.ts +193 -0
- package/src/requirements-seq-migration.test.ts +63 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements-stats-http.test.ts +288 -0
- package/src/requirements-stats.ts +164 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +98 -0
- package/src/requirements.ts +1439 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +216 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +73 -0
- package/src/retention.ts +15 -3
- package/src/route-callers-http.test.ts +120 -0
- package/src/route-timing.test.ts +211 -0
- package/src/route-timing.ts +258 -0
- package/src/rules-file-duplicate-alias.test.ts +234 -0
- package/src/rules-file-session-target.test.ts +207 -0
- package/src/rules-file-transport.test.ts +237 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
- package/src/scheduled-reply-unread-http.test.ts +212 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-tasks-http.test.ts +638 -0
- package/src/scheduled-tasks.ts +597 -0
- package/src/send-dedup-request-id.test.ts +229 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +458 -0
- package/src/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +4522 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/side-thread-command-transport.test.ts +312 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +233 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2499 -0
- package/src/skillhub-http.test.ts +108 -0
- package/src/sqlite-close-checkpoint.test.ts +78 -0
- package/src/start-node.test.ts +109 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/status-node-id-filter-http.test.ts +161 -0
- package/src/stop-delete-node.test.ts +1164 -0
- package/src/task-access-http.test.ts +379 -0
- package/src/task-access.ts +307 -0
- package/src/task-auth-origin-http.test.ts +91 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +482 -0
- package/src/task-diagnostic-http.test.ts +149 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-lifecycle-watcher.test.ts +204 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +141 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tools-list-json-schema.test.ts +66 -0
- package/src/tools.ts +3534 -343
- package/src/update-provider.test.ts +65 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +138 -5
- package/src/user-inbox-by-agent-http.test.ts +161 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +223 -0
- package/src/user-inbox-schema.test.ts +54 -0
|
@@ -0,0 +1,404 @@
|
|
|
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
|
+
const userANetworkId = rA.network_id!;
|
|
60
|
+
|
|
61
|
+
// #503 — userA joins userB's network below, which makes userA a
|
|
62
|
+
// SAME-network peer. Cross-network denial needs a principal who is in
|
|
63
|
+
// neither, so userC exists purely to be an outsider.
|
|
64
|
+
const nameC = `userccc_495_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
|
65
|
+
const rC = register(nameC, "BootstrapPw123Aa!", undefined, "userC");
|
|
66
|
+
if (!rC.ok) throw new Error(`userC register failed: ${rC.error}`);
|
|
67
|
+
const userCToken = rC.token!;
|
|
68
|
+
const userCUserId = rC.user!.user_id;
|
|
69
|
+
|
|
70
|
+
const nameB = `userbbb_495_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
|
71
|
+
const rB = register(nameB, "BootstrapPw123Aa!", undefined, "userB");
|
|
72
|
+
if (!rB.ok) throw new Error(`userB register failed: ${rB.error}`);
|
|
73
|
+
const userBToken = rB.token!;
|
|
74
|
+
const userBUserId = rB.user!.user_id;
|
|
75
|
+
const userBNetworkToken = rB.network_token!;
|
|
76
|
+
const userBNetworkId = getUserAllNetworks(userBUserId)[0]?.network_id!;
|
|
77
|
+
if (!userBNetworkId) throw new Error("userB default network not found");
|
|
78
|
+
|
|
79
|
+
// #500 CR2 DEV_OPEN 假门 fix — assert userB is NOT admin at fixture
|
|
80
|
+
// setup time. If fixture ordering ever changes (e.g. auth.ts flips
|
|
81
|
+
// "first user auto-admin" logic, or an earlier test file promotes
|
|
82
|
+
// userB), the assertion catches it before Test A/B can silently
|
|
83
|
+
// short-circuit through the admin branch of authorizeFileDownload
|
|
84
|
+
// and hide a broken DEV_OPEN gate. Independent审员 caught this
|
|
85
|
+
// class of假门 in the prior CR round — the assertion writes the
|
|
86
|
+
// "userB is non-admin" invariant into the test itself so a future
|
|
87
|
+
// fixture drift cannot re-open the hole.
|
|
88
|
+
{
|
|
89
|
+
const userBRoleRow = db.get<{ role: string }>(
|
|
90
|
+
"SELECT role FROM users WHERE user_id = ?1",
|
|
91
|
+
userBUserId,
|
|
92
|
+
);
|
|
93
|
+
if (!userBRoleRow) throw new Error("userB not found in users table after register");
|
|
94
|
+
if (userBRoleRow.role === "admin") {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`[#500 CR2 fixture-drift guard] userB was auto-promoted to admin ` +
|
|
97
|
+
`(role=${userBRoleRow.role}). Refusing to run: the DEV_OPEN and ` +
|
|
98
|
+
`cross-owner tests would short-circuit through the admin allow-list ` +
|
|
99
|
+
`branch and validate nothing.`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Add userA as a member of userB's default network. This is what makes
|
|
105
|
+
// the "same-network non-owner" test cell meaningful — without it, that
|
|
106
|
+
// test would only prove cross-network denial, which is a weaker claim.
|
|
107
|
+
// 多用户 Agent 权限:新成员默认只看授权 Agent;本测试测的是「完全信任的成员」旧语义,显式 agent_access=all。
|
|
108
|
+
const addMember = addNetworkMember(userBNetworkId, userAUserId, "member", userBUserId, { agentAccess: "all" });
|
|
109
|
+
if (!addMember.ok) throw new Error(`add userA to userB network failed: ${addMember.error}`);
|
|
110
|
+
|
|
111
|
+
const { bootServer } = await import("./server.js");
|
|
112
|
+
const server = bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
113
|
+
const BASE = `http://127.0.0.1:${server.port}`;
|
|
114
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
115
|
+
|
|
116
|
+
// Probe the loaded server's frozen AUTH_TOKEN binding. If master token
|
|
117
|
+
// works we can exercise the legacy branch; if not (aggregate load-order
|
|
118
|
+
// effect), `test.skipIf` reports "skipped" (NOT "passed").
|
|
119
|
+
const _probeMaster = await fetch(`${BASE}/api/files/00000000000000000000000000000000`, {
|
|
120
|
+
headers: { Authorization: `Bearer ${MASTER_TOKEN}` },
|
|
121
|
+
});
|
|
122
|
+
const masterTokenActive = _probeMaster.status === 404;
|
|
123
|
+
|
|
124
|
+
// Probe the loaded server's frozen DEV_OPEN binding. This file is
|
|
125
|
+
// meant to run against a DEV_OPEN=false server. In aggregate mode,
|
|
126
|
+
// if file-download-authz-dev-open.test.ts loaded server.ts first
|
|
127
|
+
// with DEV_OPEN=true, the singleton wins — we can't flip it. Skip
|
|
128
|
+
// the whole production-mode suite via `describe.skipIf` (NOT
|
|
129
|
+
// early-return in each test body) so the runner reports "skipped".
|
|
130
|
+
async function _uploadProbe(): Promise<number> {
|
|
131
|
+
const form = new FormData();
|
|
132
|
+
form.append("file", new Blob([new TextEncoder().encode("p")], { type: "application/octet-stream" }), "p");
|
|
133
|
+
const r = await fetch(`${BASE}/api/upload`, { method: "POST", body: form });
|
|
134
|
+
return r.status;
|
|
135
|
+
}
|
|
136
|
+
const devOpenSuspected = (await _uploadProbe()) === 200;
|
|
137
|
+
const isProdMode = !devOpenSuspected;
|
|
138
|
+
|
|
139
|
+
afterAll(() => {
|
|
140
|
+
try { server?.stop?.(true); } catch {}
|
|
141
|
+
try { rmSync(UPLOADS_DIR, { recursive: true, force: true }); } catch {}
|
|
142
|
+
try { rmSync(SERVER_DB, { recursive: true, force: true }); } catch {}
|
|
143
|
+
delete process.env.COMMHUB_AUTH_TOKEN;
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// #503 — a utok_ holder in more than one network must name the target
|
|
147
|
+
// network; the server refuses to guess ("first network" is not assumed).
|
|
148
|
+
// Callers using a single-network token can still omit it.
|
|
149
|
+
async function uploadAs(token: string, content: Uint8Array, filename: string, networkId?: string): Promise<string> {
|
|
150
|
+
const form = new FormData();
|
|
151
|
+
form.append("file", new Blob([content], { type: "application/octet-stream" }), filename);
|
|
152
|
+
const url = networkId
|
|
153
|
+
? `${BASE}/api/upload?network_id=${encodeURIComponent(networkId)}`
|
|
154
|
+
: `${BASE}/api/upload`;
|
|
155
|
+
const res = await fetch(url, {
|
|
156
|
+
method: "POST",
|
|
157
|
+
body: form,
|
|
158
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
159
|
+
});
|
|
160
|
+
expect(res.status).toBe(200);
|
|
161
|
+
const body: any = await res.json();
|
|
162
|
+
expect(typeof body.file_id).toBe("string");
|
|
163
|
+
return body.file_id;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function downloadAs(token: string | null, fileId: string): Promise<Response> {
|
|
167
|
+
const headers: Record<string, string> = {};
|
|
168
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
169
|
+
return fetch(`${BASE}/api/files/${fileId}`, { headers });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function downloadWithQueryToken(token: string, fileId: string): Promise<Response> {
|
|
173
|
+
return fetch(`${BASE}/api/files/${fileId}?token=${encodeURIComponent(token)}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
describe.skipIf(!isProdMode)("#495 — GET /api/files/:file_id authorization (owner-only staged)", () => {
|
|
177
|
+
let userBFileId = "";
|
|
178
|
+
|
|
179
|
+
beforeAll(async () => {
|
|
180
|
+
userBFileId = await uploadAs(userBToken, new TextEncoder().encode("secret-from-B"), "b-secret.txt");
|
|
181
|
+
expect(userBFileId.length).toBe(32);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("owner (userB) via utok_ downloads own file → 200 (normal path)", async () => {
|
|
185
|
+
const res = await downloadAs(userBToken, userBFileId);
|
|
186
|
+
expect(res.status).toBe(200);
|
|
187
|
+
expect(await res.text()).toBe("secret-from-B");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("owner (userB) via ntok_ downloads own file → 200 (agent self-upload+self-download)", async () => {
|
|
191
|
+
const agentFileId = await uploadAs(userBNetworkToken, new TextEncoder().encode("agent-blob"), "agent.bin");
|
|
192
|
+
const res = await downloadAs(userBNetworkToken, agentFileId);
|
|
193
|
+
expect(res.status).toBe(200);
|
|
194
|
+
expect(await res.text()).toBe("agent-blob");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("🔴 cross-network non-owner (userC) downloads userB's file → 404 (was 200 pre-#495)", async () => {
|
|
198
|
+
// Precondition — userC is in NEITHER of userB's networks, so the 404
|
|
199
|
+
// is cross-network denial. This row used to use userA, but #503 made
|
|
200
|
+
// userA a legitimate same-network reader; keeping userA here would
|
|
201
|
+
// have turned #495's guarantee into a test that asserts nothing.
|
|
202
|
+
const rows = db.all<{ network_id: string }>(
|
|
203
|
+
"SELECT network_id FROM network_members WHERE user_id = ?1 AND network_id = ?2",
|
|
204
|
+
userCUserId, userBNetworkId,
|
|
205
|
+
);
|
|
206
|
+
expect(rows.length).toBe(0);
|
|
207
|
+
const res = await downloadAs(userCToken, userBFileId);
|
|
208
|
+
expect(res.status).toBe(404);
|
|
209
|
+
const body: any = await res.json();
|
|
210
|
+
expect(body.error).toBe("not_found");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("🔴 same-network non-owner (userA is a member of userB's network) → 200 (#503 lifted the #495 staged carve-out)", async () => {
|
|
214
|
+
// Precondition — userA really is a member of userB's default network
|
|
215
|
+
// (added in module setup), so the 200 represents "same-network peer
|
|
216
|
+
// allowed" rather than an owner match or an admin bypass.
|
|
217
|
+
const rows = db.all<{ network_id: string }>(
|
|
218
|
+
"SELECT network_id FROM network_members WHERE user_id = ?1 AND network_id = ?2",
|
|
219
|
+
userAUserId, userBNetworkId,
|
|
220
|
+
);
|
|
221
|
+
expect(rows.length).toBe(1);
|
|
222
|
+
expect(db.get<{ role: string }>("SELECT role FROM users WHERE user_id = ?1", userAUserId)?.role)
|
|
223
|
+
.not.toBe("admin");
|
|
224
|
+
const res = await downloadAs(userAToken, userBFileId);
|
|
225
|
+
expect(res.status).toBe(200);
|
|
226
|
+
expect(await res.text()).toBe("secret-from-B");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("admin caller downloads any file → 200 (operational access preserved)", async () => {
|
|
230
|
+
const res = await downloadAs(adminToken, userBFileId);
|
|
231
|
+
expect(res.status).toBe(200);
|
|
232
|
+
expect(await res.text()).toBe("secret-from-B");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test.skipIf(!masterTokenActive)("legacy master AUTH_TOKEN downloads any file → 200 (single-tenant deployments)", async () => {
|
|
236
|
+
const res = await downloadAs(MASTER_TOKEN, userBFileId);
|
|
237
|
+
expect(res.status).toBe(200);
|
|
238
|
+
expect(await res.text()).toBe("secret-from-B");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("anonymous request → 401 (auth still required)", async () => {
|
|
242
|
+
const res = await downloadAs(null, userBFileId);
|
|
243
|
+
expect(res.status).toBe(401);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("🔴 valid query token is refused on file GET (credential must stay out of URLs)", async () => {
|
|
247
|
+
const res = await downloadWithQueryToken(userBToken, userBFileId);
|
|
248
|
+
expect(res.status).toBe(401);
|
|
249
|
+
const body: any = await res.json();
|
|
250
|
+
expect(body.error).toBe("unauthorized");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("non-owner request for an UNKNOWN file_id → 404 with same shape as owner-denied (no enumeration signal)", async () => {
|
|
254
|
+
const res = await downloadAs(userAToken, "0123456789abcdef0123456789abcdef");
|
|
255
|
+
expect(res.status).toBe(404);
|
|
256
|
+
const body: any = await res.json();
|
|
257
|
+
expect(body.error).toBe("not_found");
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// #500 CR2 ① — HEAD verb explicit authz coverage. Pre-CR2, HEAD fell
|
|
262
|
+
// through to a fallback 200 page; body was byte-identical to unknown
|
|
263
|
+
// file_id so no oracle formed by accident, but it was coincidence, not
|
|
264
|
+
// a gate. This suite asserts HEAD routes through authorizeFileDownload
|
|
265
|
+
// per RFC 9110 §9.3.2: same status/headers as GET, body omitted.
|
|
266
|
+
async function headAs(token: string | null, fileId: string): Promise<Response> {
|
|
267
|
+
const headers: Record<string, string> = {};
|
|
268
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
269
|
+
return fetch(`${BASE}/api/files/${fileId}`, { method: "HEAD", headers });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function headWithQueryToken(token: string, fileId: string): Promise<Response> {
|
|
273
|
+
return fetch(`${BASE}/api/files/${fileId}?token=${encodeURIComponent(token)}`, { method: "HEAD" });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
describe.skipIf(!isProdMode)("#500 CR2 — HEAD /api/files/:file_id authorization (parity with GET)", () => {
|
|
277
|
+
let userBFileId = "";
|
|
278
|
+
|
|
279
|
+
beforeAll(async () => {
|
|
280
|
+
userBFileId = await uploadAs(userBToken, new TextEncoder().encode("head-scenario-payload"), "b-head.txt");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("owner (userB) HEAD own file → 200 + content-length header (body omitted per HEAD)", async () => {
|
|
284
|
+
const res = await headAs(userBToken, userBFileId);
|
|
285
|
+
expect(res.status).toBe(200);
|
|
286
|
+
expect(res.headers.get("content-length")).toBe(String("head-scenario-payload".length));
|
|
287
|
+
expect(res.headers.get("content-disposition")).toContain("attachment");
|
|
288
|
+
expect(res.headers.get("x-content-type-options")).toBe("nosniff");
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("🔴 cross-network HEAD (userC HEAD userB's file) → 404 via helper (was fallback 200 pre-CR2)", async () => {
|
|
292
|
+
// userC, not userA — #503 makes userA a same-network reader, so the
|
|
293
|
+
// GET/HEAD parity claim needs a principal who is genuinely denied.
|
|
294
|
+
const res = await headAs(userCToken, userBFileId);
|
|
295
|
+
expect(res.status).toBe(404);
|
|
296
|
+
// 404 must originate from authorizeFileDownload, not the fallback
|
|
297
|
+
// page. Body is application/json ({"ok":false,"error":"not_found"})
|
|
298
|
+
// — check Content-Type so a future fallback rewrite (e.g. adding
|
|
299
|
+
// "requested resource type: X") does not silently form an oracle
|
|
300
|
+
// while HEAD still returns 200 from that page.
|
|
301
|
+
expect(res.headers.get("content-type")?.toLowerCase()).toContain("application/json");
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("HEAD unknown file_id → 404 with same shape as cross-owner (no enumeration signal)", async () => {
|
|
305
|
+
const res = await headAs(userAToken, "0123456789abcdef0123456789abcdef");
|
|
306
|
+
expect(res.status).toBe(404);
|
|
307
|
+
expect(res.headers.get("content-type")?.toLowerCase()).toContain("application/json");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("HEAD anonymous → 401 (auth still required, symmetric with GET)", async () => {
|
|
311
|
+
const res = await headAs(null, userBFileId);
|
|
312
|
+
expect(res.status).toBe(401);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("🔴 valid query token is refused on file HEAD (same gate as GET)", async () => {
|
|
316
|
+
const res = await headWithQueryToken(userBToken, userBFileId);
|
|
317
|
+
expect(res.status).toBe(401);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("admin HEAD any file → 200 (operational access preserved on HEAD)", async () => {
|
|
321
|
+
const res = await headAs(adminToken, userBFileId);
|
|
322
|
+
expect(res.status).toBe(200);
|
|
323
|
+
expect(res.headers.get("content-length")).toBe(String("head-scenario-payload".length));
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// #500 CR2 ④ — Upload owner persistence. Prove that new authenticated
|
|
328
|
+
// uploads always write a truthy owner_id to the on-disk index entry.
|
|
329
|
+
// If the upload handler ever regresses to writing owner_id=null in
|
|
330
|
+
// requireAuth-guarded paths, D1 turns red — the invariant that
|
|
331
|
+
// "production uploads never produce null-owner blobs" is documented
|
|
332
|
+
// in the authorizeFileDownload comment and this test enforces it.
|
|
333
|
+
describe.skipIf(!isProdMode)("#500 CR2 — upload persists truthy owner_id (D1)", () => {
|
|
334
|
+
test("D1: userA authenticated upload → index entry owner_id === userA.userId (never null)", async () => {
|
|
335
|
+
// userA belongs to two networks (own default + userB's), so #503
|
|
336
|
+
// requires the target network to be named explicitly.
|
|
337
|
+
const fileId = await uploadAs(userAToken, new TextEncoder().encode("owner-persistence-check"), "d1.bin", userANetworkId);
|
|
338
|
+
const { readFileSync } = await import("fs");
|
|
339
|
+
const entryPath = join(UPLOADS_DIR, ".index", `${fileId}.json`);
|
|
340
|
+
expect(existsSync(entryPath)).toBe(true);
|
|
341
|
+
const entry = JSON.parse(readFileSync(entryPath, "utf-8"));
|
|
342
|
+
expect(entry.owner_id).not.toBeNull();
|
|
343
|
+
expect(entry.owner_id).toBe(userAUserId);
|
|
344
|
+
expect(entry.owner).toBe(nameA); // owner username stays consistent with the token that uploaded
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test("D1b: userB authenticated upload via utok_ → owner_id === userB.userId", async () => {
|
|
348
|
+
const fileId = await uploadAs(userBToken, new TextEncoder().encode("owner-persistence-check-b"), "d1b.bin");
|
|
349
|
+
const { readFileSync } = await import("fs");
|
|
350
|
+
const entryPath = join(UPLOADS_DIR, ".index", `${fileId}.json`);
|
|
351
|
+
expect(existsSync(entryPath)).toBe(true);
|
|
352
|
+
const entry = JSON.parse(readFileSync(entryPath, "utf-8"));
|
|
353
|
+
expect(entry.owner_id).not.toBeNull();
|
|
354
|
+
expect(entry.owner_id).toBe(userBUserId);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
describe.skipIf(!isProdMode)("#495 — null-owner policy in production (DEV_OPEN=off, non-admin/non-legacy)", () => {
|
|
359
|
+
let nullOwnerFileId = "";
|
|
360
|
+
|
|
361
|
+
beforeAll(async () => {
|
|
362
|
+
// Simulate a legacy null-owner index entry by uploading normally
|
|
363
|
+
// then rewriting the on-disk index to owner_id=null. This precisely
|
|
364
|
+
// reproduces the shape a DEV_OPEN or legacy master upload would
|
|
365
|
+
// create, without needing to spin up a second server in a
|
|
366
|
+
// different mode.
|
|
367
|
+
nullOwnerFileId = await uploadAs(userAToken, new TextEncoder().encode("legacy-blob"), "legacy.txt", userANetworkId);
|
|
368
|
+
const { readFileSync: rfs, writeFileSync: wfs } = await import("fs");
|
|
369
|
+
const entryFile = join(UPLOADS_DIR, ".index", `${nullOwnerFileId}.json`);
|
|
370
|
+
if (!existsSync(entryFile)) throw new Error(`index entry not found: ${entryFile}`);
|
|
371
|
+
const entry = JSON.parse(rfs(entryFile, "utf-8"));
|
|
372
|
+
entry.owner = null;
|
|
373
|
+
entry.owner_id = null;
|
|
374
|
+
// #503 — a true legacy entry predates network attribution entirely.
|
|
375
|
+
// Deleting the key (rather than nulling it) matches what the writer
|
|
376
|
+
// emits when there is no attribution, so this fixture exercises the
|
|
377
|
+
// legacy compatibility branch instead of the network branch.
|
|
378
|
+
delete entry.network_id;
|
|
379
|
+
wfs(entryFile, JSON.stringify(entry, null, 2));
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test.skipIf(!masterTokenActive)("null-owner + legacy master → 200 (RFC-001 read-only backward-compat)", async () => {
|
|
383
|
+
const res = await downloadAs(MASTER_TOKEN, nullOwnerFileId);
|
|
384
|
+
expect(res.status).toBe(200);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("null-owner + admin utok_ → 200 (operational access preserved)", async () => {
|
|
388
|
+
const res = await downloadAs(adminToken, nullOwnerFileId);
|
|
389
|
+
expect(res.status).toBe(200);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test("🔴 Test A (production default) — null-owner + normal user + DEV_OPEN off → 404 fail-closed", async () => {
|
|
393
|
+
// Runtime invariant: this test file explicitly deletes COMMHUB_DEV_OPEN
|
|
394
|
+
// at module setup, so the DEV_OPEN branch in authorizeFileDownload
|
|
395
|
+
// MUST NOT execute here. If someone weakens the guard so that
|
|
396
|
+
// null-owner is granted even without DEV_OPEN, this assertion turns
|
|
397
|
+
// red. That IS the mutation self-check for the production carve-out.
|
|
398
|
+
expect(process.env.COMMHUB_DEV_OPEN).toBeUndefined();
|
|
399
|
+
const res = await downloadAs(userAToken, nullOwnerFileId);
|
|
400
|
+
expect(res.status).toBe(404);
|
|
401
|
+
const body: any = await res.json();
|
|
402
|
+
expect(body.error).toBe("not_found");
|
|
403
|
+
});
|
|
404
|
+
});
|