@sleep2agi/commhub-server 0.9.0-preview.3 → 0.9.0-preview.31
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 +125 -7
- package/bin/commhub.ts +10 -2
- package/package.json +5 -5
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +331 -0
- package/src/admin-networks-http.test.ts +84 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -0
- package/src/api-host-supervisors-fallback.test.ts +267 -0
- package/src/api-nodes-shape.test.ts +188 -0
- package/src/auth.ts +93 -14
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/boot-side-effect.test.ts +124 -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.ts +48 -1
- package/src/dashboard-slash-routing-http.test.ts +103 -0
- package/src/db-adapter-guard.test.ts +85 -0
- package/src/db-adapter.ts +64 -23
- package/src/db.ts +522 -32
- 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 +316 -0
- package/src/external-schedule-edits.ts +314 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +403 -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 +731 -0
- package/src/health-redaction.test.ts +637 -0
- package/src/index.ts +15 -2172
- package/src/lifecycle-guard.ts +50 -0
- package/src/list-host-supervisors.test.ts +314 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/network-name-http.test.ts +71 -0
- package/src/network-scope.ts +101 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +213 -0
- package/src/observer-avatar-http.test.ts +349 -0
- package/src/observer-push.test.ts +176 -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/probe-validate.test.ts +221 -0
- package/src/probe-validate.ts +104 -0
- package/src/probe.test.ts +80 -0
- package/src/probe.ts +200 -0
- package/src/push.ts +96 -7
- package/src/require-explicit-test-db.ts +9 -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 +211 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +67 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-tasks-http.test.ts +479 -0
- package/src/scheduled-tasks.ts +549 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-attachments.test.ts +437 -0
- package/src/server.ts +3508 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
- package/src/shared/probe-host-allowlist.ts +128 -0
- package/src/side-thread-command-transport.test.ts +302 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +229 -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 +2495 -0
- package/src/skillhub-http.test.ts +88 -0
- package/src/stop-delete-node.test.ts +720 -0
- package/src/task-auth-origin-http.test.ts +87 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +398 -0
- package/src/task-diagnostic-http.test.ts +145 -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 +200 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +137 -0
- package/src/tasks-pagination-http.test.ts +137 -0
- package/src/tools.ts +2312 -169
- package/src/update-provider.test.ts +234 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +92 -14
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +130 -5
- package/src/vault.test.ts +200 -0
- package/src/vault.ts +156 -0
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
// #509 — file download must use the date_bucket stored in the index
|
|
2
|
+
// entry, NOT today's date computed from the runtime clock.
|
|
3
|
+
//
|
|
4
|
+
// Bug: server.ts:1793 called `buildStoragePath(entry.file_id, entry.ext)`
|
|
5
|
+
// without passing a bucket. buildStoragePath internally calls
|
|
6
|
+
// getDateBucket(opts.now) which defaults to `new Date()` → today. Any
|
|
7
|
+
// file uploaded on a previous day would look under today's directory
|
|
8
|
+
// and return 404 blob_missing, even though authorization passed and
|
|
9
|
+
// the blob was still on disk in yesterday's directory.
|
|
10
|
+
//
|
|
11
|
+
// The 6 layers of pre-release verification (byte-for-byte review, two
|
|
12
|
+
// independent audits, dev-confirm, two validator container matrices,
|
|
13
|
+
// pre-release smoke) all uploaded + downloaded within the same UTC day,
|
|
14
|
+
// so the paths coincidentally matched. See project memory
|
|
15
|
+
// `feedback_time_based_storage_tests_must_inject_time`.
|
|
16
|
+
//
|
|
17
|
+
// This suite constructs fixture entries with hard-coded past date
|
|
18
|
+
// buckets and matching on-disk blobs; the runtime clock is never
|
|
19
|
+
// advanced. Mutation self-check: reverting the download path to use
|
|
20
|
+
// buildStoragePath (which computes today) must turn Door 1 tests red.
|
|
21
|
+
//
|
|
22
|
+
// Cross-lane note: This is orthogonal to #495/#500/#505 (authz).
|
|
23
|
+
// Reuses the utok_ + admin fixture pattern from file-download-authz
|
|
24
|
+
// but constructs entries by writing the .index JSON + blob directly
|
|
25
|
+
// so we can pin the date_bucket without waiting for real time.
|
|
26
|
+
|
|
27
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
28
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from "fs";
|
|
29
|
+
import { join } from "path";
|
|
30
|
+
import { tmpdir } from "os";
|
|
31
|
+
|
|
32
|
+
const SERVER_DB = mkdtempSync(join(tmpdir(), "anet-509-db-")) + "/commhub.db";
|
|
33
|
+
const UPLOADS_DIR = mkdtempSync(join(tmpdir(), "anet-509-fs-"));
|
|
34
|
+
|
|
35
|
+
// Set env BEFORE importing anything that pulls in db-adapter.ts or
|
|
36
|
+
// server.ts (which capture 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
|
+
delete process.env.COMMHUB_DEV_OPEN;
|
|
41
|
+
|
|
42
|
+
const { register } = await import("./auth.js");
|
|
43
|
+
const { db } = await import("./db.js");
|
|
44
|
+
|
|
45
|
+
// Register an admin FIRST to consume the "first-user-auto-admin" slot,
|
|
46
|
+
// then register userA as a regular user. Prior CR rounds landed the
|
|
47
|
+
// fixture-drift guard as a canonical pattern (see project memory
|
|
48
|
+
// `feedback_first_registered_user_is_admin_fake_green`).
|
|
49
|
+
const seedName = `seed509_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
|
50
|
+
const rSeed = register(seedName, "BootstrapPw123Aa!", undefined, "seed509");
|
|
51
|
+
if (!rSeed.ok) throw new Error(`seed509 register failed: ${rSeed.error}`);
|
|
52
|
+
// Also promote via SQL in case the auto-admin heuristic changes.
|
|
53
|
+
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [rSeed.user!.user_id]);
|
|
54
|
+
|
|
55
|
+
const nameA = `user509a_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
|
56
|
+
const rA = register(nameA, "BootstrapPw123Aa!", undefined, "user509A");
|
|
57
|
+
if (!rA.ok) throw new Error(`user509A register failed: ${rA.error}`);
|
|
58
|
+
const userAToken = rA.token!;
|
|
59
|
+
const userAUserId = rA.user!.user_id;
|
|
60
|
+
|
|
61
|
+
// #500 CR2 fixture-drift guard — assert userA is NOT admin so tests
|
|
62
|
+
// exercise the regular utok_ path, not the admin bypass.
|
|
63
|
+
{
|
|
64
|
+
const row = db.get<{ role: string }>("SELECT role FROM users WHERE user_id = ?1", userAUserId);
|
|
65
|
+
if (!row) throw new Error("userA missing after register");
|
|
66
|
+
if (row.role === "admin") {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`[#509 fixture-drift guard] userA was auto-promoted to admin ` +
|
|
69
|
+
`(role=${row.role}). Refusing to run: download tests would ` +
|
|
70
|
+
`short-circuit through the admin allow-list and validate nothing.`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const { bootServer } = await import("./server.js");
|
|
76
|
+
const server = bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
77
|
+
const BASE = `http://127.0.0.1:${server.port}`;
|
|
78
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
79
|
+
|
|
80
|
+
async function _uploadProbe(): Promise<number> {
|
|
81
|
+
const form = new FormData();
|
|
82
|
+
form.append("file", new Blob([new TextEncoder().encode("p")], { type: "application/octet-stream" }), "p");
|
|
83
|
+
const r = await fetch(`${BASE}/api/upload`, { method: "POST", body: form });
|
|
84
|
+
return r.status;
|
|
85
|
+
}
|
|
86
|
+
const isProdMode = (await _uploadProbe()) !== 200;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 🔴 Precondition, deliberately OUTSIDE every skipIf.
|
|
90
|
+
*
|
|
91
|
+
* The whole #509 suite below hangs off `describe.skipIf(!isProdMode)`, and
|
|
92
|
+
* `isProdMode` is decided by a *live probe* rather than a constant. If the
|
|
93
|
+
* probe comes back 200 (DEV_OPEN active in this environment), every Door
|
|
94
|
+
* test silently disappears and the file still reports green — a validator
|
|
95
|
+
* running it in a differently-configured container would report PASS while
|
|
96
|
+
* having exercised none of the #509 read path.
|
|
97
|
+
*
|
|
98
|
+
* Unlike the master-token / dev-open conditionals elsewhere in this repo,
|
|
99
|
+
* #509 has no dev-open counterpart suite: skipping here means the coverage
|
|
100
|
+
* is simply absent, not relocated. So an unrunnable suite must fail loudly
|
|
101
|
+
* and say why, instead of vanishing quietly.
|
|
102
|
+
*/
|
|
103
|
+
test("precondition: #509 suite is actually runnable (not silently skipped)", () => {
|
|
104
|
+
expect(isProdMode).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
afterAll(() => {
|
|
108
|
+
try { server?.stop?.(true); } catch {}
|
|
109
|
+
try { rmSync(UPLOADS_DIR, { recursive: true, force: true }); } catch {}
|
|
110
|
+
try { rmSync(SERVER_DB, { recursive: true, force: true }); } catch {}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Manually construct an index entry + matching blob at a caller-chosen
|
|
115
|
+
* date bucket. This lets Door 1 tests pin "yesterday" without waiting
|
|
116
|
+
* for real time — the whole point of #509's fix is that read path must
|
|
117
|
+
* respect index-recorded bucket, so we test by placing a blob in a
|
|
118
|
+
* bucket that is NOT today.
|
|
119
|
+
*/
|
|
120
|
+
function plantFileAtBucket(opts: {
|
|
121
|
+
ownerToken: string;
|
|
122
|
+
ownerUserId: string;
|
|
123
|
+
dateBucket: string; // "YYYY-MM-DD" — put blob HERE
|
|
124
|
+
content: Uint8Array;
|
|
125
|
+
ext?: string; // default ".txt"
|
|
126
|
+
name?: string; // metadata only
|
|
127
|
+
}): string {
|
|
128
|
+
const ext = opts.ext ?? ".txt";
|
|
129
|
+
const fileId = require("crypto").randomUUID().replace(/-/g, "");
|
|
130
|
+
const bucketDir = join(UPLOADS_DIR, opts.dateBucket);
|
|
131
|
+
mkdirSync(bucketDir, { recursive: true });
|
|
132
|
+
writeFileSync(join(bucketDir, fileId + ext), opts.content);
|
|
133
|
+
|
|
134
|
+
const indexDir = join(UPLOADS_DIR, ".index");
|
|
135
|
+
mkdirSync(indexDir, { recursive: true });
|
|
136
|
+
const entry = {
|
|
137
|
+
file_id: fileId,
|
|
138
|
+
date_bucket: opts.dateBucket,
|
|
139
|
+
ext,
|
|
140
|
+
name: opts.name ?? "planted.txt",
|
|
141
|
+
mime: "application/octet-stream",
|
|
142
|
+
size: opts.content.length,
|
|
143
|
+
owner: opts.ownerUserId,
|
|
144
|
+
owner_id: opts.ownerUserId,
|
|
145
|
+
uploaded_at: new Date().toISOString(),
|
|
146
|
+
};
|
|
147
|
+
writeFileSync(join(indexDir, fileId + ".json"), JSON.stringify(entry, null, 2));
|
|
148
|
+
return fileId;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function downloadAs(token: string, fileId: string, extraHeaders: Record<string, string> = {}): Promise<Response> {
|
|
152
|
+
return fetch(`${BASE}/api/files/${fileId}`, {
|
|
153
|
+
method: "GET",
|
|
154
|
+
headers: { Authorization: `Bearer ${token}`, ...extraHeaders },
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function headAs(token: string, fileId: string, extraHeaders: Record<string, string> = {}): Promise<Response> {
|
|
159
|
+
return fetch(`${BASE}/api/files/${fileId}`, {
|
|
160
|
+
method: "HEAD",
|
|
161
|
+
headers: { Authorization: `Bearer ${token}`, ...extraHeaders },
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Yesterday, three-days-ago (relative to test-run time). Fully static
|
|
166
|
+
// dates would rot; computing offsets keeps the test durable while
|
|
167
|
+
// still isolating the "yesterday" case from today.
|
|
168
|
+
const _pad = (n: number) => String(n).padStart(2, "0");
|
|
169
|
+
function bucketNDaysAgo(n: number): string {
|
|
170
|
+
const d = new Date();
|
|
171
|
+
d.setUTCDate(d.getUTCDate() - n);
|
|
172
|
+
return `${d.getUTCFullYear()}-${_pad(d.getUTCMonth() + 1)}-${_pad(d.getUTCDate())}`;
|
|
173
|
+
}
|
|
174
|
+
const BUCKET_YESTERDAY = bucketNDaysAgo(1);
|
|
175
|
+
const BUCKET_3_DAYS_AGO = bucketNDaysAgo(3);
|
|
176
|
+
const BUCKET_TODAY = bucketNDaysAgo(0);
|
|
177
|
+
|
|
178
|
+
describe.skipIf(!isProdMode)("#509 — download uses index date_bucket, not today's date", () => {
|
|
179
|
+
test("Door 1: yesterday's file downloads today → 200 (was 404 blob_missing pre-fix)", async () => {
|
|
180
|
+
const content = new TextEncoder().encode("blob-from-yesterday");
|
|
181
|
+
const fileId = plantFileAtBucket({
|
|
182
|
+
ownerToken: userAToken,
|
|
183
|
+
ownerUserId: userAUserId,
|
|
184
|
+
dateBucket: BUCKET_YESTERDAY,
|
|
185
|
+
content,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Precondition: blob is REALLY in yesterday's bucket, not today's.
|
|
189
|
+
// If both existed the test would be indistinguishable from a
|
|
190
|
+
// buildStoragePath-uses-today bug regression.
|
|
191
|
+
expect(existsSync(join(UPLOADS_DIR, BUCKET_YESTERDAY, fileId + ".txt"))).toBe(true);
|
|
192
|
+
expect(existsSync(join(UPLOADS_DIR, BUCKET_TODAY, fileId + ".txt"))).toBe(false);
|
|
193
|
+
|
|
194
|
+
const res = await downloadAs(userAToken, fileId);
|
|
195
|
+
expect(res.status).toBe(200);
|
|
196
|
+
expect(await res.text()).toBe("blob-from-yesterday");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("Door 1 (further past): 3-days-ago file downloads today → 200", async () => {
|
|
200
|
+
const content = new TextEncoder().encode("blob-from-3-days-ago");
|
|
201
|
+
const fileId = plantFileAtBucket({
|
|
202
|
+
ownerToken: userAToken,
|
|
203
|
+
ownerUserId: userAUserId,
|
|
204
|
+
dateBucket: BUCKET_3_DAYS_AGO,
|
|
205
|
+
content,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const res = await downloadAs(userAToken, fileId);
|
|
209
|
+
expect(res.status).toBe(200);
|
|
210
|
+
expect(await res.text()).toBe("blob-from-3-days-ago");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("Door 2 regression: today's file downloads today → 200 (unchanged behavior)", async () => {
|
|
214
|
+
const content = new TextEncoder().encode("blob-from-today");
|
|
215
|
+
const fileId = plantFileAtBucket({
|
|
216
|
+
ownerToken: userAToken,
|
|
217
|
+
ownerUserId: userAUserId,
|
|
218
|
+
dateBucket: BUCKET_TODAY,
|
|
219
|
+
content,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const res = await downloadAs(userAToken, fileId);
|
|
223
|
+
expect(res.status).toBe(200);
|
|
224
|
+
expect(await res.text()).toBe("blob-from-today");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("Door 3 GET: yesterday's file via GET → 200 with correct body", async () => {
|
|
228
|
+
const content = new TextEncoder().encode("GET-cross-day");
|
|
229
|
+
const fileId = plantFileAtBucket({
|
|
230
|
+
ownerToken: userAToken,
|
|
231
|
+
ownerUserId: userAUserId,
|
|
232
|
+
dateBucket: BUCKET_YESTERDAY,
|
|
233
|
+
content,
|
|
234
|
+
});
|
|
235
|
+
const res = await downloadAs(userAToken, fileId);
|
|
236
|
+
expect(res.status).toBe(200);
|
|
237
|
+
expect(await res.text()).toBe("GET-cross-day");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("Door 3 HEAD: yesterday's file via HEAD → 200 with Content-Length, no body", async () => {
|
|
241
|
+
const content = new TextEncoder().encode("HEAD-cross-day-payload");
|
|
242
|
+
const fileId = plantFileAtBucket({
|
|
243
|
+
ownerToken: userAToken,
|
|
244
|
+
ownerUserId: userAUserId,
|
|
245
|
+
dateBucket: BUCKET_YESTERDAY,
|
|
246
|
+
content,
|
|
247
|
+
});
|
|
248
|
+
const res = await headAs(userAToken, fileId);
|
|
249
|
+
expect(res.status).toBe(200);
|
|
250
|
+
expect(res.headers.get("Content-Length")).toBe(String(content.length));
|
|
251
|
+
// HEAD must not return a body — Bun exposes the stream but reads to
|
|
252
|
+
// 0 bytes. Some HTTP stacks return no ReadableStream at all; either
|
|
253
|
+
// is acceptable per RFC 9110 §9.3.2 — check size, not presence.
|
|
254
|
+
const buf = await res.arrayBuffer();
|
|
255
|
+
expect(buf.byteLength).toBe(0);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("Door 3 Range: yesterday's file with Range header → data served from correct bucket", async () => {
|
|
259
|
+
const content = new TextEncoder().encode("0123456789ABCDEFGHIJ");
|
|
260
|
+
const fileId = plantFileAtBucket({
|
|
261
|
+
ownerToken: userAToken,
|
|
262
|
+
ownerUserId: userAUserId,
|
|
263
|
+
dateBucket: BUCKET_YESTERDAY,
|
|
264
|
+
content,
|
|
265
|
+
});
|
|
266
|
+
// #514: Range is now handled in application code, not delegated to Bun.
|
|
267
|
+
// That makes 206 the only valid result across Bun versions; accepting 200
|
|
268
|
+
// here would re-open the runtime-dependent behavior this test guards.
|
|
269
|
+
const res = await fetch(`${BASE}/api/files/${fileId}`, {
|
|
270
|
+
method: "GET",
|
|
271
|
+
headers: { Authorization: `Bearer ${userAToken}`, Range: "bytes=0-4" },
|
|
272
|
+
});
|
|
273
|
+
expect(res.status).toBe(206);
|
|
274
|
+
expect(await res.text()).toBe("01234");
|
|
275
|
+
expect(res.headers.get("content-range")).toBe(`bytes 0-4/${content.byteLength}`);
|
|
276
|
+
expect(res.headers.get("content-length")).toBe("5");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("Door 3 conditional: yesterday's file with If-None-Match → served or 304, never 404", async () => {
|
|
280
|
+
const content = new TextEncoder().encode("cond-cross-day");
|
|
281
|
+
const fileId = plantFileAtBucket({
|
|
282
|
+
ownerToken: userAToken,
|
|
283
|
+
ownerUserId: userAUserId,
|
|
284
|
+
dateBucket: BUCKET_YESTERDAY,
|
|
285
|
+
content,
|
|
286
|
+
});
|
|
287
|
+
const res = await downloadAs(userAToken, fileId, { "If-None-Match": '"never-matches-509"' });
|
|
288
|
+
expect([200, 304]).toContain(res.status);
|
|
289
|
+
expect(res.status).not.toBe(404);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("Door 4: existing bucket-recorded blob is immediately readable without migration", async () => {
|
|
293
|
+
// Simulate the scenario Vincent hit: yesterday's PPT still on disk
|
|
294
|
+
// in yesterday's bucket, index intact — no re-upload, no data move,
|
|
295
|
+
// fix alone should make it readable.
|
|
296
|
+
const yesterdayBlob = new TextEncoder().encode("Vincents-actual-PPT-payload");
|
|
297
|
+
const fileId = plantFileAtBucket({
|
|
298
|
+
ownerToken: userAToken,
|
|
299
|
+
ownerUserId: userAUserId,
|
|
300
|
+
dateBucket: BUCKET_YESTERDAY,
|
|
301
|
+
content: yesterdayBlob,
|
|
302
|
+
ext: ".pptx",
|
|
303
|
+
name: "presentation.pptx",
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// The blob file was NEVER moved after "plantFileAtBucket" wrote it
|
|
307
|
+
// to yesterday's bucket. Post-fix, the server must find it there.
|
|
308
|
+
const res = await downloadAs(userAToken, fileId);
|
|
309
|
+
expect(res.status).toBe(200);
|
|
310
|
+
expect(new Uint8Array(await res.arrayBuffer())).toEqual(yesterdayBlob);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("Item 3 (副指挥 b1082017) — server TZ / UTC-boundary: entry bucket is authoritative regardless of runtime clock", async () => {
|
|
314
|
+
// 副指挥 7 硬门 ③: "覆盖服务时区与 UTC 日期边界".
|
|
315
|
+
//
|
|
316
|
+
// The read path is now structurally no-clock: pathForExistingBlob
|
|
317
|
+
// takes only the stored dateBucket, calls no getDateBucket / new
|
|
318
|
+
// Date() / Date.now(). This test pins that invariant with an
|
|
319
|
+
// explicit UTC-boundary scenario rather than leaving it as
|
|
320
|
+
// structural reasoning.
|
|
321
|
+
//
|
|
322
|
+
// Scenario: an entry recorded on 2026-07-29 (a hard-coded UTC
|
|
323
|
+
// date, unrelated to today's runtime clock). Even if the server
|
|
324
|
+
// process's runtime clock is anywhere in 2026-07-30..2026-08-01
|
|
325
|
+
// (i.e. one, two, or more midnights past the recorded bucket),
|
|
326
|
+
// the download must still resolve to the stored bucket path.
|
|
327
|
+
//
|
|
328
|
+
// The fixture uses a hard-coded date string (not offset from
|
|
329
|
+
// today) so the assertion is anchored on the invariant, not on
|
|
330
|
+
// "wall-clock happens to differ by exactly N days". If someone
|
|
331
|
+
// future-dates the runtime environment, this test still passes
|
|
332
|
+
// because the read path never consults the clock.
|
|
333
|
+
const hardcodedPastBucket = "2026-07-29";
|
|
334
|
+
const content = new TextEncoder().encode("tz-boundary-payload");
|
|
335
|
+
const fileId = plantFileAtBucket({
|
|
336
|
+
ownerToken: userAToken,
|
|
337
|
+
ownerUserId: userAUserId,
|
|
338
|
+
dateBucket: hardcodedPastBucket,
|
|
339
|
+
content,
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// Precondition: blob is at the hard-coded bucket, NOT at any
|
|
343
|
+
// date derivable from `new Date()` at test time. If both paths
|
|
344
|
+
// happened to exist the test would not distinguish a
|
|
345
|
+
// stored-bucket read from a runtime-computed one.
|
|
346
|
+
expect(existsSync(join(UPLOADS_DIR, hardcodedPastBucket, fileId + ".txt"))).toBe(true);
|
|
347
|
+
// If today happens to be exactly 2026-07-29 (rare but possible
|
|
348
|
+
// when re-running the fixture), the today-bucket path exists too;
|
|
349
|
+
// the invariant we care about is the read succeeds because of the
|
|
350
|
+
// stored bucket, not because of a runtime coincidence.
|
|
351
|
+
|
|
352
|
+
const res = await downloadAs(userAToken, fileId);
|
|
353
|
+
expect(res.status).toBe(200);
|
|
354
|
+
expect(await res.text()).toBe("tz-boundary-payload");
|
|
355
|
+
|
|
356
|
+
// Additional invariant grep: the fixture's stored bucket is the
|
|
357
|
+
// ONLY bucket the read path should have visited. We can't observe
|
|
358
|
+
// the exact filesystem access here without instrumenting, but the
|
|
359
|
+
// structural property is enforced by pathForExistingBlob's API
|
|
360
|
+
// (no `now` parameter, no Date import). Combined with Door 1
|
|
361
|
+
// (yesterday-not-today) and this hard-coded case, the intent is
|
|
362
|
+
// pinned in tests, not just in the function name.
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// Helper: plant an index entry with a poisoned/invalid date_bucket.
|
|
366
|
+
// Blob file is optional — the point is the bucket string itself.
|
|
367
|
+
function plantPoisonedBucketEntry(dateBucket: unknown): string {
|
|
368
|
+
const fileId = require("crypto").randomUUID().replace(/-/g, "");
|
|
369
|
+
const indexDir = join(UPLOADS_DIR, ".index");
|
|
370
|
+
mkdirSync(indexDir, { recursive: true });
|
|
371
|
+
const entry = {
|
|
372
|
+
file_id: fileId,
|
|
373
|
+
date_bucket: dateBucket,
|
|
374
|
+
ext: ".txt",
|
|
375
|
+
name: "poison.txt",
|
|
376
|
+
mime: "application/octet-stream",
|
|
377
|
+
size: 4,
|
|
378
|
+
owner: userAUserId,
|
|
379
|
+
owner_id: userAUserId,
|
|
380
|
+
uploaded_at: new Date().toISOString(),
|
|
381
|
+
};
|
|
382
|
+
writeFileSync(join(indexDir, fileId + ".json"), JSON.stringify(entry));
|
|
383
|
+
return fileId;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// 副指挥 66983a19 hard-door: 稳定 404 非 500 for
|
|
387
|
+
// • path-escape poisoned buckets ("../etc", "..\\etc", etc.)
|
|
388
|
+
// • non-calendar buckets ("2026-02-30", "2026-13-01", ...)
|
|
389
|
+
// • shape-only-valid but semantically impossible dates
|
|
390
|
+
// 500 says "server failed to handle input"; 404 says "input refused
|
|
391
|
+
// at the boundary". The latter is a stronger, more auditable posture.
|
|
392
|
+
const invalidBuckets: Array<[string, unknown]> = [
|
|
393
|
+
["path-escape ../etc", "../etc"],
|
|
394
|
+
["path-escape .. only", ".."],
|
|
395
|
+
["backslash traversal", "..\\etc"],
|
|
396
|
+
["contains null byte", "date"],
|
|
397
|
+
["contains slash", "2026/07/29"],
|
|
398
|
+
["empty string", ""],
|
|
399
|
+
["all-nines shape but not calendar", "9999-99-99"],
|
|
400
|
+
["month 13 (impossible)", "2026-13-01"],
|
|
401
|
+
["month 00 (impossible)", "2026-00-15"],
|
|
402
|
+
["day 30 in February 2026 (non-leap-year rejected)", "2026-02-30"],
|
|
403
|
+
["day 32 (impossible)", "2026-07-32"],
|
|
404
|
+
["Feb 29 on non-leap year", "2025-02-29"],
|
|
405
|
+
];
|
|
406
|
+
|
|
407
|
+
for (const [label, badBucket] of invalidBuckets) {
|
|
408
|
+
test(`Refinement (副指挥 66983a19) — invalid bucket "${label}" → 404 (not 500)`, async () => {
|
|
409
|
+
const fileId = plantPoisonedBucketEntry(badBucket);
|
|
410
|
+
const res = await downloadAs(userAToken, fileId);
|
|
411
|
+
// 稳定 404: pre-check via isValidCalendarBucket rejects at the
|
|
412
|
+
// boundary. 500 would signal a caught throw from deeper code —
|
|
413
|
+
// that is exactly the shape we're refusing to accept per副指挥
|
|
414
|
+
// b1082017 (可靠性差很远).
|
|
415
|
+
expect(res.status).toBe(404);
|
|
416
|
+
const body: any = await res.json();
|
|
417
|
+
expect(body.error).toBe("not_found");
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
test("Refinement (副指挥 66983a19) — valid Feb 29 on ACTUAL leap year 2024 is accepted (not over-rejected)", async () => {
|
|
422
|
+
// Sanity: the calendar validator must not accidentally reject
|
|
423
|
+
// legitimate leap-day buckets from a real leap year. Constructs
|
|
424
|
+
// an entry at 2024-02-29 (valid), blob at that path, downloads it.
|
|
425
|
+
const content = new TextEncoder().encode("leap-day-blob");
|
|
426
|
+
const fileId = plantFileAtBucket({
|
|
427
|
+
ownerToken: userAToken,
|
|
428
|
+
ownerUserId: userAUserId,
|
|
429
|
+
dateBucket: "2024-02-29",
|
|
430
|
+
content,
|
|
431
|
+
});
|
|
432
|
+
const res = await downloadAs(userAToken, fileId);
|
|
433
|
+
expect(res.status).toBe(200);
|
|
434
|
+
expect(await res.text()).toBe("leap-day-blob");
|
|
435
|
+
});
|
|
436
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// #503 — the DEV_OPEN half of the upload attribution matrix (row U3).
|
|
2
|
+
//
|
|
3
|
+
// Separate file for the same reason #495/#500 split theirs: DEV_OPEN is
|
|
4
|
+
// frozen at server.ts module-load time, so it cannot be flipped inside a
|
|
5
|
+
// process that already loaded the module with DEV_OPEN=false.
|
|
6
|
+
//
|
|
7
|
+
// The row that matters: an unauthenticated DEV_OPEN caller must NOT be
|
|
8
|
+
// able to file a blob into a real tenant network, even by asking for one
|
|
9
|
+
// via ?network_id=. The upload succeeds (that is the point of dev-open
|
|
10
|
+
// mode) but lands unattributed, and "unattributed" must be the key being
|
|
11
|
+
// ABSENT rather than written as null — the writer and validateIndexEntry
|
|
12
|
+
// have to agree on one shape or a dev-mode upload would be unreadable by
|
|
13
|
+
// its own server.
|
|
14
|
+
|
|
15
|
+
import { describe, expect, test, afterAll } from "bun:test";
|
|
16
|
+
import { mkdtempSync, rmSync, readFileSync, existsSync } from "fs";
|
|
17
|
+
import { join } from "path";
|
|
18
|
+
import { tmpdir } from "os";
|
|
19
|
+
|
|
20
|
+
const SERVER_DB = mkdtempSync(join(tmpdir(), "anet-503-devopen-db-")) + "/commhub.db";
|
|
21
|
+
const UPLOADS_DIR = mkdtempSync(join(tmpdir(), "anet-503-devopen-fs-"));
|
|
22
|
+
|
|
23
|
+
process.env.COMMHUB_DB ||= SERVER_DB;
|
|
24
|
+
process.env.COMMHUB_UPLOADS_DIR = UPLOADS_DIR;
|
|
25
|
+
process.env.HOST = "127.0.0.1";
|
|
26
|
+
process.env.COMMHUB_DEV_OPEN = "1";
|
|
27
|
+
delete process.env.COMMHUB_AUTH_TOKEN;
|
|
28
|
+
|
|
29
|
+
const { register } = await import("./auth.js");
|
|
30
|
+
|
|
31
|
+
const stamp = `${Date.now()}${Math.floor(Math.random() * 100000)}`;
|
|
32
|
+
const seed = register(`u503dorealnet${stamp}`, "BootstrapPw123Aa!", undefined, "seed");
|
|
33
|
+
if (!seed.ok) throw new Error(`seed register failed: ${seed.error}`);
|
|
34
|
+
const REAL_NETWORK_ID = seed.network_id!;
|
|
35
|
+
|
|
36
|
+
const { bootServer } = await import("./server.js");
|
|
37
|
+
const server = bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
38
|
+
const BASE = `http://127.0.0.1:${server.port}`;
|
|
39
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
40
|
+
|
|
41
|
+
async function uploadAnon(body: string, filename: string, networkId?: string): Promise<Response> {
|
|
42
|
+
const form = new FormData();
|
|
43
|
+
form.append("file", new Blob([new TextEncoder().encode(body)], { type: "application/octet-stream" }), filename);
|
|
44
|
+
const url = networkId
|
|
45
|
+
? `${BASE}/api/upload?network_id=${encodeURIComponent(networkId)}`
|
|
46
|
+
: `${BASE}/api/upload`;
|
|
47
|
+
return fetch(url, { method: "POST", body: form });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Probe whether the loaded module really has DEV_OPEN on. In an aggregate
|
|
51
|
+
// run a sibling may have loaded server.ts first; `skipIf` then reports
|
|
52
|
+
// "skipped" rather than a green that measured the wrong build.
|
|
53
|
+
const devOpenActive = (await uploadAnon("probe", "probe.bin")).status === 200;
|
|
54
|
+
|
|
55
|
+
afterAll(() => {
|
|
56
|
+
try { server?.stop?.(true); } catch {}
|
|
57
|
+
try { rmSync(UPLOADS_DIR, { recursive: true, force: true }); } catch {}
|
|
58
|
+
try { rmSync(SERVER_DB, { recursive: true, force: true }); } catch {}
|
|
59
|
+
delete process.env.COMMHUB_DEV_OPEN;
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
function readEntry(fileId: string): any {
|
|
63
|
+
return JSON.parse(readFileSync(join(UPLOADS_DIR, ".index", `${fileId}.json`), "utf-8"));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe.skipIf(!devOpenActive)("#503 U3 — DEV_OPEN anonymous uploads are never attributed to a network", () => {
|
|
67
|
+
test("U3: anonymous DEV_OPEN upload asking for a REAL network → 200, but the network_id key is absent", async () => {
|
|
68
|
+
// The requested network genuinely exists, so an absent key proves the
|
|
69
|
+
// request was refused attribution, not that the lookup missed.
|
|
70
|
+
const { db } = await import("./db.js");
|
|
71
|
+
expect(db.get<any>("SELECT * FROM networks WHERE network_id = ?1", REAL_NETWORK_ID)).toBeTruthy();
|
|
72
|
+
|
|
73
|
+
const res = await uploadAnon("dev-open-payload", "u3.bin", REAL_NETWORK_ID);
|
|
74
|
+
expect(res.status).toBe(200);
|
|
75
|
+
const fileId = (await res.json() as any).file_id;
|
|
76
|
+
|
|
77
|
+
const entry = readEntry(fileId);
|
|
78
|
+
expect("network_id" in entry).toBe(false);
|
|
79
|
+
expect(entry.network_id).toBeUndefined();
|
|
80
|
+
expect(entry.owner_id).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("U3b: an unattributed DEV_OPEN blob is still readable by its own server (writer and validator agree)", async () => {
|
|
84
|
+
const res = await uploadAnon("round-trip", "u3b.bin");
|
|
85
|
+
expect(res.status).toBe(200);
|
|
86
|
+
const fileId = (await res.json() as any).file_id;
|
|
87
|
+
expect("network_id" in readEntry(fileId)).toBe(false);
|
|
88
|
+
|
|
89
|
+
// If the writer had emitted `network_id: null`, validateIndexEntry
|
|
90
|
+
// would reject the entry here and this would 404 — the "自己打死
|
|
91
|
+
// 自己" failure the spread pattern exists to prevent.
|
|
92
|
+
const dl = await fetch(`${BASE}/api/files/${fileId}`);
|
|
93
|
+
expect(dl.status).toBe(200);
|
|
94
|
+
expect(await dl.text()).toBe("round-trip");
|
|
95
|
+
expect(existsSync(join(UPLOADS_DIR, ".index", `${fileId}.json`))).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
});
|