@sleep2agi/commhub-server 0.9.0-preview.24 → 0.9.0-preview.26
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 +9 -6
- package/package.json +1 -1
- package/src/auth.ts +5 -0
- package/src/avatar-validate.test.ts +52 -2
- package/src/avatar-validate.ts +35 -1
- package/src/ext-validation-regression.test.ts +138 -0
- package/src/file-download-authz.test.ts +50 -16
- 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 +731 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/network-scope.ts +81 -0
- package/src/send-reply-attachments.test.ts +437 -0
- package/src/server.ts +298 -89
- package/src/tools.ts +148 -42
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +80 -3
- package/src/uploads.ts +81 -3
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
// #503 — network scope for /api/files/:file_id + upload attribution.
|
|
2
|
+
//
|
|
3
|
+
// #495/#500 gave downloads an owner-only allow-list and explicitly
|
|
4
|
+
// deferred "same-network non-owner" to this issue. This suite covers
|
|
5
|
+
// both halves of the fix:
|
|
6
|
+
// • upload decides WHICH network a blob belongs to (U rows), and
|
|
7
|
+
// • download decides who may read a blob given that network (D rows),
|
|
8
|
+
// plus the enumeration-oracle rows (E) that pin denied responses to be
|
|
9
|
+
// byte-identical to "no such thing".
|
|
10
|
+
//
|
|
11
|
+
// FIXTURE DISCIPLINE — the whole suite is worthless if a probe is admin.
|
|
12
|
+
// `authorizeFileDownload` returns true for admin-utok before it ever
|
|
13
|
+
// looks at the network, so an admin probe passes every D row while
|
|
14
|
+
// proving nothing. commhub auto-promotes the FIRST registered user to
|
|
15
|
+
// admin (auth.ts register(): `isFirstUser ? "admin" : "user"`), so:
|
|
16
|
+
// 1. a throwaway `seedUser` is registered first to occupy that slot,
|
|
17
|
+
// 2. every non-admin probe is registered after it, and
|
|
18
|
+
// 3. each row re-reads the probe's role from the DB and asserts it is
|
|
19
|
+
// not admin — inline, not via a helper, so the assertion cannot be
|
|
20
|
+
// deleted in one place and lost everywhere.
|
|
21
|
+
// See project memory feedback_first_registered_user_is_admin_fake_green.
|
|
22
|
+
//
|
|
23
|
+
// DEV_OPEN rows live in file-network-scope-dev-open.test.ts: DEV_OPEN is
|
|
24
|
+
// captured at server.ts module-load time and cannot be flipped mid-suite
|
|
25
|
+
// (same reason #500 split its DEV_OPEN coverage into a sibling file).
|
|
26
|
+
|
|
27
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
28
|
+
import { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
|
29
|
+
import { join, dirname } from "path";
|
|
30
|
+
import { tmpdir } from "os";
|
|
31
|
+
// Type-only — erased at runtime, so it cannot pull server.ts in before
|
|
32
|
+
// the env below is set.
|
|
33
|
+
import type { Principal } from "./server.js";
|
|
34
|
+
|
|
35
|
+
const SERVER_DB = mkdtempSync(join(tmpdir(), "anet-503-db-")) + "/commhub.db";
|
|
36
|
+
const UPLOADS_DIR = mkdtempSync(join(tmpdir(), "anet-503-fs-"));
|
|
37
|
+
const MASTER_TOKEN = `master-503-${Date.now()}-${Math.floor(Math.random() * 100000)}`;
|
|
38
|
+
|
|
39
|
+
// Env must be set before anything pulls in db-adapter.ts (which refuses
|
|
40
|
+
// the default DB under NODE_ENV=test) or server.ts (which freezes
|
|
41
|
+
// DEV_OPEN + AUTH_TOKEN at module load).
|
|
42
|
+
process.env.COMMHUB_DB = SERVER_DB;
|
|
43
|
+
process.env.COMMHUB_UPLOADS_DIR = UPLOADS_DIR;
|
|
44
|
+
process.env.HOST = "127.0.0.1";
|
|
45
|
+
process.env.COMMHUB_AUTH_TOKEN = MASTER_TOKEN;
|
|
46
|
+
delete process.env.COMMHUB_DEV_OPEN;
|
|
47
|
+
|
|
48
|
+
const { register, addNetworkMember, createNetworkTokenForNode } = await import("./auth.js");
|
|
49
|
+
const { db } = await import("./db.js");
|
|
50
|
+
|
|
51
|
+
const STAMP = `${Date.now()}${Math.floor(Math.random() * 100000)}`;
|
|
52
|
+
|
|
53
|
+
type Probe = {
|
|
54
|
+
name: string;
|
|
55
|
+
userId: string;
|
|
56
|
+
token: string; // utok_
|
|
57
|
+
ntok: string; // ntok_ bound to their own default network
|
|
58
|
+
networkId: string; // their own auto-created default network
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
function reg(label: string): Probe {
|
|
62
|
+
const name = `u503${label}${STAMP}`;
|
|
63
|
+
const r = register(name, "BootstrapPw123Aa!", undefined, label);
|
|
64
|
+
if (!r.ok) throw new Error(`${label} register failed: ${r.error}`);
|
|
65
|
+
return {
|
|
66
|
+
name,
|
|
67
|
+
userId: r.user!.user_id,
|
|
68
|
+
token: r.token!,
|
|
69
|
+
ntok: r.network_token!,
|
|
70
|
+
networkId: r.network_id!,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function roleOf(userId: string): string | null {
|
|
75
|
+
return db.get<{ role: string }>("SELECT role FROM users WHERE user_id = ?1", userId)?.role ?? null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 1. Occupies the auto-admin slot so no probe below can inherit it.
|
|
79
|
+
const seedUser = reg("seed");
|
|
80
|
+
// 2. The admin probe is promoted explicitly rather than relying on
|
|
81
|
+
// registration order, which is not stable under aggregate runs.
|
|
82
|
+
const adminUser = reg("admin");
|
|
83
|
+
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [adminUser.userId]);
|
|
84
|
+
// 3. Non-admin probes.
|
|
85
|
+
const nonAdminUserA = reg("usera");
|
|
86
|
+
const nonAdminUserB = reg("userb");
|
|
87
|
+
const outsiderUser = reg("outsider");
|
|
88
|
+
const viewerOnlyUser = reg("vieweronly");
|
|
89
|
+
const mixedUser = reg("mixed");
|
|
90
|
+
// 4. Additional admins with different network-membership counts for
|
|
91
|
+
// F2=F auto-derive (U11a/b/c). adminUser is multi-network (own
|
|
92
|
+
// default + N1 below, → U11b). adminSingleUser stays single-network
|
|
93
|
+
// on its own default → U11a. adminZeroUser has zero memberships
|
|
94
|
+
// (own default is dropped below) → U11c.
|
|
95
|
+
const adminSingleUser = reg("adminsingle");
|
|
96
|
+
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [adminSingleUser.userId]);
|
|
97
|
+
const adminZeroUser = reg("adminzero");
|
|
98
|
+
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [adminZeroUser.userId]);
|
|
99
|
+
db.run("DELETE FROM network_members WHERE user_id = ?1", [adminZeroUser.userId]);
|
|
100
|
+
|
|
101
|
+
// N1 is userA's own default network — userA stays single-network so the
|
|
102
|
+
// "no query param, unambiguous membership" upload path (U6) is reachable.
|
|
103
|
+
const N1 = nonAdminUserA.networkId;
|
|
104
|
+
// N2 is userB's own default network. userB also joins N1, making userB
|
|
105
|
+
// multi-network (U7/U8) and a same-network non-owner peer of userA (D3).
|
|
106
|
+
const N2 = nonAdminUserB.networkId;
|
|
107
|
+
// N3 exists but userB is not a member — the "not yours" upload row (U9).
|
|
108
|
+
const N3 = seedUser.networkId;
|
|
109
|
+
const N_FAKE = `net_503fake${STAMP}`;
|
|
110
|
+
|
|
111
|
+
if (!addNetworkMember(N1, nonAdminUserB.userId, "member", nonAdminUserA.userId).ok) {
|
|
112
|
+
throw new Error("fixture: userB → N1 member failed");
|
|
113
|
+
}
|
|
114
|
+
// The admin needs write access to N1 to be issued a node ntok_ there
|
|
115
|
+
// (createNetworkTokenForNode refuses non-members and viewers). That
|
|
116
|
+
// token is the D15 probe: a ntok_ ISSUED BY an admin.
|
|
117
|
+
if (!addNetworkMember(N1, adminUser.userId, "member", nonAdminUserA.userId).ok) {
|
|
118
|
+
throw new Error("fixture: admin → N1 member failed");
|
|
119
|
+
}
|
|
120
|
+
// viewerOnlyUser must hold exactly one network, with role viewer, so the
|
|
121
|
+
// "no param + unambiguous network + no write access" row (U13) is
|
|
122
|
+
// reachable. register() makes everyone owner of a default network, so
|
|
123
|
+
// that membership is dropped here.
|
|
124
|
+
db.run("DELETE FROM network_members WHERE user_id = ?1", [viewerOnlyUser.userId]);
|
|
125
|
+
if (!addNetworkMember(N1, viewerOnlyUser.userId, "viewer", nonAdminUserA.userId).ok) {
|
|
126
|
+
throw new Error("fixture: viewerOnly → N1 viewer failed");
|
|
127
|
+
}
|
|
128
|
+
// mixedUser can write in N1 but only read in N2 — U14 asserts that an
|
|
129
|
+
// explicit param pointing at the viewer network is refused.
|
|
130
|
+
if (!addNetworkMember(N1, mixedUser.userId, "member", nonAdminUserA.userId).ok) {
|
|
131
|
+
throw new Error("fixture: mixed → N1 member failed");
|
|
132
|
+
}
|
|
133
|
+
if (!addNetworkMember(N2, mixedUser.userId, "viewer", nonAdminUserB.userId).ok) {
|
|
134
|
+
throw new Error("fixture: mixed → N2 viewer failed");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const adminNtokN1 = createNetworkTokenForNode(adminUser.userId, N1, "d15probe");
|
|
138
|
+
if (!adminNtokN1.ok) throw new Error(`fixture: admin ntok_ on N1 failed: ${adminNtokN1.error}`);
|
|
139
|
+
|
|
140
|
+
const { bootServer, authorizeFileDownload, normalizeEntry } = await import("./server.js");
|
|
141
|
+
const server = bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
142
|
+
const BASE = `http://127.0.0.1:${server.port}`;
|
|
143
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
144
|
+
|
|
145
|
+
// The loaded server module's AUTH_TOKEN binding may predate our env in
|
|
146
|
+
// an aggregate run. Probe it and `skipIf` the legacy-master rows so the
|
|
147
|
+
// runner reports "skipped", never "passed".
|
|
148
|
+
const _probeMaster = await fetch(`${BASE}/api/files/00000000000000000000000000000000`, {
|
|
149
|
+
headers: { Authorization: `Bearer ${MASTER_TOKEN}` },
|
|
150
|
+
});
|
|
151
|
+
const masterTokenActive = _probeMaster.status === 404;
|
|
152
|
+
|
|
153
|
+
// This file asserts production semantics; if a sibling loaded server.ts
|
|
154
|
+
// with DEV_OPEN=1 first, the singleton wins and these rows would be
|
|
155
|
+
// measuring the wrong build.
|
|
156
|
+
async function _anonUploadStatus(): Promise<number> {
|
|
157
|
+
const form = new FormData();
|
|
158
|
+
form.append("file", new Blob([new TextEncoder().encode("p")]), "p.bin");
|
|
159
|
+
const r = await fetch(`${BASE}/api/upload`, { method: "POST", body: form });
|
|
160
|
+
return r.status;
|
|
161
|
+
}
|
|
162
|
+
const isProdMode = (await _anonUploadStatus()) !== 200;
|
|
163
|
+
|
|
164
|
+
afterAll(() => {
|
|
165
|
+
try { server?.stop?.(true); } catch {}
|
|
166
|
+
try { rmSync(UPLOADS_DIR, { recursive: true, force: true }); } catch {}
|
|
167
|
+
try { rmSync(SERVER_DB, { recursive: true, force: true }); } catch {}
|
|
168
|
+
delete process.env.COMMHUB_AUTH_TOKEN;
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// ── helpers ────────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
function uploadUrl(networkId?: string): string {
|
|
174
|
+
return networkId ? `${BASE}/api/upload?network_id=${encodeURIComponent(networkId)}` : `${BASE}/api/upload`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function upload(token: string | null, body: string, filename: string, networkId?: string): Promise<Response> {
|
|
178
|
+
const form = new FormData();
|
|
179
|
+
form.append("file", new Blob([new TextEncoder().encode(body)], { type: "application/octet-stream" }), filename);
|
|
180
|
+
const headers: Record<string, string> = {};
|
|
181
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
182
|
+
return fetch(uploadUrl(networkId), { method: "POST", body: form, headers });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function uploadOk(token: string, body: string, filename: string, networkId?: string): Promise<string> {
|
|
186
|
+
const res = await upload(token, body, filename, networkId);
|
|
187
|
+
if (res.status !== 200) throw new Error(`upload expected 200, got ${res.status}: ${await res.text()}`);
|
|
188
|
+
const json: any = await res.json();
|
|
189
|
+
return json.file_id;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function entryPath(fileId: string): string {
|
|
193
|
+
return join(UPLOADS_DIR, ".index", `${fileId}.json`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function readEntry(fileId: string): any {
|
|
197
|
+
return JSON.parse(readFileSync(entryPath(fileId), "utf-8"));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function writeEntry(fileId: string, entry: any): void {
|
|
201
|
+
writeFileSync(entryPath(fileId), JSON.stringify(entry, null, 2));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function download(token: string | null, fileId: string): Promise<Response> {
|
|
205
|
+
const headers: Record<string, string> = {};
|
|
206
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
207
|
+
return fetch(`${BASE}/api/files/${fileId}`, { headers });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function head(token: string | null, fileId: string): Promise<Response> {
|
|
211
|
+
const headers: Record<string, string> = {};
|
|
212
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
213
|
+
return fetch(`${BASE}/api/files/${fileId}`, { method: "HEAD", headers });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const UNKNOWN_FILE_ID = "0123456789abcdef0123456789abcdef";
|
|
217
|
+
|
|
218
|
+
// Files used by the D rows. Built once, in beforeAll, through the real
|
|
219
|
+
// upload path so the D rows read what the U rows actually write.
|
|
220
|
+
let fileInN1 = "";
|
|
221
|
+
let fileInN2 = "";
|
|
222
|
+
let legacyOwnedByA = "";
|
|
223
|
+
let legacyNullOwner = "";
|
|
224
|
+
|
|
225
|
+
describe.skipIf(!isProdMode)("#503 — fixture integrity", () => {
|
|
226
|
+
test("probes are registered after the auto-admin slot is taken, and are not admin", () => {
|
|
227
|
+
expect(roleOf(nonAdminUserA.userId)).not.toBe("admin");
|
|
228
|
+
expect(roleOf(nonAdminUserB.userId)).not.toBe("admin");
|
|
229
|
+
expect(roleOf(outsiderUser.userId)).not.toBe("admin");
|
|
230
|
+
expect(roleOf(viewerOnlyUser.userId)).not.toBe("admin");
|
|
231
|
+
expect(roleOf(mixedUser.userId)).not.toBe("admin");
|
|
232
|
+
// The admin probe must really be admin, or D6/D10/D14/U10b/U11/U12
|
|
233
|
+
// would be measuring a non-admin and silently prove nothing.
|
|
234
|
+
expect(roleOf(adminUser.userId)).toBe("admin");
|
|
235
|
+
// F2=F auto-derive probes: adminSingleUser must be admin AND in exactly
|
|
236
|
+
// one network; adminZeroUser must be admin AND in zero networks.
|
|
237
|
+
// Fixture drift on either turns U11a/U11c into vacuously-true tests.
|
|
238
|
+
expect(roleOf(adminSingleUser.userId)).toBe("admin");
|
|
239
|
+
expect(roleOf(adminZeroUser.userId)).toBe("admin");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("network membership is shaped as the matrix assumes", () => {
|
|
243
|
+
const memberships = (userId: string) =>
|
|
244
|
+
db.all<{ network_id: string; role: string }>(
|
|
245
|
+
"SELECT network_id, role FROM network_members WHERE user_id = ?1", userId,
|
|
246
|
+
);
|
|
247
|
+
// userA single-network (U6 depends on it being unambiguous).
|
|
248
|
+
expect(memberships(nonAdminUserA.userId).map((r) => r.network_id)).toEqual([N1]);
|
|
249
|
+
// userB multi-network (U7 depends on it being ambiguous).
|
|
250
|
+
expect(memberships(nonAdminUserB.userId).map((r) => r.network_id).sort()).toEqual([N1, N2].sort());
|
|
251
|
+
// outsider is in neither N1 nor N2 (D4 depends on it).
|
|
252
|
+
expect(memberships(outsiderUser.userId).some((r) => r.network_id === N1)).toBe(false);
|
|
253
|
+
// viewerOnly holds exactly one network, as viewer (U13 depends on both).
|
|
254
|
+
expect(memberships(viewerOnlyUser.userId)).toEqual([{ network_id: N1, role: "viewer" }]);
|
|
255
|
+
// N_FAKE really does not exist (U10a/U10b/E8/E9 depend on it).
|
|
256
|
+
expect(db.get<any>("SELECT * FROM networks WHERE network_id = ?1", N_FAKE)).toBeFalsy();
|
|
257
|
+
// N3 really does exist and userB really is not in it (U9 must be
|
|
258
|
+
// "exists but not yours", not accidentally "does not exist").
|
|
259
|
+
expect(db.get<any>("SELECT * FROM networks WHERE network_id = ?1", N3)).toBeTruthy();
|
|
260
|
+
expect(memberships(nonAdminUserB.userId).some((r) => r.network_id === N3)).toBe(false);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// ── Upload matrix ──────────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
describe.skipIf(!isProdMode)("#503 U — upload network attribution", () => {
|
|
267
|
+
// U1/U2 as designed assumed a legacy master could upload. It cannot:
|
|
268
|
+
// requireAuth 401s master tokens on every non-GET /api/ request since
|
|
269
|
+
// RFC-001 made them read-only, so the `legacy-master` upload branch is
|
|
270
|
+
// unreachable. These two rows pin that fact instead — if RFC-001's
|
|
271
|
+
// read-only rule is ever relaxed, they go red and the unattributed-write
|
|
272
|
+
// question has to be answered deliberately rather than by accident.
|
|
273
|
+
test.skipIf(!masterTokenActive)("U1: legacy master upload, no param → 401 (master tokens are read-only, RFC-001)", async () => {
|
|
274
|
+
const res = await upload(MASTER_TOKEN, "u1", "u1.bin");
|
|
275
|
+
expect(res.status).toBe(401);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test.skipIf(!masterTokenActive)("U2: legacy master upload, param=N1 → 401 (a query param does not buy write access)", async () => {
|
|
279
|
+
const res = await upload(MASTER_TOKEN, "u2", "u2.bin", N1);
|
|
280
|
+
expect(res.status).toBe(401);
|
|
281
|
+
// Same token CAN read — proves the 401 is about the write verb, not a
|
|
282
|
+
// dead/misconfigured master token (which would make U1 vacuous).
|
|
283
|
+
const readBack = await fetch(`${BASE}/api/files/${UNKNOWN_FILE_ID}`, {
|
|
284
|
+
headers: { Authorization: `Bearer ${MASTER_TOKEN}` },
|
|
285
|
+
});
|
|
286
|
+
expect(readBack.status).toBe(404);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("U4: ntok_(N1), no param → 200 and entry.network_id === N1 (bound network is the attribution)", async () => {
|
|
290
|
+
const fileId = await uploadOk(nonAdminUserA.ntok, "u4", "u4.bin");
|
|
291
|
+
expect(readEntry(fileId).network_id).toBe(N1);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("U5: ntok_(N1), param=N2 → 400 network_id_conflict (never silently overridden)", async () => {
|
|
295
|
+
const res = await upload(nonAdminUserA.ntok, "u5", "u5.bin", N2);
|
|
296
|
+
expect(res.status).toBe(400);
|
|
297
|
+
expect((await res.json() as any).error).toBe("network_id_conflict");
|
|
298
|
+
// The conflicting network must not have been written to either.
|
|
299
|
+
expect(db.get<any>("SELECT * FROM networks WHERE network_id = ?1", N2)).toBeTruthy();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("U6: utok_ single-network non-admin, no param → 200 and entry.network_id === N1", async () => {
|
|
303
|
+
expect(roleOf(nonAdminUserA.userId)).not.toBe("admin");
|
|
304
|
+
const fileId = await uploadOk(nonAdminUserA.token, "u6", "u6.bin");
|
|
305
|
+
expect(readEntry(fileId).network_id).toBe(N1);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("U7: utok_ multi-network non-admin, no param → 400 network_id_required ('first network' is never assumed)", async () => {
|
|
309
|
+
expect(roleOf(nonAdminUserB.userId)).not.toBe("admin");
|
|
310
|
+
const res = await upload(nonAdminUserB.token, "u7", "u7.bin");
|
|
311
|
+
expect(res.status).toBe(400);
|
|
312
|
+
expect((await res.json() as any).error).toBe("network_id_required");
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("U8: utok_ multi-network non-admin, param=N1 (a network they belong to) → 200 and entry.network_id === N1", async () => {
|
|
316
|
+
expect(roleOf(nonAdminUserB.userId)).not.toBe("admin");
|
|
317
|
+
const fileId = await uploadOk(nonAdminUserB.token, "u8", "u8.bin", N1);
|
|
318
|
+
expect(readEntry(fileId).network_id).toBe(N1);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// U9/U10a as designed expected a 404 emitted by the upload handler.
|
|
322
|
+
// The request never reaches it: every REST /api endpoint already passes
|
|
323
|
+
// through a shared network-scope guard (server.ts, `restScope.denied`)
|
|
324
|
+
// that 403s a non-member's ?network_id=. The security property the
|
|
325
|
+
// design wanted — a non-admin cannot tell "exists, not yours" from "no
|
|
326
|
+
// such network" — holds there instead, and E8 pins it byte-for-byte.
|
|
327
|
+
test("U9: utok_ non-admin, param=N3 (exists, not a member) → 403 at the shared REST scope guard", async () => {
|
|
328
|
+
expect(roleOf(nonAdminUserB.userId)).not.toBe("admin");
|
|
329
|
+
const res = await upload(nonAdminUserB.token, "u9", "u9.bin", N3);
|
|
330
|
+
expect(res.status).toBe(403);
|
|
331
|
+
expect((await res.json() as any).error).toBe("access denied to requested network");
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("U10a: utok_ non-admin, param=N_FAKE (does not exist) → 403, indistinguishable from U9", async () => {
|
|
335
|
+
expect(roleOf(nonAdminUserB.userId)).not.toBe("admin");
|
|
336
|
+
const res = await upload(nonAdminUserB.token, "u10a", "u10a.bin", N_FAKE);
|
|
337
|
+
expect(res.status).toBe(403);
|
|
338
|
+
expect((await res.json() as any).error).toBe("access denied to requested network");
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("U10b: admin utok_, param=N_FAKE → 400 unknown_network (admin already knows the network list; no oracle)", async () => {
|
|
342
|
+
expect(roleOf(adminUser.userId)).toBe("admin");
|
|
343
|
+
const res = await upload(adminUser.token, "u10b", "u10b.bin", N_FAKE);
|
|
344
|
+
expect(res.status).toBe(400);
|
|
345
|
+
expect((await res.json() as any).error).toBe("unknown_network");
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// #503 Finding 2 = Option F (lead 40be9845): admin gets the same
|
|
349
|
+
// "auto-derive when unambiguous / require param when ambiguous"
|
|
350
|
+
// rule as utok_. Split old U11 into three rows to pin all three
|
|
351
|
+
// membership shapes.
|
|
352
|
+
test("U11a: admin utok_, EXACTLY 1 network membership, no param → 200 and entry.network_id === that network (F auto-derive)", async () => {
|
|
353
|
+
expect(roleOf(adminSingleUser.userId)).toBe("admin");
|
|
354
|
+
const singleNet = db.all<{ network_id: string }>(
|
|
355
|
+
"SELECT network_id FROM network_members WHERE user_id = ?1", adminSingleUser.userId,
|
|
356
|
+
);
|
|
357
|
+
expect(singleNet.length).toBe(1);
|
|
358
|
+
const fileId = await uploadOk(adminSingleUser.token, "u11a", "u11a.bin");
|
|
359
|
+
expect(readEntry(fileId).network_id).toBe(singleNet[0].network_id);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("U11b: admin utok_, ≥2 network memberships, no param → 400 network_id_required (strict on ambiguity)", async () => {
|
|
363
|
+
expect(roleOf(adminUser.userId)).toBe("admin");
|
|
364
|
+
const multi = db.all<{ network_id: string }>(
|
|
365
|
+
"SELECT network_id FROM network_members WHERE user_id = ?1", adminUser.userId,
|
|
366
|
+
);
|
|
367
|
+
expect(multi.length).toBeGreaterThanOrEqual(2);
|
|
368
|
+
const res = await upload(adminUser.token, "u11b", "u11b.bin");
|
|
369
|
+
expect(res.status).toBe(400);
|
|
370
|
+
expect((await res.json() as any).error).toBe("network_id_required");
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("U11c: admin utok_, ZERO network memberships, no param → 400 network_id_required (no unowned files even for admin)", async () => {
|
|
374
|
+
expect(roleOf(adminZeroUser.userId)).toBe("admin");
|
|
375
|
+
const zero = db.all<{ network_id: string }>(
|
|
376
|
+
"SELECT network_id FROM network_members WHERE user_id = ?1", adminZeroUser.userId,
|
|
377
|
+
);
|
|
378
|
+
expect(zero.length).toBe(0);
|
|
379
|
+
const res = await upload(adminZeroUser.token, "u11c", "u11c.bin");
|
|
380
|
+
expect(res.status).toBe(400);
|
|
381
|
+
expect((await res.json() as any).error).toBe("network_id_required");
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("U12: admin utok_, param=N1 → 200 and entry.network_id === N1", async () => {
|
|
385
|
+
const fileId = await uploadOk(adminUser.token, "u12", "u12.bin", N1);
|
|
386
|
+
expect(readEntry(fileId).network_id).toBe(N1);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test("U13: utok_ viewer, single network, no param → 403 permission_denied (read-only role cannot write)", async () => {
|
|
390
|
+
expect(roleOf(viewerOnlyUser.userId)).not.toBe("admin");
|
|
391
|
+
const res = await upload(viewerOnlyUser.token, "u13", "u13.bin");
|
|
392
|
+
expect(res.status).toBe(403);
|
|
393
|
+
expect((await res.json() as any).error).toBe("permission_denied");
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("U14: utok_ member of N1 but viewer in N2, param=N2 → 403 permission_denied", async () => {
|
|
397
|
+
expect(roleOf(mixedUser.userId)).not.toBe("admin");
|
|
398
|
+
const res = await upload(mixedUser.token, "u14", "u14.bin", N2);
|
|
399
|
+
expect(res.status).toBe(403);
|
|
400
|
+
expect((await res.json() as any).error).toBe("permission_denied");
|
|
401
|
+
// Same caller CAN write to the network where they hold member — proves
|
|
402
|
+
// the 403 above is about the role in N2, not a blanket denial.
|
|
403
|
+
const okFileId = await uploadOk(mixedUser.token, "u14ok", "u14ok.bin", N1);
|
|
404
|
+
expect(readEntry(okFileId).network_id).toBe(N1);
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
// ── Download matrix ────────────────────────────────────────────────────
|
|
409
|
+
|
|
410
|
+
describe.skipIf(!isProdMode)("#503 D — download network scope", () => {
|
|
411
|
+
beforeAll(async () => {
|
|
412
|
+
fileInN1 = await uploadOk(nonAdminUserA.token, "payload-N1", "n1.bin");
|
|
413
|
+
fileInN2 = await uploadOk(nonAdminUserB.token, "payload-N2", "n2.bin", N2);
|
|
414
|
+
expect(readEntry(fileInN1).network_id).toBe(N1);
|
|
415
|
+
expect(readEntry(fileInN2).network_id).toBe(N2);
|
|
416
|
+
|
|
417
|
+
// Legacy entries: uploaded through the real path, then stripped of
|
|
418
|
+
// network_id so they carry the exact on-disk shape of a pre-#503
|
|
419
|
+
// blob. `delete` (not `= null`) matches the writer, which omits the
|
|
420
|
+
// key entirely when there is no attribution.
|
|
421
|
+
legacyOwnedByA = await uploadOk(nonAdminUserA.token, "payload-legacy-A", "legacy-a.bin");
|
|
422
|
+
const eA = readEntry(legacyOwnedByA);
|
|
423
|
+
delete eA.network_id;
|
|
424
|
+
writeEntry(legacyOwnedByA, eA);
|
|
425
|
+
|
|
426
|
+
legacyNullOwner = await uploadOk(nonAdminUserA.token, "payload-legacy-null", "legacy-null.bin");
|
|
427
|
+
const eN = readEntry(legacyNullOwner);
|
|
428
|
+
delete eN.network_id;
|
|
429
|
+
eN.owner = null;
|
|
430
|
+
eN.owner_id = null;
|
|
431
|
+
writeEntry(legacyNullOwner, eN);
|
|
432
|
+
|
|
433
|
+
expect("network_id" in readEntry(legacyOwnedByA)).toBe(false);
|
|
434
|
+
expect("network_id" in readEntry(legacyNullOwner)).toBe(false);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("D1: ntok_ bound to N1 reads a file in N1 → 200", async () => {
|
|
438
|
+
const res = await download(nonAdminUserA.ntok, fileInN1);
|
|
439
|
+
expect(res.status).toBe(200);
|
|
440
|
+
expect(await res.text()).toBe("payload-N1");
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("🔴 D2: ntok_ bound to N2 reads a file in N1 → 404 (the cross-network leak #503 closes)", async () => {
|
|
444
|
+
const res = await download(nonAdminUserB.ntok, fileInN1);
|
|
445
|
+
expect(res.status).toBe(404);
|
|
446
|
+
expect((await res.json() as any).error).toBe("not_found");
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test("D3: utok_ non-admin who is a member of N1 reads a file in N1 → 200 (the carve-out #495 deferred)", async () => {
|
|
450
|
+
expect(roleOf(nonAdminUserB.userId)).not.toBe("admin");
|
|
451
|
+
// userB is a same-network peer, not the owner — that distinction is
|
|
452
|
+
// the whole point of the row.
|
|
453
|
+
expect(readEntry(fileInN1).owner_id).toBe(nonAdminUserA.userId);
|
|
454
|
+
expect(readEntry(fileInN1).owner_id).not.toBe(nonAdminUserB.userId);
|
|
455
|
+
const res = await download(nonAdminUserB.token, fileInN1);
|
|
456
|
+
expect(res.status).toBe(200);
|
|
457
|
+
expect(await res.text()).toBe("payload-N1");
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("🔴 D4: utok_ non-admin who is not a member of N1 reads a file in N1 → 404", async () => {
|
|
461
|
+
expect(roleOf(outsiderUser.userId)).not.toBe("admin");
|
|
462
|
+
const res = await download(outsiderUser.token, fileInN1);
|
|
463
|
+
expect(res.status).toBe(404);
|
|
464
|
+
expect((await res.json() as any).error).toBe("not_found");
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
test("D5: utok_ non-admin with role viewer in N1 reads a file in N1 → 200 (viewer is read-only, not read-never)", async () => {
|
|
468
|
+
expect(roleOf(viewerOnlyUser.userId)).not.toBe("admin");
|
|
469
|
+
const res = await download(viewerOnlyUser.token, fileInN1);
|
|
470
|
+
expect(res.status).toBe(200);
|
|
471
|
+
expect(await res.text()).toBe("payload-N1");
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test("D6: admin utok_ reads a file in a network → 200 (operational access preserved)", async () => {
|
|
475
|
+
expect(roleOf(adminUser.userId)).toBe("admin");
|
|
476
|
+
const res = await download(adminUser.token, fileInN2);
|
|
477
|
+
expect(res.status).toBe(200);
|
|
478
|
+
expect(await res.text()).toBe("payload-N2");
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test.skipIf(!masterTokenActive)("D7: legacy master reads a file in a network → 200 (single-tenant deployments)", async () => {
|
|
482
|
+
const res = await download(MASTER_TOKEN, fileInN2);
|
|
483
|
+
expect(res.status).toBe(200);
|
|
484
|
+
expect(await res.text()).toBe("payload-N2");
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
test("D8: utok_ non-admin owner reads their own legacy (no network_id) file → 200", async () => {
|
|
488
|
+
expect(roleOf(nonAdminUserA.userId)).not.toBe("admin");
|
|
489
|
+
const res = await download(nonAdminUserA.token, legacyOwnedByA);
|
|
490
|
+
expect(res.status).toBe(200);
|
|
491
|
+
expect(await res.text()).toBe("payload-legacy-A");
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test("🔴 D9: utok_ non-admin non-owner reads someone else's legacy file → 404 (owner match did not widen)", async () => {
|
|
495
|
+
expect(roleOf(nonAdminUserB.userId)).not.toBe("admin");
|
|
496
|
+
// userB shares network N1 with the owner — under the legacy branch
|
|
497
|
+
// that must NOT be enough, or the compat path would become a hole
|
|
498
|
+
// wider than the network branch it stands in for.
|
|
499
|
+
expect(db.all("SELECT 1 FROM network_members WHERE user_id = ?1 AND network_id = ?2",
|
|
500
|
+
nonAdminUserB.userId, N1).length).toBe(1);
|
|
501
|
+
const res = await download(nonAdminUserB.token, legacyOwnedByA);
|
|
502
|
+
expect(res.status).toBe(404);
|
|
503
|
+
expect((await res.json() as any).error).toBe("not_found");
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test("D10: admin utok_ reads a legacy file → 200 (存量可读性不变)", async () => {
|
|
507
|
+
const res = await download(adminUser.token, legacyOwnedByA);
|
|
508
|
+
expect(res.status).toBe(200);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test.skipIf(!masterTokenActive)("D11: legacy master reads a legacy file → 200", async () => {
|
|
512
|
+
const res = await download(MASTER_TOKEN, legacyOwnedByA);
|
|
513
|
+
expect(res.status).toBe(200);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
test("D12: utok_ non-admin reads a null-owner legacy file with DEV_OPEN off → 404 fail-closed", async () => {
|
|
517
|
+
expect(roleOf(nonAdminUserA.userId)).not.toBe("admin");
|
|
518
|
+
expect(process.env.COMMHUB_DEV_OPEN).toBeUndefined();
|
|
519
|
+
const res = await download(nonAdminUserA.token, legacyNullOwner);
|
|
520
|
+
expect(res.status).toBe(404);
|
|
521
|
+
expect((await res.json() as any).error).toBe("not_found");
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
test.skipIf(!masterTokenActive)("D13: legacy master reads a null-owner legacy file → 200", async () => {
|
|
525
|
+
const res = await download(MASTER_TOKEN, legacyNullOwner);
|
|
526
|
+
expect(res.status).toBe(200);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
test("D14: admin utok_ reads a null-owner legacy file → 200", async () => {
|
|
530
|
+
const res = await download(adminUser.token, legacyNullOwner);
|
|
531
|
+
expect(res.status).toBe(200);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
test("🔴 D15: an ntok_ ISSUED BY AN ADMIN, bound to N1, reads a file in N2 → 404", async () => {
|
|
535
|
+
// The production shape: agent node tokens resolve to an admin user.
|
|
536
|
+
// If resolvePrincipal classified by admin-ness before boundness, this
|
|
537
|
+
// token would take the admin bypass and every node would keep its
|
|
538
|
+
// cross-network read. Assert the premise (the token's user really is
|
|
539
|
+
// admin) so a fixture drift cannot turn this into a trivial pass.
|
|
540
|
+
expect(roleOf(adminUser.userId)).toBe("admin");
|
|
541
|
+
const tokenRow = db.get<{ user_id: string; network_id: string }>(
|
|
542
|
+
"SELECT user_id, network_id FROM api_tokens WHERE name = ?1", "node:d15probe",
|
|
543
|
+
);
|
|
544
|
+
expect(tokenRow?.user_id).toBe(adminUser.userId);
|
|
545
|
+
expect(tokenRow?.network_id).toBe(N1);
|
|
546
|
+
|
|
547
|
+
const res = await download(adminNtokN1.token!, fileInN2);
|
|
548
|
+
expect(res.status).toBe(404);
|
|
549
|
+
expect((await res.json() as any).error).toBe("not_found");
|
|
550
|
+
|
|
551
|
+
// Same token in its own network still works — proves the 404 is
|
|
552
|
+
// scope, not a broken token.
|
|
553
|
+
const ok = await download(adminNtokN1.token!, fileInN1);
|
|
554
|
+
expect(ok.status).toBe(200);
|
|
555
|
+
});
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
// ── Enumeration oracle ─────────────────────────────────────────────────
|
|
559
|
+
|
|
560
|
+
describe.skipIf(!isProdMode)("#503 E — denied responses carry no enumeration signal", () => {
|
|
561
|
+
let unknownBody = "";
|
|
562
|
+
let unknownRes: Response;
|
|
563
|
+
|
|
564
|
+
beforeAll(async () => {
|
|
565
|
+
unknownRes = await download(nonAdminUserB.token, UNKNOWN_FILE_ID);
|
|
566
|
+
unknownBody = await unknownRes.text();
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
test("E1: unknown file_id → 404 with the canonical not_found body", async () => {
|
|
570
|
+
expect(unknownRes.status).toBe(404);
|
|
571
|
+
expect(JSON.parse(unknownBody)).toEqual({ ok: false, error: "not_found" });
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
test("E2: cross-network deny (D2) is byte-identical to unknown file_id", async () => {
|
|
575
|
+
const res = await download(nonAdminUserB.ntok, fileInN1);
|
|
576
|
+
expect(res.status).toBe(unknownRes.status);
|
|
577
|
+
expect(await res.text()).toBe(unknownBody);
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
test("E3: cross-network deny via utok_ (D4) is byte-identical to unknown file_id", async () => {
|
|
581
|
+
expect(roleOf(outsiderUser.userId)).not.toBe("admin");
|
|
582
|
+
const res = await download(outsiderUser.token, fileInN1);
|
|
583
|
+
expect(res.status).toBe(unknownRes.status);
|
|
584
|
+
expect(await res.text()).toBe(unknownBody);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
test("E4: corrupted index JSON is byte-identical to unknown file_id", async () => {
|
|
588
|
+
const fileId = await uploadOk(nonAdminUserA.token, "e4", "e4.bin");
|
|
589
|
+
writeFileSync(entryPath(fileId), "{not json");
|
|
590
|
+
const res = await download(nonAdminUserA.token, fileId);
|
|
591
|
+
expect(res.status).toBe(unknownRes.status);
|
|
592
|
+
expect(await res.text()).toBe(unknownBody);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
test("E5: schema-invalid index entry is byte-identical to unknown file_id", async () => {
|
|
596
|
+
const fileId = await uploadOk(nonAdminUserA.token, "e5", "e5.bin");
|
|
597
|
+
const entry = readEntry(fileId);
|
|
598
|
+
// Empty-string network_id: present but not a usable value. If
|
|
599
|
+
// validateIndexEntry stopped rejecting it, normalizeEntry would coerce
|
|
600
|
+
// it to null and the blob would silently fall back to the legacy
|
|
601
|
+
// owner-only branch — a downgrade, not a denial.
|
|
602
|
+
entry.network_id = "";
|
|
603
|
+
writeEntry(fileId, entry);
|
|
604
|
+
const res = await download(nonAdminUserA.token, fileId);
|
|
605
|
+
expect(res.status).toBe(unknownRes.status);
|
|
606
|
+
expect(await res.text()).toBe(unknownBody);
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
test("E6: HEAD on a cross-network deny matches HEAD on unknown file_id (status + headers)", async () => {
|
|
610
|
+
const denied = await head(nonAdminUserB.ntok, fileInN1);
|
|
611
|
+
const unknown = await head(nonAdminUserB.token, UNKNOWN_FILE_ID);
|
|
612
|
+
expect(denied.status).toBe(404);
|
|
613
|
+
expect(denied.status).toBe(unknown.status);
|
|
614
|
+
expect(denied.headers.get("content-type")).toBe(unknown.headers.get("content-type"));
|
|
615
|
+
expect(denied.headers.get("content-length")).toBe(unknown.headers.get("content-length"));
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
test("E7: upload to a network you are not a member of → 403 with the shared scope-guard body", async () => {
|
|
619
|
+
expect(roleOf(nonAdminUserB.userId)).not.toBe("admin");
|
|
620
|
+
const res = await upload(nonAdminUserB.token, "e7", "e7.bin", N3);
|
|
621
|
+
expect(res.status).toBe(403);
|
|
622
|
+
expect(JSON.parse(await res.text())).toEqual({ ok: false, error: "access denied to requested network" });
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
test("E8: for a non-admin, 'network exists but is not yours' and 'network does not exist' are byte-identical", async () => {
|
|
626
|
+
// The premise both halves rest on: one network really exists and the
|
|
627
|
+
// other really does not. Without this the rows could match by both
|
|
628
|
+
// being the same kind of failure.
|
|
629
|
+
expect(db.get<any>("SELECT * FROM networks WHERE network_id = ?1", N3)).toBeTruthy();
|
|
630
|
+
expect(db.get<any>("SELECT * FROM networks WHERE network_id = ?1", N_FAKE)).toBeFalsy();
|
|
631
|
+
expect(roleOf(nonAdminUserB.userId)).not.toBe("admin");
|
|
632
|
+
const notMember = await upload(nonAdminUserB.token, "e8a", "e8a.bin", N3);
|
|
633
|
+
const notExist = await upload(nonAdminUserB.token, "e8b", "e8b.bin", N_FAKE);
|
|
634
|
+
expect(notExist.status).toBe(notMember.status);
|
|
635
|
+
expect(await notExist.text()).toBe(await notMember.text());
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
test("E9: admin uploading to a nonexistent network gets the distinguishable 400 (no oracle — admin sees the list anyway)", async () => {
|
|
639
|
+
expect(roleOf(adminUser.userId)).toBe("admin");
|
|
640
|
+
const res = await upload(adminUser.token, "e9", "e9.bin", N_FAKE);
|
|
641
|
+
expect(res.status).toBe(400);
|
|
642
|
+
expect(JSON.parse(await res.text()).error).toBe("unknown_network");
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
// ── Principal enumeration ──────────────────────────────────────────────
|
|
647
|
+
//
|
|
648
|
+
// Direct unit coverage of every Principal variant against both entry
|
|
649
|
+
// shapes. `anonymous` and `dev-open-anon` are unreachable over HTTP in
|
|
650
|
+
// production mode (requireAuth 401s first), so this is the only place
|
|
651
|
+
// they can be pinned at all.
|
|
652
|
+
|
|
653
|
+
describe("#503 — authorizeFileDownload covers every Principal variant", () => {
|
|
654
|
+
const inNetwork = normalizeEntry({ owner_id: "u_owner", network_id: "net_1" });
|
|
655
|
+
const legacyOwned = normalizeEntry({ owner_id: "u_owner" });
|
|
656
|
+
const legacyNull = normalizeEntry({ owner_id: null });
|
|
657
|
+
|
|
658
|
+
const anonymous: Principal = { kind: "anonymous" };
|
|
659
|
+
const devOpenAnon: Principal = { kind: "dev-open-anon" };
|
|
660
|
+
const legacyMaster: Principal = { kind: "legacy-master" };
|
|
661
|
+
const utokOwner: Principal = { kind: "utok", userId: "u_owner", username: "owner", isAdmin: false };
|
|
662
|
+
const ntokSame: Principal = { kind: "ntok", userId: "u_node", boundNetworkId: "net_1" };
|
|
663
|
+
const ntokOther: Principal = { kind: "ntok", userId: "u_node", boundNetworkId: "net_2" };
|
|
664
|
+
const adminUtok: Principal = { kind: "admin-utok", userId: "u_admin", username: "admin" };
|
|
665
|
+
|
|
666
|
+
test("legacy-master and admin-utok bypass in both entry shapes", () => {
|
|
667
|
+
for (const entry of [inNetwork, legacyOwned, legacyNull]) {
|
|
668
|
+
expect(authorizeFileDownload(legacyMaster, entry)).toBe(true);
|
|
669
|
+
expect(authorizeFileDownload(adminUtok, entry)).toBe(true);
|
|
670
|
+
}
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
test("anonymous and dev-open-anon are denied an attributed file", () => {
|
|
674
|
+
expect(authorizeFileDownload(anonymous, inNetwork)).toBe(false);
|
|
675
|
+
expect(authorizeFileDownload(devOpenAnon, inNetwork)).toBe(false);
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
test("ntok_ is allowed only in its bound network", () => {
|
|
679
|
+
expect(authorizeFileDownload(ntokSame, inNetwork)).toBe(true);
|
|
680
|
+
expect(authorizeFileDownload(ntokOther, inNetwork)).toBe(false);
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
test("an attributed file ignores owner match — network membership is the rule", () => {
|
|
684
|
+
// utokOwner IS the owner_id on this entry but holds no role in net_1,
|
|
685
|
+
// so it must still be denied. Owner-match must not leak back in as a
|
|
686
|
+
// second, weaker path around the network gate.
|
|
687
|
+
expect(inNetwork.ownerId).toBe("u_owner");
|
|
688
|
+
expect(authorizeFileDownload(utokOwner, inNetwork)).toBe(false);
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
test("legacy entries keep the pre-#503 owner-match rule", () => {
|
|
692
|
+
expect(authorizeFileDownload(utokOwner, legacyOwned)).toBe(true);
|
|
693
|
+
const other: Principal = { kind: "utok", userId: "u_other", username: "other", isAdmin: false };
|
|
694
|
+
expect(authorizeFileDownload(other, legacyOwned)).toBe(false);
|
|
695
|
+
expect(authorizeFileDownload(other, legacyNull)).toBe(false);
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
test("normalizeEntry is the only place on-disk values are narrowed", () => {
|
|
699
|
+
expect(normalizeEntry({ owner_id: null, network_id: undefined })).toEqual({ ownerId: null, networkId: null });
|
|
700
|
+
expect(normalizeEntry({ owner_id: "", network_id: "" })).toEqual({ ownerId: null, networkId: null });
|
|
701
|
+
expect(normalizeEntry({ owner_id: 42, network_id: { evil: true } } as any)).toEqual({ ownerId: null, networkId: null });
|
|
702
|
+
expect(normalizeEntry({ owner_id: "u1", network_id: "n1" })).toEqual({ ownerId: "u1", networkId: "n1" });
|
|
703
|
+
});
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
// ── #509 regression under network scope ────────────────────────────────
|
|
707
|
+
|
|
708
|
+
describe.skipIf(!isProdMode)("#503 — network scope does not regress cross-day downloads (#509)", () => {
|
|
709
|
+
test("a file indexed under a past date_bucket is still readable by its network", async () => {
|
|
710
|
+
const fileId = await uploadOk(nonAdminUserA.token, "yesterday-payload", "yd.bin");
|
|
711
|
+
const entry = readEntry(fileId);
|
|
712
|
+
expect(entry.network_id).toBe(N1);
|
|
713
|
+
|
|
714
|
+
// Move the blob and its index entry into a past bucket, mirroring a
|
|
715
|
+
// file uploaded before midnight and read after. The read path must
|
|
716
|
+
// use the indexed bucket, not today's.
|
|
717
|
+
const past = "2026-01-02";
|
|
718
|
+
const from = join(UPLOADS_DIR, entry.date_bucket, `${fileId}${entry.ext}`);
|
|
719
|
+
const to = join(UPLOADS_DIR, past, `${fileId}${entry.ext}`);
|
|
720
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
721
|
+
writeFileSync(to, readFileSync(from));
|
|
722
|
+
rmSync(from, { force: true });
|
|
723
|
+
entry.date_bucket = past;
|
|
724
|
+
writeEntry(fileId, entry);
|
|
725
|
+
expect(existsSync(to)).toBe(true);
|
|
726
|
+
|
|
727
|
+
const res = await download(nonAdminUserA.ntok, fileId);
|
|
728
|
+
expect(res.status).toBe(200);
|
|
729
|
+
expect(await res.text()).toBe("yesterday-payload");
|
|
730
|
+
});
|
|
731
|
+
});
|