@sleep2agi/commhub-server 0.9.0-preview.4 â 0.9.0-preview.40
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 +107 -13
- 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-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +80 -1
- package/src/create-node-validate.ts +49 -5
- package/src/create-node.ts +51 -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-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +622 -33
- package/src/delivered-without-push.test.ts +140 -0
- package/src/desktop-message.test.ts +286 -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 +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/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2187
- 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/node-id-alias.test.ts +147 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +51 -0
- package/src/observer-avatar-http.test.ts +424 -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/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 +163 -7
- package/src/reply-target-derivation.test.ts +96 -0
- 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 +212 -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-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +437 -0
- package/src/server.ts +3591 -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/start-node.test.ts +109 -0
- package/src/stop-delete-node.test.ts +952 -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 +471 -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 +2952 -191
- 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,140 @@
|
|
|
1
|
+
// f015d9d6 ççśâ ďźtasks.status 袍罎为 'delivered'ďźč SSE ć¨éć šćŹć˛Ąĺĺşă
|
|
2
|
+
//
|
|
3
|
+
// send_task ć ćĄäťśäťĽĺé˘é 'delivered' ćĺ
Ľ tasks čĄďźć¨éĺ´ĺŚĺ¤č˘Ť
|
|
4
|
+
// `if (target.state === "online")` ćĄçďźtools.ts:1432ďźăĺä¸ćŽľäťŁç éçť
|
|
5
|
+
// network observer çäşäťśćĺä¸äťśäşć ć "queued" ââ äšĺ°ąćŻčŻ´äťŁç **çĽé**
|
|
6
|
+
// ĺŽć˛Ąćĺşĺťďźĺ´äťçść delivered ĺčżäş tasks.statusă
|
|
7
|
+
//
|
|
8
|
+
// č "online" çĺ¤ćŽćŻ resolveDeliveryTarget éç
|
|
9
|
+
// stale = now - (last_seen_at || updated_at) > 5 ĺé
|
|
10
|
+
// ä¸ last_seen_at ĺŞĺ¨ report_status ćśĺˇć°ďźtools.ts:674/702ďźă
|
|
11
|
+
// ĺ
¨ç˝ăĺĺĺżčˇł/çśćçąťćśćŻăççé˘ĺşŚč§ĺäšä¸ďźä¸ä¸ŞĺŽéç MCP äźčŻĺż
çśĺ staleă
|
|
12
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
13
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
14
|
+
import { db } from "./db.js";
|
|
15
|
+
import { registerTools } from "./tools.js";
|
|
16
|
+
import { __resetSSEClientsForTest, createSSEStream } from "./push.js";
|
|
17
|
+
|
|
18
|
+
const NET = "net_delivered_push_f015";
|
|
19
|
+
const USER = "user_delivered_push_f015";
|
|
20
|
+
const SENDER = "sender-f015b";
|
|
21
|
+
const TARGET = "quiet-mcp-f015b";
|
|
22
|
+
|
|
23
|
+
type ToolHandler = (args: any) => Promise<{ content: Array<{ type: "text"; text: string }> }>;
|
|
24
|
+
|
|
25
|
+
function cleanup() {
|
|
26
|
+
__resetSSEClientsForTest();
|
|
27
|
+
for (const t of ["tasks", "inbox", "task_events", "sessions", "api_tokens", "nodes", "rename_txn"]) {
|
|
28
|
+
try { db.run(`DELETE FROM ${t} WHERE network_id = ?1`, [NET]); } catch {}
|
|
29
|
+
}
|
|
30
|
+
try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET]); } catch {}
|
|
31
|
+
try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET]); } catch {}
|
|
32
|
+
try { db.run("DELETE FROM users WHERE user_id = ?1", [USER]); } catch {}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** minutesAgo ć§ĺśçŽć äźčŻç last_seen_at ââ čżćŻćŹćäťśĺŻä¸čŚĺ¨çĺéă */
|
|
36
|
+
function seed(minutesAgo: number) {
|
|
37
|
+
db.run("INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?1, 'x', 'admin', datetime('now'))", [USER]);
|
|
38
|
+
db.run("INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))", [NET, USER]);
|
|
39
|
+
db.run("INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))", [USER, NET]);
|
|
40
|
+
for (const alias of [SENDER, TARGET]) {
|
|
41
|
+
db.run(
|
|
42
|
+
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, 'network')",
|
|
43
|
+
[`token-${alias}`, `hash-${alias}`, USER, NET, `node:${alias}`],
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
db.run(
|
|
47
|
+
"INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at) VALUES (?1, ?1, 'idle', NULL, ?2, datetime('now'), datetime('now', ?3))",
|
|
48
|
+
[SENDER, NET, "-1 minutes"],
|
|
49
|
+
);
|
|
50
|
+
// đ´ çŽć ďźMCP ćč˝˝ç claude-code äźčŻĺ˝˘çśďźnode_id NULLďźďź
|
|
51
|
+
// status ćŻ 'idle'ďźä¸ćŻ offlineďźďźĺŞćŻĺŽéäş minutesAgo ĺéă
|
|
52
|
+
db.run(
|
|
53
|
+
"INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at) VALUES (?1, ?2, 'idle', NULL, ?3, datetime('now', ?4), datetime('now', ?4))",
|
|
54
|
+
[`cc-n_${TARGET}`, TARGET, NET, `-${minutesAgo} minutes`],
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function toolsFor(alias: string): Record<string, ToolHandler> {
|
|
59
|
+
const server = new McpServer({ name: "delivered-push-f015", version: "0" }) as any;
|
|
60
|
+
const tools: Record<string, ToolHandler> = {};
|
|
61
|
+
const original = server.tool.bind(server);
|
|
62
|
+
server.tool = (name: string, description: string, schema: any, handler: ToolHandler) => {
|
|
63
|
+
tools[name] = handler; return original(name, description, schema, handler);
|
|
64
|
+
};
|
|
65
|
+
registerTools(server, undefined, NET, USER, alias, true, `token-${alias}`);
|
|
66
|
+
return tools;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** äťčŽ˘é
č
éŁä¸äž§çćŁčŻťĺşĺč ââ ĺ°é˘çç¸ďźä¸ćŻčżćĽčŽĄć°ă */
|
|
70
|
+
async function readUntil(reader: ReadableStreamDefaultReader<Uint8Array>, needle: string, ms = 1500): Promise<string | null> {
|
|
71
|
+
const dec = new TextDecoder();
|
|
72
|
+
let buf = "";
|
|
73
|
+
const deadline = Date.now() + ms;
|
|
74
|
+
while (Date.now() < deadline) {
|
|
75
|
+
const chunk = await Promise.race([
|
|
76
|
+
reader.read(),
|
|
77
|
+
new Promise<{ done: true; value: undefined }>((r) => setTimeout(() => r({ done: true, value: undefined }), 200)),
|
|
78
|
+
]);
|
|
79
|
+
if (chunk.value) buf += dec.decode(chunk.value, { stream: true });
|
|
80
|
+
if (buf.includes(needle)) return buf;
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let sendSeq = 0;
|
|
86
|
+
/** minutesAgo = çŽć ĺŽéäşĺ¤äš
ďźwithSubscriber = ćŻĺŚççćäşşčżçă */
|
|
87
|
+
async function send(minutesAgo: number, withSubscriber = true) {
|
|
88
|
+
cleanup(); seed(minutesAgo);
|
|
89
|
+
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
|
90
|
+
if (withSubscriber) {
|
|
91
|
+
const res = createSSEStream(TARGET, NET);
|
|
92
|
+
reader = res.body!.getReader();
|
|
93
|
+
}
|
|
94
|
+
const r = await toolsFor(SENDER).send_task({ alias: TARGET, task: `ping-${++sendSeq}`, priority: "normal" });
|
|
95
|
+
const out = JSON.parse(r.content[0].text);
|
|
96
|
+
const row = db.get<{ status: string }>("SELECT status FROM tasks WHERE to_name = ?1 AND network_id = ?2", TARGET, NET);
|
|
97
|
+
const received = reader ? await readUntil(reader, "new_task") : null;
|
|
98
|
+
return { out, taskStatus: row?.status ?? null, received };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
describe("f015d9d6 ççśâ â delivered 罎ä˝ä¸ĺŽé
ć¨éä¸ĺćĽ", () => {
|
|
102
|
+
beforeEach(() => cleanup());
|
|
103
|
+
afterAll(() => cleanup());
|
|
104
|
+
|
|
105
|
+
test("寚ç
§ďźçŽć 1 ĺéĺčżć´ťčˇ â 莢é
č
ćśĺ° new_task", async () => {
|
|
106
|
+
const { taskStatus, received } = await send(1);
|
|
107
|
+
expect(taskStatus).toBe("delivered");
|
|
108
|
+
expect(received).toContain("new_task");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("đ´ ć ¸ĺżďźçŽć ĺŽé 10 ĺéä˝čŽ˘é
č
čżç â äťçśćśĺžĺ°ďźäżŽĺ¤ĺčżéćśĺ° nullďź", async () => {
|
|
112
|
+
const { taskStatus, received } = await send(10);
|
|
113
|
+
expect(received).toContain("new_task");
|
|
114
|
+
expect(taskStatus).toBe("delivered"); // ç°ĺ¨čżä¸Ş delivered ćŻçčŻäş
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("đ´ ĺŻčžžć§ĺ¤ćŽĺż
饝ćĽčŞčŽ˘é
č
注ĺ襨ďźä¸ćŻ last_seen_at çćśé´ćłçćľ", async () => {
|
|
118
|
+
// ĺŽé 10 ĺé = ćśé´ćłĺ¤ćŽäźčŻ´ offlineďźčĺŽé
ä¸ćäşşĺ¨ĺŹă
|
|
119
|
+
const quiet = await send(10);
|
|
120
|
+
expect(quiet.received).toContain("new_task");
|
|
121
|
+
// ç沥人ĺŹçćśĺďźpushEvent ćŹćĽĺ°ąćŻ no-opďźä¸éčŚé˘ĺ¤çé¸ćĽäżć¤ă
|
|
122
|
+
const nobody = await send(10, false);
|
|
123
|
+
expect(nobody.received).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("çç沥ć莢é
č
ćśďźtasks čĄäťĺ delivered ââ ćŹ PR ĺťćä¸ćšďźčްĺ˝ĺ¨ćĄ", async () => {
|
|
127
|
+
const { taskStatus, received } = await send(10, false);
|
|
128
|
+
expect(received).toBeNull();
|
|
129
|
+
// čżä¸ćĄéçćŻ**ĺ˝ĺčĄä¸ş**ďźä¸ćŻć莤为寚çčĄä¸şďźć˛Ąäşşćśĺ°ďźčĄäťčŞç§° deliveredă
|
|
130
|
+
expect(taskStatus).toBe("delivered");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("ć´ťć§ĺ¤ćŽçčžĺ
Ľ last_seen_at ĺŞçą report_status ĺˇć° ââ ĺéćšä¸ĺˇć°ĺŽ", async () => {
|
|
134
|
+
cleanup(); seed(4);
|
|
135
|
+
const before = db.get<{ last_seen_at: string }>("SELECT last_seen_at FROM sessions WHERE alias = ?1 AND network_id = ?2", TARGET, NET);
|
|
136
|
+
await toolsFor(SENDER).send_task({ alias: TARGET, task: `ping-${++sendSeq}`, priority: "normal" });
|
|
137
|
+
const after = db.get<{ last_seen_at: string }>("SELECT last_seen_at FROM sessions WHERE alias = ?1 AND network_id = ?2", TARGET, NET);
|
|
138
|
+
expect(after?.last_seen_at).toBe(before!.last_seen_at);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// Desktop user push contract.
|
|
2
|
+
//
|
|
3
|
+
// Pins the new user-keyed SSE path and send_desktop_message MCP tool:
|
|
4
|
+
// - user streams are keyed by networkId:userId, never alias
|
|
5
|
+
// - ntok callers cannot cross network by supplying a foreign to_user_id/network_id
|
|
6
|
+
// - target ambiguity/miss is fail-closed before audit/push
|
|
7
|
+
|
|
8
|
+
import { afterAll, afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
10
|
+
import { db } from "./db.js";
|
|
11
|
+
import { registerTools } from "./tools.js";
|
|
12
|
+
import {
|
|
13
|
+
__resetSSEClientsForTest,
|
|
14
|
+
createUserEventStream,
|
|
15
|
+
pushUserEvent,
|
|
16
|
+
} from "./push.js";
|
|
17
|
+
|
|
18
|
+
const NET_A = "net_desktop_a";
|
|
19
|
+
const NET_B = "net_desktop_b";
|
|
20
|
+
const NODE_USER = "u_desktop_node_owner";
|
|
21
|
+
const SENDER_USER = "u_desktop_sender";
|
|
22
|
+
const TARGET_A = "u_desktop_target_a";
|
|
23
|
+
const TARGET_B = "u_desktop_target_b";
|
|
24
|
+
const TARGET_BOTH = "u_desktop_target_both";
|
|
25
|
+
const ALL_USERS = [NODE_USER, SENDER_USER, TARGET_A, TARGET_B, TARGET_BOTH];
|
|
26
|
+
|
|
27
|
+
type ToolHandler = (args: any) => Promise<{ content: Array<{ type: "text"; text: string }> }>;
|
|
28
|
+
|
|
29
|
+
function cleanup() {
|
|
30
|
+
try { db.run("DELETE FROM audit_log WHERE action = 'send_desktop_message' OR network_id IN (?1, ?2)", [NET_A, NET_B]); } catch {}
|
|
31
|
+
try { db.run("DELETE FROM network_members WHERE network_id IN (?1, ?2)", [NET_A, NET_B]); } catch {}
|
|
32
|
+
try { db.run("DELETE FROM networks WHERE network_id IN (?1, ?2)", [NET_A, NET_B]); } catch {}
|
|
33
|
+
for (const u of ALL_USERS) {
|
|
34
|
+
try { db.run("DELETE FROM users WHERE user_id = ?1", [u]); } catch {}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function seed() {
|
|
39
|
+
for (const u of ALL_USERS) {
|
|
40
|
+
db.run(
|
|
41
|
+
`INSERT INTO users (user_id, username, password_hash, role, created_at)
|
|
42
|
+
VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
|
|
43
|
+
[u, `${u}_name`],
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [NET_A, SENDER_USER]);
|
|
47
|
+
db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [NET_B, TARGET_B]);
|
|
48
|
+
const member = (userId: string, networkId: string, role: string) =>
|
|
49
|
+
db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, ?3, datetime('now'))`, [userId, networkId, role]);
|
|
50
|
+
member(NODE_USER, NET_A, "member");
|
|
51
|
+
member(SENDER_USER, NET_A, "owner");
|
|
52
|
+
member(TARGET_A, NET_A, "member");
|
|
53
|
+
member(TARGET_B, NET_B, "member");
|
|
54
|
+
member(TARGET_BOTH, NET_A, "member");
|
|
55
|
+
member(TARGET_BOTH, NET_B, "member");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
beforeEach(() => { cleanup(); seed(); });
|
|
59
|
+
afterEach(() => { __resetSSEClientsForTest(); });
|
|
60
|
+
afterAll(cleanup);
|
|
61
|
+
|
|
62
|
+
function buildHandlers(opts: { netId?: string | null; userId?: string | null; alias?: string | null; isNetwork?: boolean }) {
|
|
63
|
+
const server = new McpServer({ name: "test", version: "0" }) as any;
|
|
64
|
+
const tools: Record<string, ToolHandler> = {};
|
|
65
|
+
const origTool = server.tool.bind(server);
|
|
66
|
+
server.tool = (name: string, _desc: string, schema: any, handler: ToolHandler) => {
|
|
67
|
+
tools[name] = handler;
|
|
68
|
+
return origTool(name, _desc, schema, handler);
|
|
69
|
+
};
|
|
70
|
+
registerTools(
|
|
71
|
+
server,
|
|
72
|
+
undefined,
|
|
73
|
+
opts.netId ?? null,
|
|
74
|
+
opts.userId ?? null,
|
|
75
|
+
opts.alias ?? opts.userId ?? null,
|
|
76
|
+
opts.isNetwork ?? false,
|
|
77
|
+
"tok_desktop_test",
|
|
78
|
+
);
|
|
79
|
+
return tools;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function call(handler: ToolHandler, args: any): Promise<Record<string, any>> {
|
|
83
|
+
const r = await handler(args);
|
|
84
|
+
return JSON.parse(r.content[0].text);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function readFrame(
|
|
88
|
+
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
89
|
+
timeoutMs = 2_000,
|
|
90
|
+
): Promise<Record<string, any>> {
|
|
91
|
+
const decoder = new TextDecoder();
|
|
92
|
+
let buf = "";
|
|
93
|
+
const deadline = Date.now() + timeoutMs;
|
|
94
|
+
while (Date.now() < deadline) {
|
|
95
|
+
const result = await Promise.race([
|
|
96
|
+
reader.read(),
|
|
97
|
+
new Promise<never>((_, rej) =>
|
|
98
|
+
setTimeout(() => rej(new Error(`no SSE frame within ${timeoutMs}ms`)), deadline - Date.now()),
|
|
99
|
+
),
|
|
100
|
+
]);
|
|
101
|
+
if (result.done) throw new Error("SSE stream ended unexpectedly");
|
|
102
|
+
buf += decoder.decode(result.value, { stream: true });
|
|
103
|
+
const sep = buf.indexOf("\n\n");
|
|
104
|
+
if (sep === -1) continue;
|
|
105
|
+
const rawFrame = buf.slice(0, sep);
|
|
106
|
+
buf = buf.slice(sep + 2);
|
|
107
|
+
const dataLine = rawFrame.split("\n").find((l) => l.startsWith("data: "));
|
|
108
|
+
if (!dataLine) continue;
|
|
109
|
+
return JSON.parse(dataLine.slice(6));
|
|
110
|
+
}
|
|
111
|
+
throw new Error(`no SSE frame within ${timeoutMs}ms`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function auditCount() {
|
|
115
|
+
return db.get<{ n: number }>("SELECT COUNT(*) AS n FROM audit_log WHERE action = 'send_desktop_message'")?.n ?? 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
describe("send_desktop_message", () => {
|
|
119
|
+
test("ntok caller can push to a user in the same bound network", async () => {
|
|
120
|
+
const stream = createUserEventStream(NET_A, TARGET_A);
|
|
121
|
+
const reader = stream.body!.getReader();
|
|
122
|
+
await readFrame(reader);
|
|
123
|
+
|
|
124
|
+
const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
|
|
125
|
+
const result = await call(tools.send_desktop_message, {
|
|
126
|
+
to_user_id: TARGET_A,
|
|
127
|
+
message: "desktop hello",
|
|
128
|
+
severity: "success",
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(result.ok).toBe(true);
|
|
132
|
+
expect(result.message_id).toStartWith("dm_");
|
|
133
|
+
expect(auditCount()).toBe(1);
|
|
134
|
+
const evt = await readFrame(reader);
|
|
135
|
+
expect(evt.type).toBe("desktop_message");
|
|
136
|
+
expect(evt.message_id).toBe(result.message_id);
|
|
137
|
+
expect(evt.message).toBe("desktop hello");
|
|
138
|
+
expect(evt.from).toBe("node-a");
|
|
139
|
+
expect(evt.network_id).toBe(NET_A);
|
|
140
|
+
expect(evt.user_id).toBe(TARGET_A);
|
|
141
|
+
expect(evt.scope).toBe("user");
|
|
142
|
+
await reader.cancel();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("utok caller can push with explicit network_id to a member user", async () => {
|
|
146
|
+
const stream = createUserEventStream(NET_A, TARGET_A);
|
|
147
|
+
const reader = stream.body!.getReader();
|
|
148
|
+
await readFrame(reader);
|
|
149
|
+
|
|
150
|
+
const tools = buildHandlers({ userId: SENDER_USER, alias: "sender-user" });
|
|
151
|
+
const result = await call(tools.send_desktop_message, {
|
|
152
|
+
to_username: `${TARGET_A}_name`,
|
|
153
|
+
network_id: NET_A,
|
|
154
|
+
title: "Heads up",
|
|
155
|
+
message: "from user token",
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
expect(result.ok).toBe(true);
|
|
159
|
+
const evt = await readFrame(reader);
|
|
160
|
+
expect(evt.title).toBe("Heads up");
|
|
161
|
+
expect(evt.message).toBe("from user token");
|
|
162
|
+
expect(evt.network_id).toBe(NET_A);
|
|
163
|
+
await reader.cancel();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("ntok caller cannot cross network with foreign network_id and to_user_id; no audit or push happens first", async () => {
|
|
167
|
+
const stream = createUserEventStream(NET_B, TARGET_B);
|
|
168
|
+
const reader = stream.body!.getReader();
|
|
169
|
+
await readFrame(reader);
|
|
170
|
+
|
|
171
|
+
const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
|
|
172
|
+
const result = await call(tools.send_desktop_message, {
|
|
173
|
+
to_user_id: TARGET_B,
|
|
174
|
+
network_id: NET_B,
|
|
175
|
+
message: "must not cross",
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
expect(result).toEqual({ ok: false, error: "desktop_target_not_in_network", network_id: NET_A });
|
|
179
|
+
expect(auditCount()).toBe(0);
|
|
180
|
+
|
|
181
|
+
pushUserEvent(NET_B, TARGET_B, { type: "desktop_message", message_id: "marker_after_denied" });
|
|
182
|
+
expect((await readFrame(reader)).message_id).toBe("marker_after_denied");
|
|
183
|
+
await reader.cancel();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("missing target is fail-closed before audit", async () => {
|
|
187
|
+
const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
|
|
188
|
+
const result = await call(tools.send_desktop_message, { message: "nobody" });
|
|
189
|
+
expect(result).toEqual({
|
|
190
|
+
ok: false,
|
|
191
|
+
error: "desktop_target_required",
|
|
192
|
+
message: "to_user_id or to_username is required",
|
|
193
|
+
});
|
|
194
|
+
expect(auditCount()).toBe(0);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("conflicting to_user_id and to_username is fail-closed before audit", async () => {
|
|
198
|
+
const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
|
|
199
|
+
const result = await call(tools.send_desktop_message, {
|
|
200
|
+
to_user_id: TARGET_A,
|
|
201
|
+
to_username: `${TARGET_BOTH}_name`,
|
|
202
|
+
message: "conflict",
|
|
203
|
+
});
|
|
204
|
+
expect(result).toEqual({
|
|
205
|
+
ok: false,
|
|
206
|
+
error: "desktop_target_mismatch",
|
|
207
|
+
to_user_id: TARGET_A,
|
|
208
|
+
to_username: `${TARGET_BOTH}_name`,
|
|
209
|
+
});
|
|
210
|
+
expect(auditCount()).toBe(0);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("unknown target is fail-closed before audit", async () => {
|
|
214
|
+
const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
|
|
215
|
+
const result = await call(tools.send_desktop_message, {
|
|
216
|
+
to_username: "not_a_real_user",
|
|
217
|
+
message: "lost",
|
|
218
|
+
});
|
|
219
|
+
expect(result).toEqual({ ok: false, error: "desktop_target_not_found", field: "to_username" });
|
|
220
|
+
expect(auditCount()).toBe(0);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// #1459 ââ čżĺĺźĺż
饝ĺć çĺŽćéćă
|
|
225
|
+
//
|
|
226
|
+
// send_desktop_message ćŻ fire-and-forgetďźĺŞ INSERT audit_logďźä¸ĺ inboxďźďź
|
|
227
|
+
// ĺ pushUserEvent ćĺşďźč pushUserEvent ĺ¨ć˛Ąć莢é
č
ćśééť returnă
|
|
228
|
+
// ç¨ćˇ dashboard 猝线/éčżćśćśćŻć°¸äš
丢ďźć DB čĄ â ć äť reconcileďź
|
|
229
|
+
// /api/messages ĺŞ SELECT ... FROM inboxďźďźčč°ç¨ćšćżĺ°çćŻ ok:trueă
|
|
230
|
+
//
|
|
231
|
+
// ćŹ PR ĺŞĺă莊丢夹ĺŻč§ăčżä¸ĺďźä¸ç˘° schemaďźčżĺĺźčŻ´ĺŽčŻă
|
|
232
|
+
// ćäš
ĺ + éčżčĄĽćďźčŽŠä¸˘ĺ¤ąä¸ĺçďźćŻĺŚä¸ćĄďźćšĺ塲ĺŽä¸şć user_id 寝ĺçć°čĄ¨ă
|
|
233
|
+
describe("#1459 send_desktop_message čżĺĺźĺć çĺŽćéć", () => {
|
|
234
|
+
test("đ´ ć ć´ťčˇčŽ˘é
č
â delivered:false + ĺŻĺ¤čŻťç reasonďźčä¸ćŻçŹźçť ok:true", async () => {
|
|
235
|
+
// ć
ćä¸ĺťş SSE ćľďźčżćŁćŻç¨ćˇ dashboard ĺ
łççć
形
|
|
236
|
+
const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
|
|
237
|
+
const before = auditCount();
|
|
238
|
+
const result = await call(tools.send_desktop_message, {
|
|
239
|
+
to_user_id: TARGET_A,
|
|
240
|
+
message: "nobody is listening",
|
|
241
|
+
severity: "info",
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// ok äťä¸ş trueďźč°ç¨č˘ŤćĽĺă厥莥塲č˝ĺş ââ čżä¸ć źć˛Ąĺ
|
|
245
|
+
expect(result.ok).toBe(true);
|
|
246
|
+
expect(result.message_id).toStartWith("dm_");
|
|
247
|
+
expect(auditCount()).toBe(before + 1);
|
|
248
|
+
|
|
249
|
+
// ć°ĺ˘çéŁä¸ć źďźćéć
|
|
250
|
+
expect(result.delivered).toBe(false);
|
|
251
|
+
expect(result.reason).toBe("no_live_subscriber");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("ćć´ťčˇčŽ˘é
č
â delivered:true ä¸ä¸ĺ¸Ś reason", async () => {
|
|
255
|
+
const stream = createUserEventStream(NET_A, TARGET_A);
|
|
256
|
+
const reader = stream.body!.getReader();
|
|
257
|
+
await readFrame(reader);
|
|
258
|
+
|
|
259
|
+
const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
|
|
260
|
+
const result = await call(tools.send_desktop_message, {
|
|
261
|
+
to_user_id: TARGET_A,
|
|
262
|
+
message: "someone is listening",
|
|
263
|
+
severity: "info",
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
expect(result.delivered).toBe(true);
|
|
267
|
+
expect(result.reason).toBeUndefined();
|
|
268
|
+
// ĺććč¨ďźčżä¸ćĄççéĺ°äşčŽ˘é
č
ćä¸ďźĺŚĺ delivered:true ĺ°ąćŻçŠşčŻ
|
|
269
|
+
const evt = await readFrame(reader);
|
|
270
|
+
expect(evt.message).toBe("someone is listening");
|
|
271
|
+
await reader.cancel();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("đ´ 两ç§ć
形ĺż
饝çťĺşä¸ĺčŻťć° ââ é˛ć˘ĺ¤ćŽéĺććç", async () => {
|
|
275
|
+
const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
|
|
276
|
+
const offline = await call(tools.send_desktop_message, { to_user_id: TARGET_A, message: "off", severity: "info" });
|
|
277
|
+
|
|
278
|
+
const stream = createUserEventStream(NET_A, TARGET_A);
|
|
279
|
+
const reader = stream.body!.getReader();
|
|
280
|
+
await readFrame(reader);
|
|
281
|
+
const online = await call(tools.send_desktop_message, { to_user_id: TARGET_A, message: "on", severity: "info" });
|
|
282
|
+
await reader.cancel();
|
|
283
|
+
|
|
284
|
+
expect(offline.delivered).not.toBe(online.delivered);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
buildStoragePath,
|
|
4
|
+
pathForExistingBlob,
|
|
5
|
+
validateIndexEntry,
|
|
6
|
+
} from "./uploads.js";
|
|
7
|
+
|
|
8
|
+
const fileId = "0123456789abcdef0123456789abcdef";
|
|
9
|
+
const dateBucket = "2026-08-09";
|
|
10
|
+
const uploadsRoot = "/srv/uploads";
|
|
11
|
+
|
|
12
|
+
const malicious = [
|
|
13
|
+
"../x",
|
|
14
|
+
"/abs/x",
|
|
15
|
+
".png/../../y",
|
|
16
|
+
".p ng",
|
|
17
|
+
".p\0ng",
|
|
18
|
+
".\\..\\x",
|
|
19
|
+
"." + "a".repeat(17),
|
|
20
|
+
".tar.gz",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const legal = ["", ".png", ".PNG", ".mp4", "." + "a".repeat(16)];
|
|
24
|
+
|
|
25
|
+
describe("#527 shared extension-token invariant", () => {
|
|
26
|
+
test("new-upload path rejects every malformed token and accepts the shared legal set", () => {
|
|
27
|
+
for (const ext of malicious) {
|
|
28
|
+
expect(() => buildStoragePath(fileId, ext, { uploadsRoot })).toThrow(/ext .* is invalid/);
|
|
29
|
+
}
|
|
30
|
+
for (const ext of legal) {
|
|
31
|
+
expect(buildStoragePath(fileId, ext, { uploadsRoot }).ext).toBe(ext);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("existing-blob path rejects every malformed token and accepts the shared legal set", () => {
|
|
36
|
+
for (const ext of malicious) {
|
|
37
|
+
expect(() => pathForExistingBlob(dateBucket, fileId, ext, { uploadsRoot })).toThrow(/ext .* is invalid/);
|
|
38
|
+
}
|
|
39
|
+
for (const ext of legal) {
|
|
40
|
+
expect(pathForExistingBlob(dateBucket, fileId, ext, { uploadsRoot }).ext).toBe(ext);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("stored-index gate rejects every malformed token and accepts the shared legal set", () => {
|
|
45
|
+
for (const ext of malicious) {
|
|
46
|
+
expect(validateIndexEntry({ file_id: fileId, date_bucket: dateBucket, ext, size: 0 })).toBe(false);
|
|
47
|
+
}
|
|
48
|
+
for (const ext of legal) {
|
|
49
|
+
expect(validateIndexEntry({ file_id: fileId, date_bucket: dateBucket, ext, size: 0 })).toBe(true);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("all three boundaries fail closed on non-string runtime values", () => {
|
|
54
|
+
for (const ext of [undefined, null, 7, {}, []]) {
|
|
55
|
+
expect(() => buildStoragePath(fileId, ext as never, { uploadsRoot })).toThrow(/ext .* is invalid/);
|
|
56
|
+
expect(() => pathForExistingBlob(dateBucket, fileId, ext as never, { uploadsRoot })).toThrow(/ext .* is invalid/);
|
|
57
|
+
expect(validateIndexEntry({ file_id: fileId, date_bucket: dateBucket, ext, size: 0 })).toBe(false);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Task #25 â regression pins for ext validation in validateIndexEntry.
|
|
2
|
+
//
|
|
3
|
+
// đ´ SCOPE: this suite exercises the stored-index boundary through
|
|
4
|
+
// `validateIndexEntry`. Issue #527 removed the three independent copies
|
|
5
|
+
// of the stored extension-token grammar: buildStoragePath,
|
|
6
|
+
// pathForExistingBlob and validateIndexEntry now use one shared private
|
|
7
|
+
// validator. `ext-token-shared.test.ts` separately pins all three call
|
|
8
|
+
// sites. sanitizeExt remains intentionally independent because it parses
|
|
9
|
+
// a client filename rather than validating an already-extracted token.
|
|
10
|
+
//
|
|
11
|
+
// Background (éäżĄéž d01bb8ce): the stored extension-token grammar
|
|
12
|
+
// `/^\.[A-Za-z0-9]{1,16}$/` correctly rejects 11 known malicious ext
|
|
13
|
+
// values (path-escape, traversal, absolute paths, doubles, NUL bytes,
|
|
14
|
+
// backslash forms, over-length, whitespace). Verified via real-run on
|
|
15
|
+
// origin/main by lead â 11/11 malicious values â false.
|
|
16
|
+
//
|
|
17
|
+
// But NO test locked that behavior. Anyone who widens the regex in the
|
|
18
|
+
// future (accidentally or on purpose) would not hit a red test â the
|
|
19
|
+
// live protection would degrade silently. This suite pins current
|
|
20
|
+
// behavior in two directions:
|
|
21
|
+
//
|
|
22
|
+
// 1. SAFETY (ćŹ äżŽ direction) â 11 malicious values â false, one row
|
|
23
|
+
// per value with the exact literal. If regex is widened to accept
|
|
24
|
+
// any of them, that row turns red.
|
|
25
|
+
// 2. NON-REGRESSION (čżäżŽ direction) â a set of common legitimate
|
|
26
|
+
// extensions â true. If regex is tightened to reject any of them
|
|
27
|
+
// (e.g. someone locks down to a whitelist), that row turns red.
|
|
28
|
+
//
|
|
29
|
+
// Witnessed-red for the SAFETY rows: temporarily widen the regex to
|
|
30
|
+
// `/./` (or comment it out) in validateIndexEntry, run this suite,
|
|
31
|
+
// confirm all 11 malicious rows turn red, then revert. The mutation
|
|
32
|
+
// itself is not committed; the assertion IS the mutation-red proof
|
|
33
|
+
// for the reader. Recording the mutation output in the followup PR
|
|
34
|
+
// body (per Constraint 3 discipline established in #503).
|
|
35
|
+
//
|
|
36
|
+
// Pure unit test â no server setup, no DB, no filesystem. The regex
|
|
37
|
+
// lives in a pure function, so the guard is testable at that level.
|
|
38
|
+
|
|
39
|
+
import { describe, expect, test } from "bun:test";
|
|
40
|
+
import { validateIndexEntry } from "./uploads.js";
|
|
41
|
+
|
|
42
|
+
// A structurally-valid base entry â file_id / date_bucket / size all
|
|
43
|
+
// pass their own checks â so `ext` is the only field a test row varies.
|
|
44
|
+
// This isolates the ext regex as the deciding factor.
|
|
45
|
+
const validBase = {
|
|
46
|
+
file_id: "0123456789abcdef0123456789abcdef",
|
|
47
|
+
date_bucket: "2026-07-30",
|
|
48
|
+
size: 0,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
describe("Task #25 (#503 followup) â ext validation regression pins", () => {
|
|
52
|
+
describe("SAFETY: the 11 malicious ext values from lead d01bb8ce all return false", () => {
|
|
53
|
+
// Each row uses the exact literal from lead's verification list so
|
|
54
|
+
// a widening regex catches on the same value the manual audit did.
|
|
55
|
+
const malicious: Array<{ label: string; ext: string }> = [
|
|
56
|
+
{ label: "path-escape prefix", ext: "../x" },
|
|
57
|
+
{ label: "deep traversal to /etc", ext: "../../etc/passwd" },
|
|
58
|
+
{ label: "absolute path", ext: "/abs/x" },
|
|
59
|
+
{ label: "double-dot alone", ext: ".." },
|
|
60
|
+
{ label: "single-dot alone", ext: "." },
|
|
61
|
+
{ label: "chained after legit prefix", ext: ".png/../../y" },
|
|
62
|
+
{ label: "embedded whitespace", ext: ".p ng" },
|
|
63
|
+
{ label: "over-length (>16 chars)", ext: "." + "a".repeat(17) },
|
|
64
|
+
{ label: "embedded NUL byte", ext: ".p\x00ng" },
|
|
65
|
+
{ label: "backslash traversal", ext: ".\\..\\x" },
|
|
66
|
+
{ label: "double-backslash prefix", ext: "..\\x" },
|
|
67
|
+
];
|
|
68
|
+
for (const { label, ext } of malicious) {
|
|
69
|
+
test(`rejects: ${label} â ext=${JSON.stringify(ext)}`, () => {
|
|
70
|
+
expect(validateIndexEntry({ ...validBase, ext })).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
test("precondition: 11 malicious cases enumerated (defends against silent test-shrink)", () => {
|
|
75
|
+
// If someone deletes rows from the malicious list, this fails
|
|
76
|
+
// instead of the deletion silently narrowing the guard. Number
|
|
77
|
+
// matches lead's verification list â bump only when lead adds a
|
|
78
|
+
// case and confirms it via real-run.
|
|
79
|
+
expect(malicious.length).toBe(11);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("NON-REGRESSION: common legitimate extensions all return true (é˛čżäżŽ)", () => {
|
|
84
|
+
// These are the extensions the live upload path actually produces
|
|
85
|
+
// via sanitizeExt / mime-typed multipart uploads. If someone
|
|
86
|
+
// tightens the regex to a stricter whitelist, any of these turning
|
|
87
|
+
// red flags a break in a normal user path.
|
|
88
|
+
const legal = [".png", ".pdf", ".pptx", ".jpg", ".jpeg", ".txt", ".mp4", ".zip", ".json", ".md"];
|
|
89
|
+
for (const ext of legal) {
|
|
90
|
+
test(`accepts: ext=${JSON.stringify(ext)}`, () => {
|
|
91
|
+
expect(validateIndexEntry({ ...validBase, ext })).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
test("empty ext is accepted (file with no extension is a legitimate upload)", () => {
|
|
96
|
+
expect(validateIndexEntry({ ...validBase, ext: "" })).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("precondition: at least 10 legitimate ext values pinned", () => {
|
|
100
|
+
// Same discipline as the malicious side â reject silent shrink.
|
|
101
|
+
expect(legal.length).toBeGreaterThanOrEqual(10);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("regex shape assumptions (documented, not enforced by the function)", () => {
|
|
106
|
+
// These rows document facts the current regex enforces beyond the
|
|
107
|
+
// 11 malicious literals. They are close to the boundary and would
|
|
108
|
+
// be the first things to move if someone reworked the regex.
|
|
109
|
+
test("upper-case extensions accepted (regex is case-insensitive by [A-Za-z])", () => {
|
|
110
|
+
expect(validateIndexEntry({ ...validBase, ext: ".PNG" })).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
test("mixed-case extensions accepted", () => {
|
|
113
|
+
expect(validateIndexEntry({ ...validBase, ext: ".PdF" })).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
test("digits after dot accepted (e.g. .mp4, .mp3)", () => {
|
|
116
|
+
expect(validateIndexEntry({ ...validBase, ext: ".mp4" })).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
test("exactly 16 chars after dot is accepted (upper bound of {1,16})", () => {
|
|
119
|
+
expect(validateIndexEntry({ ...validBase, ext: "." + "a".repeat(16) })).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
test("exactly 17 chars after dot is rejected (immediately past upper bound)", () => {
|
|
122
|
+
expect(validateIndexEntry({ ...validBase, ext: "." + "a".repeat(17) })).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
test("compound extensions like .tar.gz are rejected (contains inner dot)", () => {
|
|
125
|
+
// Documented: the current regex does not support compound
|
|
126
|
+
// extensions. If we ever want .tar.gz support, it needs its
|
|
127
|
+
// own explicit design + this row updated with lead sign-off.
|
|
128
|
+
expect(validateIndexEntry({ ...validBase, ext: ".tar.gz" })).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
});
|