@sleep2agi/commhub-server 0.9.0-preview.7 → 0.9.0-preview.71
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 +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-delete-network-http.test.ts +177 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/agent-access.ts +461 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +510 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +167 -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 +423 -29
- 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/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 +51 -1
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +85 -0
- package/src/db-adapter-translate.test.ts +112 -0
- package/src/db-adapter.ts +246 -83
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +799 -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/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 +73 -0
- package/src/http-gzip.ts +60 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +149 -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-write-network-resolution.test.ts +429 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -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 +204 -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-tags.ts +16 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +708 -0
- package/src/requirements-mcp.test.ts +142 -0
- package/src/requirements-migrate.ts +83 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +85 -0
- package/src/requirements.ts +899 -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 +10 -1
- 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-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-tasks-http.test.ts +625 -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 +4385 -0
- package/src/shared/external-schedule-contract.ts +60 -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 +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 +2495 -0
- package/src/skillhub-http.test.ts +92 -0
- package/src/start-node.test.ts +109 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/stop-delete-node.test.ts +1164 -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 +3446 -257
- 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 +130 -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
|
@@ -166,4 +166,69 @@ describe("update_provider — SQL invariants (RFC-028 P1.5)", () => {
|
|
|
166
166
|
expect(row!.enabled).toBe(0);
|
|
167
167
|
// dashboard renders different message for these two cases
|
|
168
168
|
});
|
|
169
|
+
|
|
170
|
+
test("no-op same-value patch: NO audit_log row written (通信牛 nit)", () => {
|
|
171
|
+
// Regression for the post-merge fix: when patch fields all match the
|
|
172
|
+
// existing row (e.g. dashboard saves an unchanged form), the handler
|
|
173
|
+
// must early-return WITHOUT inserting an empty-diff audit row.
|
|
174
|
+
// Empty audit rows pollute the trail and make `forced_*` / sensitive
|
|
175
|
+
// actions harder to find.
|
|
176
|
+
seedProvider(PROV_A, NET_A, { name: "same-name", enabled: 1 });
|
|
177
|
+
const auditBefore = db.get<{n:number}>(
|
|
178
|
+
"SELECT COUNT(*) AS n FROM audit_log WHERE target_id = ?1 AND action = 'update_provider'",
|
|
179
|
+
PROV_A,
|
|
180
|
+
)!.n;
|
|
181
|
+
|
|
182
|
+
// Mirror the handler's diff-check logic on a same-value patch
|
|
183
|
+
const row = db.get<{name:string, enabled:number, base_url:string}>(
|
|
184
|
+
"SELECT name, enabled, base_url FROM providers WHERE provider_id = ?1", PROV_A,
|
|
185
|
+
)!;
|
|
186
|
+
const patch = { name: "same-name", enabled: true };
|
|
187
|
+
const sets: string[] = [];
|
|
188
|
+
if (patch.name !== undefined && patch.name !== row.name) sets.push("name");
|
|
189
|
+
if (patch.enabled !== undefined && (patch.enabled ? 1 : 0) !== row.enabled) sets.push("enabled");
|
|
190
|
+
const willReplaceModels = false; // no patch.models
|
|
191
|
+
|
|
192
|
+
// sets MUST be empty for a same-value patch
|
|
193
|
+
expect(sets.length).toBe(0);
|
|
194
|
+
expect(willReplaceModels).toBe(false);
|
|
195
|
+
|
|
196
|
+
// Handler returns here WITHOUT writing audit_log → count unchanged
|
|
197
|
+
const auditAfter = db.get<{n:number}>(
|
|
198
|
+
"SELECT COUNT(*) AS n FROM audit_log WHERE target_id = ?1 AND action = 'update_provider'",
|
|
199
|
+
PROV_A,
|
|
200
|
+
)!.n;
|
|
201
|
+
expect(auditAfter).toBe(auditBefore);
|
|
202
|
+
expect(auditAfter).toBe(0);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("real change DOES write audit_log row (positive guard for the early-return)", () => {
|
|
206
|
+
// Belt-and-suspenders: no-op early-return MUST NOT trigger when even
|
|
207
|
+
// one field actually changes. Otherwise we'd silently lose audit
|
|
208
|
+
// coverage for real updates.
|
|
209
|
+
seedProvider(PROV_A, NET_A, { name: "old-name", enabled: 1 });
|
|
210
|
+
const row = db.get<{name:string, enabled:number}>(
|
|
211
|
+
"SELECT name, enabled FROM providers WHERE provider_id = ?1", PROV_A,
|
|
212
|
+
)!;
|
|
213
|
+
const patch = { name: "new-name", enabled: true };
|
|
214
|
+
const sets: string[] = [];
|
|
215
|
+
if (patch.name !== undefined && patch.name !== row.name) sets.push("name");
|
|
216
|
+
if (patch.enabled !== undefined && (patch.enabled ? 1 : 0) !== row.enabled) sets.push("enabled");
|
|
217
|
+
expect(sets).toEqual(["name"]);
|
|
218
|
+
|
|
219
|
+
db.exec("BEGIN");
|
|
220
|
+
db.run("UPDATE providers SET name = ?1 WHERE provider_id = ?2", ["new-name", PROV_A]);
|
|
221
|
+
db.run(
|
|
222
|
+
`INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
|
|
223
|
+
VALUES (?1, ?2, 'update_provider', 'provider', ?3, ?4, ?5)`,
|
|
224
|
+
["u_test", "test", PROV_A, JSON.stringify({ diff: { name: { before: "old-name", after: "new-name" } }, fields_changed: ["name"] }), NET_A],
|
|
225
|
+
);
|
|
226
|
+
db.exec("COMMIT");
|
|
227
|
+
|
|
228
|
+
const audit = db.get<any>(
|
|
229
|
+
"SELECT detail FROM audit_log WHERE target_id = ?1 AND action = 'update_provider' ORDER BY id DESC LIMIT 1",
|
|
230
|
+
PROV_A,
|
|
231
|
+
)!;
|
|
232
|
+
expect(JSON.parse(audit.detail).fields_changed).toEqual(["name"]);
|
|
233
|
+
});
|
|
169
234
|
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// #503 F3=A static guard: every 4xx return in the /api/upload handler
|
|
2
|
+
// that fires BEFORE `req.formData()` drains the request body MUST be
|
|
3
|
+
// wrapped in `earlyReject` (which sets `Connection: close`). Otherwise
|
|
4
|
+
// the client's still-inbound body poisons the keepalive pool and the
|
|
5
|
+
// next pooled request stalls.
|
|
6
|
+
//
|
|
7
|
+
// Why a STATIC guard (not a runtime one): mutation M9 in
|
|
8
|
+
// docs/tests/p-503-file-network-scope/mutation-matrix.txt showed that
|
|
9
|
+
// under Bun's fetch client in this test setup, removing
|
|
10
|
+
// `Connection: close` does NOT turn any dynamic test red — the pool
|
|
11
|
+
// poisoning is real HTTP-level behavior but the client under test does
|
|
12
|
+
// not reliably reproduce it. Without a guard, deleting the helper or
|
|
13
|
+
// forgetting to wrap a NEW 15th pre-drain return goes silent. This
|
|
14
|
+
// test judges the SOURCE FILE, per lead 7a88ce29 and Constraint 3-1
|
|
15
|
+
// pattern (source-based, not runner-output-based).
|
|
16
|
+
//
|
|
17
|
+
// PRIOR-ART GOTCHA (lead 7a88ce29 踩过): parse boundaries MUST exclude
|
|
18
|
+
// comments. `req.formData()` appears in a doc comment near the top of
|
|
19
|
+
// the handler too; if the boundary matches on that, the pre-drain
|
|
20
|
+
// range collapses to empty and this guard silently becomes vacuously
|
|
21
|
+
// true. Same trap family as "范围为空的全称断言恒真". So this test
|
|
22
|
+
// has an explicit precondition: the parsed pre-drain return count must
|
|
23
|
+
// be ≥ 10 (currently 14). If the count drops to 0, the boundary parser
|
|
24
|
+
// broke, not the code.
|
|
25
|
+
|
|
26
|
+
import { describe, expect, test } from "bun:test";
|
|
27
|
+
import { readFileSync } from "fs";
|
|
28
|
+
import { join } from "path";
|
|
29
|
+
|
|
30
|
+
const SOURCE_PATH = join(import.meta.dirname, "server.ts");
|
|
31
|
+
const RAW = readFileSync(SOURCE_PATH, "utf-8");
|
|
32
|
+
const LINES = RAW.split("\n");
|
|
33
|
+
|
|
34
|
+
// Strip line-comments so we don't match on comment-mentioned tokens
|
|
35
|
+
// like the "req.formData()" doc references at the top of the handler.
|
|
36
|
+
// (Block comments /* */ are rare inside this handler; if you add one
|
|
37
|
+
// that mentions the boundary, extend this helper.)
|
|
38
|
+
//
|
|
39
|
+
// KNOWN LIMITATION (lead 50208c1b): this naive strip cuts on ANY `//`,
|
|
40
|
+
// including one inside a string literal (e.g. `"https://…"`). If a
|
|
41
|
+
// pre-drain line ever contains such a literal, the strip could snip
|
|
42
|
+
// off the return statement after it and produce a false NEGATIVE (miss
|
|
43
|
+
// a genuine unwrapped return). Currently no pre-drain line in the
|
|
44
|
+
// /api/upload handler holds this shape; the precondition floor of 10
|
|
45
|
+
// catches only "range collapsed to empty", not "silently minus one".
|
|
46
|
+
// If someone adds a URL-string literal in this range in the future,
|
|
47
|
+
// upgrade to a real tokenizer — not because block-comment mentions of
|
|
48
|
+
// req.formData() need it, but because the string-literal-with-`//`
|
|
49
|
+
// case is the actual gap.
|
|
50
|
+
function stripLineComment(s: string): string {
|
|
51
|
+
const idx = s.indexOf("//");
|
|
52
|
+
return idx >= 0 ? s.slice(0, idx) : s;
|
|
53
|
+
}
|
|
54
|
+
const CODE = LINES.map(stripLineComment);
|
|
55
|
+
|
|
56
|
+
// Find the /api/upload handler start (guard against a repo rename).
|
|
57
|
+
const handlerStartIdx = CODE.findIndex((line) =>
|
|
58
|
+
line.includes('url.pathname === "/api/upload"') &&
|
|
59
|
+
line.includes('req.method === "POST"'),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
// Find the true body-drain boundary: `await req.formData()` INSIDE the
|
|
63
|
+
// handler. Search from handler start forward for the first NON-COMMENT
|
|
64
|
+
// occurrence (stripLineComment above already ate any `// ... formData ...`
|
|
65
|
+
// mention).
|
|
66
|
+
let drainIdx = -1;
|
|
67
|
+
if (handlerStartIdx >= 0) {
|
|
68
|
+
for (let i = handlerStartIdx + 1; i < CODE.length; i++) {
|
|
69
|
+
if (CODE[i].includes("req.formData()")) {
|
|
70
|
+
drainIdx = i;
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Collect every `return` statement in the pre-drain range. Classify:
|
|
77
|
+
// earlyReject — the correct wrapping (sets Connection: close)
|
|
78
|
+
// withCors — bare wrapping without Connection: close (regression)
|
|
79
|
+
// other — produces a Response some OTHER way (e.g.
|
|
80
|
+
// `return Response.json(...)` / `return new Response(...)`);
|
|
81
|
+
// this shape ALSO skips earlyReject and MUST fail the guard.
|
|
82
|
+
// Detected via /Response\b/ so `return wrapped;` /
|
|
83
|
+
// `return;` / `return blob;` (helper body, non-response
|
|
84
|
+
// returns) are correctly skipped, but any new Response-
|
|
85
|
+
// producing path (Response.json / new Response / etc.) is
|
|
86
|
+
// caught. Lead 50208c1b: earlier version used implicit-skip
|
|
87
|
+
// which made the third assertion恒真 — it counted only what
|
|
88
|
+
// it recognised, so "unrecognised = 0" was vacuous.
|
|
89
|
+
// Order matters: earlyReject → withCors → other. A line
|
|
90
|
+
// like `return earlyReject(new Response(…))` contains
|
|
91
|
+
// both `earlyReject` AND `Response`, and must classify as
|
|
92
|
+
// earlyReject (already-wrapped), not as other.
|
|
93
|
+
type ReturnHit = { lineNumber: number; text: string; kind: "earlyReject" | "withCors" | "other" };
|
|
94
|
+
const preDrainReturns: ReturnHit[] = [];
|
|
95
|
+
if (handlerStartIdx >= 0 && drainIdx > handlerStartIdx) {
|
|
96
|
+
for (let i = handlerStartIdx; i < drainIdx; i++) {
|
|
97
|
+
const code = CODE[i];
|
|
98
|
+
if (!/\breturn\b/.test(code)) continue;
|
|
99
|
+
if (/\breturn earlyReject\b/.test(code)) {
|
|
100
|
+
preDrainReturns.push({ lineNumber: i + 1, text: LINES[i].trim(), kind: "earlyReject" });
|
|
101
|
+
} else if (/\breturn withCors\b/.test(code)) {
|
|
102
|
+
preDrainReturns.push({ lineNumber: i + 1, text: LINES[i].trim(), kind: "withCors" });
|
|
103
|
+
} else if (/Response\b/.test(code)) {
|
|
104
|
+
// `return new Response(...)` / `return Response.json(...)` / etc.
|
|
105
|
+
// — produces a Response but skips the earlyReject wrapper.
|
|
106
|
+
preDrainReturns.push({ lineNumber: i + 1, text: LINES[i].trim(), kind: "other" });
|
|
107
|
+
}
|
|
108
|
+
// Bare `return wrapped;`, `return;`, `return blob;` are non-response
|
|
109
|
+
// returns and correctly skipped (no `Response` token).
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
describe("#503 F3=A static guard: /api/upload pre-body-drain returns use earlyReject", () => {
|
|
114
|
+
test("precondition: scanner located handler + drain boundary + non-trivial return count (defends against parse collapse)", () => {
|
|
115
|
+
expect(handlerStartIdx).toBeGreaterThanOrEqual(0);
|
|
116
|
+
expect(drainIdx).toBeGreaterThan(handlerStartIdx);
|
|
117
|
+
// If boundary parsing collapses (someone renames req.formData(),
|
|
118
|
+
// or a comment-strip bug lets a doc reference win), this range
|
|
119
|
+
// becomes 0 and the second test would vacuously pass. Anchor
|
|
120
|
+
// the range: at time of writing (85e5c140 + #503) the count is
|
|
121
|
+
// 14; lead 7a88ce29 confirmed 14/14. Floor at 10 to leave room
|
|
122
|
+
// for genuine deletions while still catching a full collapse.
|
|
123
|
+
expect(preDrainReturns.length).toBeGreaterThanOrEqual(10);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("every pre-drain return is wrapped in earlyReject (not bare withCors)", () => {
|
|
127
|
+
const bareWithCors = preDrainReturns.filter((r) => r.kind === "withCors");
|
|
128
|
+
if (bareWithCors.length > 0) {
|
|
129
|
+
const detail = bareWithCors
|
|
130
|
+
.map((r) => ` L${r.lineNumber}: ${r.text}`)
|
|
131
|
+
.join("\n");
|
|
132
|
+
throw new Error(
|
|
133
|
+
`#503 F3=A regression: ${bareWithCors.length} pre-body-drain return(s) in ` +
|
|
134
|
+
`/api/upload use bare withCors() instead of earlyReject():\n${detail}\n\n` +
|
|
135
|
+
`Every early-reject in this range MUST set Connection: close. Otherwise ` +
|
|
136
|
+
`the client's in-flight request body poisons the keepalive pool and the ` +
|
|
137
|
+
`next pooled request stalls. Wrap with the earlyReject() helper defined ` +
|
|
138
|
+
`at the top of the upload handler.`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
expect(bareWithCors.length).toBe(0);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("no pre-drain return produces a Response without going through earlyReject (catches new response shapes)", () => {
|
|
145
|
+
// Catches `return Response.json(...)` / `return new Response(...)` /
|
|
146
|
+
// any other pre-drain Response-producing path that skips the wrapper.
|
|
147
|
+
// Lead 50208c1b: previous version was 恒真 because unrecognised
|
|
148
|
+
// shapes were silently dropped from `preDrainReturns` — the filter
|
|
149
|
+
// then counted "recognised = recognised" and never failed. Now the
|
|
150
|
+
// collector classifies unrecognised Response-producers as "other";
|
|
151
|
+
// this assertion reports them by line, not just count.
|
|
152
|
+
const unknown = preDrainReturns.filter((r) => r.kind === "other");
|
|
153
|
+
expect(unknown.map((r) => `L${r.lineNumber}: ${r.text}`)).toEqual([]);
|
|
154
|
+
});
|
|
155
|
+
});
|
package/src/uploads-http.test.ts
CHANGED
|
@@ -7,27 +7,32 @@
|
|
|
7
7
|
// this file already exercises the same code path). The hub binds to
|
|
8
8
|
// an ephemeral port so multiple test runs don't collide.
|
|
9
9
|
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
10
|
+
import "./require-explicit-test-db.js";
|
|
10
11
|
import { mkdtempSync, rmSync, existsSync } from "fs";
|
|
11
12
|
import { join } from "path";
|
|
12
13
|
import { tmpdir } from "os";
|
|
13
14
|
import { register, login } from "./auth.js";
|
|
14
15
|
|
|
15
|
-
const SERVER_DB = mkdtempSync(join(tmpdir(), "anet-upload-http-db-")) + "/commhub.db";
|
|
16
16
|
const UPLOADS_DIR = mkdtempSync(join(tmpdir(), "anet-upload-http-fs-"));
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
// #438 corrective: private bootServer({ port: 0 }) instance — the OS
|
|
18
|
+
// assigns the port and BASE is derived from the ACTUAL bound port, so
|
|
19
|
+
// this suite is immune to both the parent-side random-port TOCTOU and
|
|
20
|
+
// the aggregate module-cache no-op (import no longer boots anything).
|
|
21
|
+
let BASE = "";
|
|
19
22
|
|
|
20
23
|
let server: any;
|
|
21
24
|
let userToken = "";
|
|
22
25
|
let userNetworkId = "";
|
|
23
26
|
|
|
27
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
28
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
29
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
30
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
24
31
|
beforeAll(async () => {
|
|
25
32
|
// Bootstrap a real admin + token on the temp DB so the server can
|
|
26
33
|
// resolve it via requireAuth (we don't want to short-circuit auth —
|
|
27
34
|
// we want to validate the production code path).
|
|
28
|
-
process.env.COMMHUB_DB = SERVER_DB;
|
|
29
35
|
process.env.COMMHUB_UPLOADS_DIR = UPLOADS_DIR;
|
|
30
|
-
process.env.PORT = String(PORT);
|
|
31
36
|
process.env.HOST = "127.0.0.1";
|
|
32
37
|
|
|
33
38
|
// Use a unique username + strong password each run so we don't trip
|
|
@@ -50,31 +55,40 @@ beforeAll(async () => {
|
|
|
50
55
|
}
|
|
51
56
|
expect(userToken).toBeTruthy();
|
|
52
57
|
|
|
53
|
-
//
|
|
54
|
-
// the port
|
|
55
|
-
await import("./
|
|
58
|
+
// Importing is side-effect-free now; boot a private instance and
|
|
59
|
+
// read the real port back.
|
|
60
|
+
const { bootServer } = await import("./server.js");
|
|
61
|
+
server = bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
62
|
+
BASE = `http://127.0.0.1:${server.port}`;
|
|
56
63
|
// Tiny settle so the listener is ready.
|
|
57
64
|
await new Promise((r) => setTimeout(r, 100));
|
|
58
|
-
});
|
|
65
|
+
}, 30_000);
|
|
59
66
|
|
|
60
67
|
afterAll(() => {
|
|
61
|
-
|
|
62
|
-
// time so we can't easily stop it — process exit handles that.
|
|
68
|
+
try { server?.stop?.(true); } catch {}
|
|
63
69
|
try { rmSync(UPLOADS_DIR, { recursive: true, force: true }); } catch {}
|
|
64
|
-
try { rmSync(SERVER_DB, { recursive: true, force: true }); } catch {}
|
|
65
70
|
});
|
|
66
71
|
|
|
67
72
|
function authHeaders(): Record<string, string> {
|
|
68
73
|
return { "Authorization": `Bearer ${userToken}` };
|
|
69
74
|
}
|
|
70
75
|
|
|
76
|
+
// #503 — the bootstrap user is the first account in this suite's fresh
|
|
77
|
+
// DB, so it is a system admin, and admins must name the target network
|
|
78
|
+
// explicitly (attribution is never guessed). uploadNetworkId() keeps
|
|
79
|
+
// that detail in one place.
|
|
80
|
+
function uploadNetworkId(): string {
|
|
81
|
+
if (!userNetworkId) throw new Error("test bootstrap: userNetworkId missing");
|
|
82
|
+
return `?network_id=${encodeURIComponent(userNetworkId)}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
71
85
|
async function uploadFile(content: Uint8Array, filename: string, mime: string, opts: { skipAuth?: boolean; overrideContentLength?: string | null } = {}): Promise<Response> {
|
|
72
86
|
const form = new FormData();
|
|
73
87
|
form.append("file", new Blob([content], { type: mime }), filename);
|
|
74
88
|
const headers: Record<string, string> = opts.skipAuth ? {} : { ...authHeaders() };
|
|
75
89
|
// Note: fetch sets Content-Length automatically; only override when
|
|
76
90
|
// testing the missing-header path.
|
|
77
|
-
const res = await fetch(`${BASE}/api/upload`, { method: "POST", body: form, headers });
|
|
91
|
+
const res = await fetch(`${BASE}/api/upload${uploadNetworkId()}`, { method: "POST", body: form, headers });
|
|
78
92
|
return res;
|
|
79
93
|
}
|
|
80
94
|
|
|
@@ -134,7 +148,7 @@ describe("POST /api/upload — 6-item security checklist (HTTP integration)", ()
|
|
|
134
148
|
controller.close();
|
|
135
149
|
},
|
|
136
150
|
});
|
|
137
|
-
const res = await fetch(`${BASE}/api/upload`, {
|
|
151
|
+
const res = await fetch(`${BASE}/api/upload${uploadNetworkId()}`, {
|
|
138
152
|
method: "POST",
|
|
139
153
|
body: stream,
|
|
140
154
|
headers: { ...authHeaders(), "Content-Type": "multipart/form-data; boundary=xxx", "Transfer-Encoding": "chunked" },
|
|
@@ -150,7 +164,7 @@ describe("POST /api/upload — 6-item security checklist (HTTP integration)", ()
|
|
|
150
164
|
// this body" which is the security property we care about; the
|
|
151
165
|
// exact code depends on whether Bun's multipart parser bails before
|
|
152
166
|
// or after our Content-Type pre-check.
|
|
153
|
-
const res = await fetch(`${BASE}/api/upload`, {
|
|
167
|
+
const res = await fetch(`${BASE}/api/upload${uploadNetworkId()}`, {
|
|
154
168
|
method: "POST",
|
|
155
169
|
body: JSON.stringify({ nope: 1 }),
|
|
156
170
|
headers: { ...authHeaders(), "Content-Type": "application/json" },
|
|
@@ -161,12 +175,18 @@ describe("POST /api/upload — 6-item security checklist (HTTP integration)", ()
|
|
|
161
175
|
|
|
162
176
|
describe("GET /api/files/:file_id — download safety", () => {
|
|
163
177
|
let fileId = "";
|
|
178
|
+
let rangeFileId = "";
|
|
179
|
+
const rangeContent = Uint8Array.from({ length: 64 }, (_, i) => i);
|
|
164
180
|
|
|
165
181
|
beforeAll(async () => {
|
|
166
182
|
const content = new TextEncoder().encode("hello-world-from-#221-integration-test");
|
|
167
183
|
const res = await uploadFile(content, "greeting.txt", "text/plain");
|
|
168
184
|
const body = await res.json() as any;
|
|
169
185
|
fileId = body.file_id;
|
|
186
|
+
|
|
187
|
+
const rangeRes = await uploadFile(rangeContent, "range.bin", "application/octet-stream");
|
|
188
|
+
const rangeBody = await rangeRes.json() as any;
|
|
189
|
+
rangeFileId = rangeBody.file_id;
|
|
170
190
|
});
|
|
171
191
|
|
|
172
192
|
test("(item 3) download returns the blob with Content-Disposition: attachment + X-Content-Type-Options: nosniff", async () => {
|
|
@@ -175,10 +195,72 @@ describe("GET /api/files/:file_id — download safety", () => {
|
|
|
175
195
|
expect(res.headers.get("Content-Disposition") ?? "").toMatch(/^attachment(;|$)/i);
|
|
176
196
|
expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
|
177
197
|
expect(res.headers.get("Content-Type")).toBe("application/octet-stream");
|
|
198
|
+
expect(res.headers.get("Accept-Ranges")).toBe("bytes");
|
|
178
199
|
const text = await res.text();
|
|
179
200
|
expect(text).toBe("hello-world-from-#221-integration-test");
|
|
180
201
|
});
|
|
181
202
|
|
|
203
|
+
test("#514 Range bytes=a-b returns exact 206 partial content, not a tolerant 200/full body", async () => {
|
|
204
|
+
const res = await fetch(`${BASE}/api/files/${rangeFileId}`, {
|
|
205
|
+
headers: { ...authHeaders(), Range: "bytes=3-8" },
|
|
206
|
+
});
|
|
207
|
+
expect(res.status).toBe(206);
|
|
208
|
+
expect(res.headers.get("Content-Range")).toBe("bytes 3-8/64");
|
|
209
|
+
expect(res.headers.get("Content-Length")).toBe("6");
|
|
210
|
+
expect(res.headers.get("Accept-Ranges")).toBe("bytes");
|
|
211
|
+
const body = new Uint8Array(await res.arrayBuffer());
|
|
212
|
+
expect(body.length).toBe(6);
|
|
213
|
+
expect([...body]).toEqual([...rangeContent.slice(3, 9)]);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("#514 Range bytes=N- returns the exact open-ended tail", async () => {
|
|
217
|
+
const res = await fetch(`${BASE}/api/files/${rangeFileId}`, {
|
|
218
|
+
headers: { ...authHeaders(), Range: "bytes=60-" },
|
|
219
|
+
});
|
|
220
|
+
expect(res.status).toBe(206);
|
|
221
|
+
expect(res.headers.get("Content-Range")).toBe("bytes 60-63/64");
|
|
222
|
+
expect(res.headers.get("Content-Length")).toBe("4");
|
|
223
|
+
const body = new Uint8Array(await res.arrayBuffer());
|
|
224
|
+
expect(body.length).toBe(4);
|
|
225
|
+
expect([...body]).toEqual([...rangeContent.slice(60)]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("#514 Range bytes=-N returns the exact suffix", async () => {
|
|
229
|
+
const res = await fetch(`${BASE}/api/files/${rangeFileId}`, {
|
|
230
|
+
headers: { ...authHeaders(), Range: "bytes=-7" },
|
|
231
|
+
});
|
|
232
|
+
expect(res.status).toBe(206);
|
|
233
|
+
expect(res.headers.get("Content-Range")).toBe("bytes 57-63/64");
|
|
234
|
+
expect(res.headers.get("Content-Length")).toBe("7");
|
|
235
|
+
const body = new Uint8Array(await res.arrayBuffer());
|
|
236
|
+
expect(body.length).toBe(7);
|
|
237
|
+
expect([...body]).toEqual([...rangeContent.slice(57)]);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("#514 unsatisfiable Range returns 416 with concrete Content-Range size", async () => {
|
|
241
|
+
const res = await fetch(`${BASE}/api/files/${rangeFileId}`, {
|
|
242
|
+
headers: { ...authHeaders(), Range: "bytes=64-70" },
|
|
243
|
+
});
|
|
244
|
+
expect(res.status).toBe(416);
|
|
245
|
+
expect(res.headers.get("Content-Range")).toBe("bytes */64");
|
|
246
|
+
expect(res.headers.get("Content-Length")).toBe("0");
|
|
247
|
+
const body = new Uint8Array(await res.arrayBuffer());
|
|
248
|
+
expect(body.length).toBe(0);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("#514 HEAD Range mirrors GET status and range headers without a body", async () => {
|
|
252
|
+
const res = await fetch(`${BASE}/api/files/${rangeFileId}`, {
|
|
253
|
+
method: "HEAD",
|
|
254
|
+
headers: { ...authHeaders(), Range: "bytes=3-8" },
|
|
255
|
+
});
|
|
256
|
+
expect(res.status).toBe(206);
|
|
257
|
+
expect(res.headers.get("Content-Range")).toBe("bytes 3-8/64");
|
|
258
|
+
expect(res.headers.get("Content-Length")).toBe("6");
|
|
259
|
+
expect(res.headers.get("Accept-Ranges")).toBe("bytes");
|
|
260
|
+
const body = new Uint8Array(await res.arrayBuffer());
|
|
261
|
+
expect(body.length).toBe(0);
|
|
262
|
+
});
|
|
263
|
+
|
|
182
264
|
test("anonymous download is 401", async () => {
|
|
183
265
|
const res = await fetch(`${BASE}/api/files/${fileId}`);
|
|
184
266
|
expect(res.status).toBe(401);
|
package/src/uploads.test.ts
CHANGED
|
@@ -312,3 +312,69 @@ describe("indexEntryPath", () => {
|
|
|
312
312
|
expect(p).toBe(`/srv/up/.index/${id}.json`);
|
|
313
313
|
});
|
|
314
314
|
});
|
|
315
|
+
|
|
316
|
+
import { stripHostLocalPathsForCrossHostSafe } from "./uploads";
|
|
317
|
+
|
|
318
|
+
describe("stripHostLocalPathsForCrossHostSafe (#222 cross-host attachment safety)", () => {
|
|
319
|
+
test("strips path when file_id is present (cross-host safe — agent will fetch via /api/files/<id>)", () => {
|
|
320
|
+
const meta = {
|
|
321
|
+
attachments: [
|
|
322
|
+
{ type: "file", file_id: "abc12345", path: "/home/hub/uploads/abc12345.png", mime: "image/png" },
|
|
323
|
+
],
|
|
324
|
+
otherKey: "preserved",
|
|
325
|
+
};
|
|
326
|
+
const out = stripHostLocalPathsForCrossHostSafe(meta);
|
|
327
|
+
expect(out.attachments[0]).toEqual({ type: "file", file_id: "abc12345", mime: "image/png" });
|
|
328
|
+
expect(out.attachments[0].path).toBeUndefined();
|
|
329
|
+
expect(out.otherKey).toBe("preserved");
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test("PRESERVES path when file_id is absent (feishu single-host fallback)", () => {
|
|
333
|
+
// 🔴 通信龙 PR #222 pre-review nit — feishu-bridge ships path-only
|
|
334
|
+
// attachments (writes /work/feishu-attachments/...). If we strip
|
|
335
|
+
// unconditionally those break too. Lock the conditional contract.
|
|
336
|
+
const meta = {
|
|
337
|
+
attachments: [
|
|
338
|
+
{ type: "file", path: "/work/feishu-attachments/feishu-local/oc_xxx/img.jpg", mime: "image/jpeg" },
|
|
339
|
+
],
|
|
340
|
+
};
|
|
341
|
+
const out = stripHostLocalPathsForCrossHostSafe(meta);
|
|
342
|
+
expect(out.attachments[0].path).toBe("/work/feishu-attachments/feishu-local/oc_xxx/img.jpg");
|
|
343
|
+
expect(out.attachments[0].file_id).toBeUndefined();
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("mixed array: strips path on entries with file_id, preserves on path-only entries", () => {
|
|
347
|
+
const meta = {
|
|
348
|
+
attachments: [
|
|
349
|
+
{ type: "file", file_id: "id1xxxxx", path: "/hub/local/id1.png", mime: "image/png" },
|
|
350
|
+
{ type: "file", path: "/work/feishu-attachments/x.jpg", mime: "image/jpeg" },
|
|
351
|
+
{ type: "file", file_id: "id2yyyyy", mime: "image/gif" }, // already path-free
|
|
352
|
+
],
|
|
353
|
+
};
|
|
354
|
+
const out = stripHostLocalPathsForCrossHostSafe(meta);
|
|
355
|
+
expect(out.attachments[0]).toEqual({ type: "file", file_id: "id1xxxxx", mime: "image/png" });
|
|
356
|
+
expect(out.attachments[1]).toEqual({ type: "file", path: "/work/feishu-attachments/x.jpg", mime: "image/jpeg" });
|
|
357
|
+
expect(out.attachments[2]).toEqual({ type: "file", file_id: "id2yyyyy", mime: "image/gif" });
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("no-op when meta has no attachments array", () => {
|
|
361
|
+
const meta = { something: "else" };
|
|
362
|
+
expect(stripHostLocalPathsForCrossHostSafe(meta)).toBe(meta);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test("no-op when nothing to strip (returns SAME object reference, not a copy)", () => {
|
|
366
|
+
const meta = { attachments: [{ type: "file", file_id: "x12345xx", mime: "image/png" }] };
|
|
367
|
+
expect(stripHostLocalPathsForCrossHostSafe(meta)).toBe(meta);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("handles null / non-object meta gracefully", () => {
|
|
371
|
+
expect(stripHostLocalPathsForCrossHostSafe(null)).toBe(null);
|
|
372
|
+
expect(stripHostLocalPathsForCrossHostSafe(undefined as any)).toBe(undefined);
|
|
373
|
+
expect(stripHostLocalPathsForCrossHostSafe("string" as any)).toBe("string");
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test("handles non-array attachments field gracefully", () => {
|
|
377
|
+
const meta = { attachments: "not-an-array" };
|
|
378
|
+
expect(stripHostLocalPathsForCrossHostSafe(meta)).toBe(meta);
|
|
379
|
+
});
|
|
380
|
+
});
|