@sleep2agi/commhub-server 0.9.0-preview.3 → 0.9.0-preview.30
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/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 +3492 -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 +140 -0
- package/src/side-thread-command-transport.ts +309 -0
- package/src/side-thread-http-integration.test.ts +229 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread.test.ts +1038 -0
- package/src/side-thread.ts +2241 -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 +2302 -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,234 @@
|
|
|
1
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { db } from "./db.js";
|
|
3
|
+
import {
|
|
4
|
+
validateBaseUrl,
|
|
5
|
+
ProbeValidationError,
|
|
6
|
+
} from "./probe-validate.js";
|
|
7
|
+
|
|
8
|
+
// RFC-028 P1.5 — update_provider invariants. Tests the building blocks
|
|
9
|
+
// the MCP handler relies on:
|
|
10
|
+
// - SQL-level SEC-1 (provider_id × network_id uniqueness across tenants)
|
|
11
|
+
// - audit_log row written for every update
|
|
12
|
+
// - validateBaseUrl re-runs against vendor from DB (not patch) on
|
|
13
|
+
// base_url change → vendor immutability bypass attempt fails
|
|
14
|
+
// - patch-empty noop path
|
|
15
|
+
// - DELETE FROM provider_models + INSERT replacement is atomic
|
|
16
|
+
//
|
|
17
|
+
// Boundary behaviour (zod-strict patch wrapper, MCP -32602 on extras,
|
|
18
|
+
// admin gate, cross-tenant rejection) is covered by the docker e2e
|
|
19
|
+
// (qa-rfc028-update-provider). The unit layer focuses on the impl
|
|
20
|
+
// guarantees that survive even if the MCP boundary changes.
|
|
21
|
+
|
|
22
|
+
const NET_A = "net_test_pa_alpha";
|
|
23
|
+
const NET_B = "net_test_pa_beta";
|
|
24
|
+
const PROV_A = "prov_test_p15_alpha";
|
|
25
|
+
const PROV_B = "prov_test_p15_beta";
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
for (const p of [PROV_A, PROV_B]) {
|
|
29
|
+
try { db.run("DELETE FROM provider_models WHERE provider_id = ?1", [p]); } catch {}
|
|
30
|
+
try { db.run("DELETE FROM providers WHERE provider_id = ?1", [p]); } catch {}
|
|
31
|
+
}
|
|
32
|
+
try { db.run("DELETE FROM audit_log WHERE target_id IN (?1, ?2)", [PROV_A, PROV_B]); } catch {}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterAll(() => {
|
|
36
|
+
for (const p of [PROV_A, PROV_B]) {
|
|
37
|
+
try { db.run("DELETE FROM provider_models WHERE provider_id = ?1", [p]); } catch {}
|
|
38
|
+
try { db.run("DELETE FROM providers WHERE provider_id = ?1", [p]); } catch {}
|
|
39
|
+
}
|
|
40
|
+
try { db.run("DELETE FROM audit_log WHERE target_id IN (?1, ?2)", [PROV_A, PROV_B]); } catch {}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
function seedProvider(provider_id: string, network_id: string, opts: Partial<{ name: string; vendor: string; base_url: string; enabled: number }> = {}) {
|
|
44
|
+
db.run(
|
|
45
|
+
`INSERT INTO providers (provider_id, network_id, name, vendor, base_url, secret_key_ref, created_at, created_by, enabled)
|
|
46
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)`,
|
|
47
|
+
[provider_id, network_id, opts.name ?? "anthro-prod", opts.vendor ?? "anthropic",
|
|
48
|
+
opts.base_url ?? "https://api.anthropic.com", "ANTHROPIC_API_KEY",
|
|
49
|
+
Date.now(), "test", opts.enabled ?? 1],
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("update_provider — SQL invariants (RFC-028 P1.5)", () => {
|
|
54
|
+
test("SEC-1: provider_id is unique-per-network — netA query for netB provider_id returns nothing", () => {
|
|
55
|
+
seedProvider(PROV_A, NET_A);
|
|
56
|
+
seedProvider(PROV_B, NET_B);
|
|
57
|
+
// The handler's lookup query: SELECT ... WHERE provider_id = ? AND network_id = ?
|
|
58
|
+
const fromNetA = db.get<any>(
|
|
59
|
+
"SELECT provider_id FROM providers WHERE provider_id = ?1 AND network_id = ?2",
|
|
60
|
+
PROV_B, NET_A,
|
|
61
|
+
);
|
|
62
|
+
expect(fromNetA == null).toBe(true);
|
|
63
|
+
// Sanity: PROV_B does exist in its own network
|
|
64
|
+
const fromNetB = db.get<any>(
|
|
65
|
+
"SELECT provider_id FROM providers WHERE provider_id = ?1 AND network_id = ?2",
|
|
66
|
+
PROV_B, NET_B,
|
|
67
|
+
);
|
|
68
|
+
expect(fromNetB?.provider_id).toBe(PROV_B);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("vendor immutability via validateBaseUrl: changing base_url uses DB vendor, attempt to widen via patched vendor fails", () => {
|
|
72
|
+
seedProvider(PROV_A, NET_A, { vendor: "anthropic" });
|
|
73
|
+
// Handler reads vendor from DB row, NOT from patch. Suppose patch tried
|
|
74
|
+
// to set vendor=evil + base_url=https://attacker.example.com — the
|
|
75
|
+
// handler ignores the vendor from patch (it's not a field in the
|
|
76
|
+
// zod patch schema), and validateBaseUrl runs with vendor='anthropic'
|
|
77
|
+
// from the DB, so the host check is applied.
|
|
78
|
+
const dbVendor = db.get<{vendor: string}>("SELECT vendor FROM providers WHERE provider_id = ?1", PROV_A)!.vendor;
|
|
79
|
+
expect(dbVendor).toBe("anthropic");
|
|
80
|
+
// attempting to use base_url not in anthropic allowlist fails
|
|
81
|
+
expect(() => validateBaseUrl(dbVendor, "https://attacker.example.com")).toThrow(ProbeValidationError);
|
|
82
|
+
expect(() => validateBaseUrl(dbVendor, "https://attacker.example.com")).toThrow(/probe_target_forbidden/);
|
|
83
|
+
// valid anthropic host passes
|
|
84
|
+
expect(() => validateBaseUrl(dbVendor, "https://api.anthropic.com/v1")).not.toThrow();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("patch noop: enabled set to same value → SET clause skipped", () => {
|
|
88
|
+
seedProvider(PROV_A, NET_A, { enabled: 1 });
|
|
89
|
+
const before = db.get<{enabled: number}>("SELECT enabled FROM providers WHERE provider_id = ?1", PROV_A)!.enabled;
|
|
90
|
+
expect(before).toBe(1);
|
|
91
|
+
// simulate the handler's diff logic: if patch.enabled === row.enabled, skip
|
|
92
|
+
const newEnabled = true ? 1 : 0;
|
|
93
|
+
const shouldUpdate = newEnabled !== before;
|
|
94
|
+
expect(shouldUpdate).toBe(false);
|
|
95
|
+
// verify no SET ran (row unchanged)
|
|
96
|
+
const after = db.get<{enabled: number}>("SELECT enabled FROM providers WHERE provider_id = ?1", PROV_A)!.enabled;
|
|
97
|
+
expect(after).toBe(1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("enabled toggle 1→0 written + audit log row created in same transaction", () => {
|
|
101
|
+
seedProvider(PROV_A, NET_A, { enabled: 1 });
|
|
102
|
+
db.exec("BEGIN");
|
|
103
|
+
db.run("UPDATE providers SET enabled = 0 WHERE provider_id = ?1", [PROV_A]);
|
|
104
|
+
db.run(
|
|
105
|
+
`INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
|
|
106
|
+
VALUES (?1, ?2, 'update_provider', 'provider', ?3, ?4, ?5)`,
|
|
107
|
+
["u_test", "test", PROV_A, JSON.stringify({ diff: { enabled: { before: true, after: false } } }), NET_A],
|
|
108
|
+
);
|
|
109
|
+
db.exec("COMMIT");
|
|
110
|
+
expect(db.get<{enabled:number}>("SELECT enabled FROM providers WHERE provider_id = ?1", PROV_A)?.enabled).toBe(0);
|
|
111
|
+
const audit = db.get<any>("SELECT action, target_id, detail FROM audit_log WHERE target_id = ?1 AND action = 'update_provider'", PROV_A);
|
|
112
|
+
expect(audit?.action).toBe("update_provider");
|
|
113
|
+
expect(audit?.target_id).toBe(PROV_A);
|
|
114
|
+
expect(JSON.parse(audit!.detail).diff.enabled.after).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("audit detail does NOT contain secret_key_ref VALUE under any patch", () => {
|
|
118
|
+
seedProvider(PROV_A, NET_A);
|
|
119
|
+
// Even if an attacker tried to slip secret into the diff (impossible
|
|
120
|
+
// through the schema, but defense-in-depth), the audit serializer
|
|
121
|
+
// only includes the fields actually in `diff`. Secret isn't a patch
|
|
122
|
+
// field, so it can't reach the diff. Verify the absence:
|
|
123
|
+
const auditPayload = JSON.stringify({ diff: { name: { before: "old", after: "new" } } });
|
|
124
|
+
expect(auditPayload).not.toContain("secret");
|
|
125
|
+
expect(auditPayload).not.toContain("ANTHROPIC_API_KEY");
|
|
126
|
+
expect(auditPayload).not.toContain("sk-");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("provider_models DELETE+INSERT atomicity (transaction wraps both)", () => {
|
|
130
|
+
seedProvider(PROV_A, NET_A);
|
|
131
|
+
db.run(
|
|
132
|
+
`INSERT INTO provider_models (model_id, provider_id, model_name, enabled, created_at)
|
|
133
|
+
VALUES ('pm_old1', ?1, 'claude-old-1', 1, ?2), ('pm_old2', ?1, 'claude-old-2', 1, ?2)`,
|
|
134
|
+
[PROV_A, Date.now()],
|
|
135
|
+
);
|
|
136
|
+
expect(db.all("SELECT model_id FROM provider_models WHERE provider_id = ?1", PROV_A).length).toBe(2);
|
|
137
|
+
// Replace in one transaction
|
|
138
|
+
db.exec("BEGIN");
|
|
139
|
+
db.run("DELETE FROM provider_models WHERE provider_id = ?1", [PROV_A]);
|
|
140
|
+
db.run(
|
|
141
|
+
`INSERT INTO provider_models (model_id, provider_id, model_name, enabled, created_at)
|
|
142
|
+
VALUES ('pm_new1', ?1, 'claude-new-1', 1, ?2)`,
|
|
143
|
+
[PROV_A, Date.now()],
|
|
144
|
+
);
|
|
145
|
+
db.exec("COMMIT");
|
|
146
|
+
const rows = db.all<{model_name: string}>("SELECT model_name FROM provider_models WHERE provider_id = ?1", PROV_A);
|
|
147
|
+
expect(rows.length).toBe(1);
|
|
148
|
+
expect(rows[0].model_name).toBe("claude-new-1");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("base_url change rejected via validateBaseUrl when host not in vendor allowlist", () => {
|
|
152
|
+
seedProvider(PROV_A, NET_A, { vendor: "anthropic" });
|
|
153
|
+
const vendor = db.get<{vendor: string}>("SELECT vendor FROM providers WHERE provider_id = ?1", PROV_A)!.vendor;
|
|
154
|
+
// any host other than api.anthropic.com is rejected
|
|
155
|
+
expect(() => validateBaseUrl(vendor, "https://api.openai.com")).toThrow(/probe_target_forbidden/);
|
|
156
|
+
expect(() => validateBaseUrl(vendor, "https://169.254.169.254/v1")).toThrow(/probe_target_forbidden/);
|
|
157
|
+
expect(() => validateBaseUrl(vendor, "http://api.anthropic.com")).toThrow(/probe_base_url_invalid/);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("provider_disabled error distinct from provider_not_found in probe lookup", () => {
|
|
161
|
+
seedProvider(PROV_A, NET_A, { enabled: 0 });
|
|
162
|
+
// The new probe handler does the lookup WITHOUT enabled filter, then
|
|
163
|
+
// checks .enabled — distinguishing not_found vs disabled.
|
|
164
|
+
const row = db.get<{enabled: number}>("SELECT enabled FROM providers WHERE provider_id = ?1 AND network_id = ?2", PROV_A, NET_A);
|
|
165
|
+
expect(row).toBeTruthy();
|
|
166
|
+
expect(row!.enabled).toBe(0);
|
|
167
|
+
// dashboard renders different message for these two cases
|
|
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
|
+
});
|
|
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,15 +7,18 @@
|
|
|
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 = "";
|
|
@@ -25,9 +28,7 @@ beforeAll(async () => {
|
|
|
25
28
|
// Bootstrap a real admin + token on the temp DB so the server can
|
|
26
29
|
// resolve it via requireAuth (we don't want to short-circuit auth —
|
|
27
30
|
// we want to validate the production code path).
|
|
28
|
-
process.env.COMMHUB_DB = SERVER_DB;
|
|
29
31
|
process.env.COMMHUB_UPLOADS_DIR = UPLOADS_DIR;
|
|
30
|
-
process.env.PORT = String(PORT);
|
|
31
32
|
process.env.HOST = "127.0.0.1";
|
|
32
33
|
|
|
33
34
|
// Use a unique username + strong password each run so we don't trip
|
|
@@ -50,31 +51,40 @@ beforeAll(async () => {
|
|
|
50
51
|
}
|
|
51
52
|
expect(userToken).toBeTruthy();
|
|
52
53
|
|
|
53
|
-
//
|
|
54
|
-
// the port
|
|
55
|
-
await import("./
|
|
54
|
+
// Importing is side-effect-free now; boot a private instance and
|
|
55
|
+
// read the real port back.
|
|
56
|
+
const { bootServer } = await import("./server.js");
|
|
57
|
+
server = bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
58
|
+
BASE = `http://127.0.0.1:${server.port}`;
|
|
56
59
|
// Tiny settle so the listener is ready.
|
|
57
60
|
await new Promise((r) => setTimeout(r, 100));
|
|
58
61
|
});
|
|
59
62
|
|
|
60
63
|
afterAll(() => {
|
|
61
|
-
|
|
62
|
-
// time so we can't easily stop it — process exit handles that.
|
|
64
|
+
try { server?.stop?.(true); } catch {}
|
|
63
65
|
try { rmSync(UPLOADS_DIR, { recursive: true, force: true }); } catch {}
|
|
64
|
-
try { rmSync(SERVER_DB, { recursive: true, force: true }); } catch {}
|
|
65
66
|
});
|
|
66
67
|
|
|
67
68
|
function authHeaders(): Record<string, string> {
|
|
68
69
|
return { "Authorization": `Bearer ${userToken}` };
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
// #503 — the bootstrap user is the first account in this suite's fresh
|
|
73
|
+
// DB, so it is a system admin, and admins must name the target network
|
|
74
|
+
// explicitly (attribution is never guessed). uploadNetworkId() keeps
|
|
75
|
+
// that detail in one place.
|
|
76
|
+
function uploadNetworkId(): string {
|
|
77
|
+
if (!userNetworkId) throw new Error("test bootstrap: userNetworkId missing");
|
|
78
|
+
return `?network_id=${encodeURIComponent(userNetworkId)}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
71
81
|
async function uploadFile(content: Uint8Array, filename: string, mime: string, opts: { skipAuth?: boolean; overrideContentLength?: string | null } = {}): Promise<Response> {
|
|
72
82
|
const form = new FormData();
|
|
73
83
|
form.append("file", new Blob([content], { type: mime }), filename);
|
|
74
84
|
const headers: Record<string, string> = opts.skipAuth ? {} : { ...authHeaders() };
|
|
75
85
|
// Note: fetch sets Content-Length automatically; only override when
|
|
76
86
|
// testing the missing-header path.
|
|
77
|
-
const res = await fetch(`${BASE}/api/upload`, { method: "POST", body: form, headers });
|
|
87
|
+
const res = await fetch(`${BASE}/api/upload${uploadNetworkId()}`, { method: "POST", body: form, headers });
|
|
78
88
|
return res;
|
|
79
89
|
}
|
|
80
90
|
|
|
@@ -134,7 +144,7 @@ describe("POST /api/upload — 6-item security checklist (HTTP integration)", ()
|
|
|
134
144
|
controller.close();
|
|
135
145
|
},
|
|
136
146
|
});
|
|
137
|
-
const res = await fetch(`${BASE}/api/upload`, {
|
|
147
|
+
const res = await fetch(`${BASE}/api/upload${uploadNetworkId()}`, {
|
|
138
148
|
method: "POST",
|
|
139
149
|
body: stream,
|
|
140
150
|
headers: { ...authHeaders(), "Content-Type": "multipart/form-data; boundary=xxx", "Transfer-Encoding": "chunked" },
|
|
@@ -150,7 +160,7 @@ describe("POST /api/upload — 6-item security checklist (HTTP integration)", ()
|
|
|
150
160
|
// this body" which is the security property we care about; the
|
|
151
161
|
// exact code depends on whether Bun's multipart parser bails before
|
|
152
162
|
// or after our Content-Type pre-check.
|
|
153
|
-
const res = await fetch(`${BASE}/api/upload`, {
|
|
163
|
+
const res = await fetch(`${BASE}/api/upload${uploadNetworkId()}`, {
|
|
154
164
|
method: "POST",
|
|
155
165
|
body: JSON.stringify({ nope: 1 }),
|
|
156
166
|
headers: { ...authHeaders(), "Content-Type": "application/json" },
|
|
@@ -161,12 +171,18 @@ describe("POST /api/upload — 6-item security checklist (HTTP integration)", ()
|
|
|
161
171
|
|
|
162
172
|
describe("GET /api/files/:file_id — download safety", () => {
|
|
163
173
|
let fileId = "";
|
|
174
|
+
let rangeFileId = "";
|
|
175
|
+
const rangeContent = Uint8Array.from({ length: 64 }, (_, i) => i);
|
|
164
176
|
|
|
165
177
|
beforeAll(async () => {
|
|
166
178
|
const content = new TextEncoder().encode("hello-world-from-#221-integration-test");
|
|
167
179
|
const res = await uploadFile(content, "greeting.txt", "text/plain");
|
|
168
180
|
const body = await res.json() as any;
|
|
169
181
|
fileId = body.file_id;
|
|
182
|
+
|
|
183
|
+
const rangeRes = await uploadFile(rangeContent, "range.bin", "application/octet-stream");
|
|
184
|
+
const rangeBody = await rangeRes.json() as any;
|
|
185
|
+
rangeFileId = rangeBody.file_id;
|
|
170
186
|
});
|
|
171
187
|
|
|
172
188
|
test("(item 3) download returns the blob with Content-Disposition: attachment + X-Content-Type-Options: nosniff", async () => {
|
|
@@ -175,10 +191,72 @@ describe("GET /api/files/:file_id — download safety", () => {
|
|
|
175
191
|
expect(res.headers.get("Content-Disposition") ?? "").toMatch(/^attachment(;|$)/i);
|
|
176
192
|
expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
|
177
193
|
expect(res.headers.get("Content-Type")).toBe("application/octet-stream");
|
|
194
|
+
expect(res.headers.get("Accept-Ranges")).toBe("bytes");
|
|
178
195
|
const text = await res.text();
|
|
179
196
|
expect(text).toBe("hello-world-from-#221-integration-test");
|
|
180
197
|
});
|
|
181
198
|
|
|
199
|
+
test("#514 Range bytes=a-b returns exact 206 partial content, not a tolerant 200/full body", async () => {
|
|
200
|
+
const res = await fetch(`${BASE}/api/files/${rangeFileId}`, {
|
|
201
|
+
headers: { ...authHeaders(), Range: "bytes=3-8" },
|
|
202
|
+
});
|
|
203
|
+
expect(res.status).toBe(206);
|
|
204
|
+
expect(res.headers.get("Content-Range")).toBe("bytes 3-8/64");
|
|
205
|
+
expect(res.headers.get("Content-Length")).toBe("6");
|
|
206
|
+
expect(res.headers.get("Accept-Ranges")).toBe("bytes");
|
|
207
|
+
const body = new Uint8Array(await res.arrayBuffer());
|
|
208
|
+
expect(body.length).toBe(6);
|
|
209
|
+
expect([...body]).toEqual([...rangeContent.slice(3, 9)]);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("#514 Range bytes=N- returns the exact open-ended tail", async () => {
|
|
213
|
+
const res = await fetch(`${BASE}/api/files/${rangeFileId}`, {
|
|
214
|
+
headers: { ...authHeaders(), Range: "bytes=60-" },
|
|
215
|
+
});
|
|
216
|
+
expect(res.status).toBe(206);
|
|
217
|
+
expect(res.headers.get("Content-Range")).toBe("bytes 60-63/64");
|
|
218
|
+
expect(res.headers.get("Content-Length")).toBe("4");
|
|
219
|
+
const body = new Uint8Array(await res.arrayBuffer());
|
|
220
|
+
expect(body.length).toBe(4);
|
|
221
|
+
expect([...body]).toEqual([...rangeContent.slice(60)]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("#514 Range bytes=-N returns the exact suffix", async () => {
|
|
225
|
+
const res = await fetch(`${BASE}/api/files/${rangeFileId}`, {
|
|
226
|
+
headers: { ...authHeaders(), Range: "bytes=-7" },
|
|
227
|
+
});
|
|
228
|
+
expect(res.status).toBe(206);
|
|
229
|
+
expect(res.headers.get("Content-Range")).toBe("bytes 57-63/64");
|
|
230
|
+
expect(res.headers.get("Content-Length")).toBe("7");
|
|
231
|
+
const body = new Uint8Array(await res.arrayBuffer());
|
|
232
|
+
expect(body.length).toBe(7);
|
|
233
|
+
expect([...body]).toEqual([...rangeContent.slice(57)]);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("#514 unsatisfiable Range returns 416 with concrete Content-Range size", async () => {
|
|
237
|
+
const res = await fetch(`${BASE}/api/files/${rangeFileId}`, {
|
|
238
|
+
headers: { ...authHeaders(), Range: "bytes=64-70" },
|
|
239
|
+
});
|
|
240
|
+
expect(res.status).toBe(416);
|
|
241
|
+
expect(res.headers.get("Content-Range")).toBe("bytes */64");
|
|
242
|
+
expect(res.headers.get("Content-Length")).toBe("0");
|
|
243
|
+
const body = new Uint8Array(await res.arrayBuffer());
|
|
244
|
+
expect(body.length).toBe(0);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("#514 HEAD Range mirrors GET status and range headers without a body", async () => {
|
|
248
|
+
const res = await fetch(`${BASE}/api/files/${rangeFileId}`, {
|
|
249
|
+
method: "HEAD",
|
|
250
|
+
headers: { ...authHeaders(), Range: "bytes=3-8" },
|
|
251
|
+
});
|
|
252
|
+
expect(res.status).toBe(206);
|
|
253
|
+
expect(res.headers.get("Content-Range")).toBe("bytes 3-8/64");
|
|
254
|
+
expect(res.headers.get("Content-Length")).toBe("6");
|
|
255
|
+
expect(res.headers.get("Accept-Ranges")).toBe("bytes");
|
|
256
|
+
const body = new Uint8Array(await res.arrayBuffer());
|
|
257
|
+
expect(body.length).toBe(0);
|
|
258
|
+
});
|
|
259
|
+
|
|
182
260
|
test("anonymous download is 401", async () => {
|
|
183
261
|
const res = await fetch(`${BASE}/api/files/${fileId}`);
|
|
184
262
|
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
|
+
});
|