@sleep2agi/commhub-server 0.9.0-preview.8 → 0.9.0-preview.80

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.
Files changed (202) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +92 -11
  3. package/package.json +5 -5
  4. package/src/ack-create-request-transport.test.ts +159 -0
  5. package/src/ack-create-request.test.ts +5 -5
  6. package/src/admin-delete-network-http.test.ts +228 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/agent-access-batch.test.ts +210 -0
  9. package/src/agent-access.ts +478 -0
  10. package/src/agent-acl-groups-http.test.ts +247 -0
  11. package/src/agent-acl-http.test.ts +520 -0
  12. package/src/agent-acl-slice3-http.test.ts +267 -0
  13. package/src/agent-groups-perf.test.ts +190 -0
  14. package/src/alias-filter.test.ts +72 -0
  15. package/src/alias-filter.ts +32 -0
  16. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  17. package/src/api-host-supervisors-fallback.test.ts +271 -0
  18. package/src/api-nodes-shape.test.ts +26 -10
  19. package/src/auth-sessions-http.test.ts +250 -0
  20. package/src/auth-validate.test.ts +5 -1
  21. package/src/auth.ts +450 -31
  22. package/src/avatar-validate.test.ts +163 -0
  23. package/src/avatar-validate.ts +103 -0
  24. package/src/blocked-exit-on-reply.test.ts +122 -0
  25. package/src/boot-side-effect.test.ts +124 -0
  26. package/src/commhub-bin-args.test.ts +85 -0
  27. package/src/config-apply-sec1.test.ts +107 -0
  28. package/src/config-apply-validate.test.ts +92 -0
  29. package/src/config-apply-validate.ts +81 -3
  30. package/src/create-node-blocked-gate.test.ts +356 -0
  31. package/src/create-node-tool-schema.test.ts +39 -0
  32. package/src/create-node-validate.test.ts +89 -1
  33. package/src/create-node-validate.ts +89 -7
  34. package/src/create-node-workdir.test.ts +255 -0
  35. package/src/create-node.ts +46 -1
  36. package/src/dashboard-slash-routing-http.test.ts +107 -0
  37. package/src/db-adapter-guard.test.ts +148 -0
  38. package/src/db-adapter-translate.test.ts +128 -0
  39. package/src/db-adapter.ts +323 -87
  40. package/src/db-timestamp.test.ts +38 -0
  41. package/src/db-timestamp.ts +15 -0
  42. package/src/db.ts +805 -33
  43. package/src/delivered-without-push.test.ts +140 -0
  44. package/src/desktop-message-seam.test.ts +150 -0
  45. package/src/desktop-message.test.ts +368 -0
  46. package/src/dm-file-scope-http.test.ts +178 -0
  47. package/src/ext-token-shared.test.ts +60 -0
  48. package/src/ext-validation-regression.test.ts +131 -0
  49. package/src/external-schedule-edits-http.test.ts +321 -0
  50. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  51. package/src/external-schedule-edits.ts +327 -0
  52. package/src/file-download-authz-dev-open.test.ts +135 -0
  53. package/src/file-download-authz.test.ts +404 -0
  54. package/src/file-download-date-bucket.test.ts +436 -0
  55. package/src/file-network-scope-dev-open.test.ts +97 -0
  56. package/src/file-network-scope.test.ts +732 -0
  57. package/src/health-redaction.test.ts +642 -0
  58. package/src/http-gzip.test.ts +94 -0
  59. package/src/http-gzip.ts +110 -0
  60. package/src/hub-timestamp.test.ts +38 -0
  61. package/src/hub-timestamp.ts +27 -0
  62. package/src/human-dm.ts +215 -0
  63. package/src/inbox-count-sse.test.ts +310 -0
  64. package/src/inbox-count.ts +29 -0
  65. package/src/index.ts +15 -2331
  66. package/src/lifecycle-guard.ts +50 -0
  67. package/src/mcp-route-label-http.test.ts +120 -0
  68. package/src/mcp-write-network-resolution.test.ts +429 -0
  69. package/src/member-audit-network-id-http.test.ts +93 -0
  70. package/src/member-presence-http.test.ts +206 -0
  71. package/src/messages-ack-by-agent-http.test.ts +142 -0
  72. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  73. package/src/migrate-sqlite-to-pg.ts +263 -0
  74. package/src/network-name-http.test.ts +75 -0
  75. package/src/network-scope.ts +224 -0
  76. package/src/node-attrs-validate.ts +88 -0
  77. package/src/node-attrs.test.ts +217 -0
  78. package/src/node-create-request-status-http.test.ts +92 -0
  79. package/src/node-files-transport.test.ts +344 -0
  80. package/src/node-id-alias.test.ts +188 -0
  81. package/src/node-lifecycle-controllable.test.ts +64 -0
  82. package/src/node-lifecycle-controllable.ts +51 -0
  83. package/src/node-logs-transport.test.ts +263 -0
  84. package/src/node-request-retention.test.ts +281 -0
  85. package/src/node-request-retention.ts +120 -0
  86. package/src/node-skills-transport.test.ts +209 -0
  87. package/src/node-token-privilege-http.test.ts +149 -0
  88. package/src/observer-avatar-http.test.ts +429 -0
  89. package/src/observer-push.test.ts +252 -0
  90. package/src/package-version-consistency.test.ts +25 -0
  91. package/src/peer-reply-atomic.test.ts +455 -0
  92. package/src/peer-reply-capability.test.ts +70 -0
  93. package/src/pg-worker.ts +87 -0
  94. package/src/push.ts +284 -7
  95. package/src/redact-tokens.test.ts +65 -0
  96. package/src/redact-tokens.ts +40 -0
  97. package/src/rename.ts +7 -2
  98. package/src/reply-target-derivation.test.ts +96 -0
  99. package/src/report-status-host-schema.test.ts +211 -0
  100. package/src/report-status-resume-id-handover.test.ts +150 -0
  101. package/src/require-explicit-test-db.ts +9 -0
  102. package/src/requirement-events-http.test.ts +257 -0
  103. package/src/requirement-events.test.ts +45 -0
  104. package/src/requirement-events.ts +143 -0
  105. package/src/requirement-notify.ts +134 -0
  106. package/src/requirement-participant-notify-http.test.ts +309 -0
  107. package/src/requirement-tag-ops-http.test.ts +266 -0
  108. package/src/requirement-tags.ts +89 -0
  109. package/src/requirements-completed-at-migration.test.ts +70 -0
  110. package/src/requirements-etag-http.test.ts +91 -0
  111. package/src/requirements-http.test.ts +709 -0
  112. package/src/requirements-list-slim-http.test.ts +315 -0
  113. package/src/requirements-mcp.test.ts +163 -0
  114. package/src/requirements-migrate.ts +225 -0
  115. package/src/requirements-people-http.test.ts +114 -0
  116. package/src/requirements-priority-lowest.test.ts +114 -0
  117. package/src/requirements-search-http.test.ts +182 -0
  118. package/src/requirements-search.test.ts +32 -0
  119. package/src/requirements-search.ts +138 -0
  120. package/src/requirements-seq-http.test.ts +193 -0
  121. package/src/requirements-seq-migration.test.ts +63 -0
  122. package/src/requirements-start-date.test.ts +125 -0
  123. package/src/requirements-stats-http.test.ts +288 -0
  124. package/src/requirements-stats.ts +164 -0
  125. package/src/requirements-upgrade-e2e.test.ts +126 -0
  126. package/src/requirements-upgrade-migration.test.ts +98 -0
  127. package/src/requirements.ts +1439 -0
  128. package/src/resolve-port.test.ts +57 -0
  129. package/src/resolve-port.ts +44 -0
  130. package/src/response-charset.test.ts +130 -0
  131. package/src/rest-explicit-columns-http.test.ts +216 -0
  132. package/src/rest-identity.test.ts +129 -0
  133. package/src/rest-identity.ts +100 -0
  134. package/src/rest-projections.ts +68 -0
  135. package/src/rest-write-network-resolution.test.ts +133 -0
  136. package/src/restricted-files.ts +73 -0
  137. package/src/retention.ts +15 -3
  138. package/src/route-callers-http.test.ts +120 -0
  139. package/src/route-timing.test.ts +211 -0
  140. package/src/route-timing.ts +258 -0
  141. package/src/rules-file-duplicate-alias.test.ts +234 -0
  142. package/src/rules-file-session-target.test.ts +207 -0
  143. package/src/rules-file-transport.test.ts +237 -0
  144. package/src/runtime-label.test.ts +22 -0
  145. package/src/runtime-label.ts +21 -0
  146. package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
  147. package/src/scheduled-reply-unread-http.test.ts +212 -0
  148. package/src/scheduled-run-terminal.test.ts +268 -0
  149. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  150. package/src/scheduled-tasks-http.test.ts +638 -0
  151. package/src/scheduled-tasks.ts +597 -0
  152. package/src/send-dedup-request-id.test.ts +229 -0
  153. package/src/send-reply-agent-warning.test.ts +202 -0
  154. package/src/send-reply-alias-optional.test.ts +131 -0
  155. package/src/send-reply-attachments.test.ts +458 -0
  156. package/src/send_dedup.test.ts +54 -0
  157. package/src/send_dedup.ts +52 -6
  158. package/src/serve-error.test.ts +38 -0
  159. package/src/serve-error.ts +27 -0
  160. package/src/server.ts +4522 -0
  161. package/src/shared/external-schedule-contract.ts +60 -0
  162. package/src/side-thread-command-transport.test.ts +312 -0
  163. package/src/side-thread-command-transport.ts +318 -0
  164. package/src/side-thread-http-integration.test.ts +233 -0
  165. package/src/side-thread-http.ts +504 -0
  166. package/src/side-thread-production.test.ts +72 -0
  167. package/src/side-thread-production.ts +118 -0
  168. package/src/side-thread.test.ts +1067 -0
  169. package/src/side-thread.ts +2499 -0
  170. package/src/skillhub-http.test.ts +108 -0
  171. package/src/sqlite-close-checkpoint.test.ts +78 -0
  172. package/src/start-node.test.ts +109 -0
  173. package/src/status-alias-filter-http.test.ts +106 -0
  174. package/src/status-node-id-filter-http.test.ts +161 -0
  175. package/src/stop-delete-node.test.ts +1164 -0
  176. package/src/task-access-http.test.ts +379 -0
  177. package/src/task-access.ts +307 -0
  178. package/src/task-auth-origin-http.test.ts +91 -0
  179. package/src/task-auth-origin.test.ts +24 -0
  180. package/src/task-auth-origin.ts +7 -0
  181. package/src/task-consumption.test.ts +482 -0
  182. package/src/task-diagnostic-http.test.ts +149 -0
  183. package/src/task-diagnostic.test.ts +115 -0
  184. package/src/task-diagnostic.ts +118 -0
  185. package/src/task-idempotency-mcp.test.ts +142 -0
  186. package/src/task-idempotency.test.ts +60 -0
  187. package/src/task-idempotency.ts +75 -0
  188. package/src/task-lifecycle-watcher.test.ts +204 -0
  189. package/src/task-lifecycle-watcher.ts +49 -0
  190. package/src/task-network-resolution-http.test.ts +141 -0
  191. package/src/tasks-pagination-http.test.ts +141 -0
  192. package/src/tools-list-json-schema.test.ts +66 -0
  193. package/src/tools.ts +3534 -343
  194. package/src/update-provider.test.ts +65 -0
  195. package/src/upload-early-reject-guard.test.ts +155 -0
  196. package/src/uploads-http.test.ts +97 -15
  197. package/src/uploads.test.ts +66 -0
  198. package/src/uploads.ts +138 -5
  199. package/src/user-inbox-by-agent-http.test.ts +161 -0
  200. package/src/user-inbox-netid-notnull.test.ts +122 -0
  201. package/src/user-inbox-read-http.test.ts +223 -0
  202. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,247 @@
1
+ // Agent 分组(RFC-038 §8)—— HTTP 集成测试(真实 Bun.serve,私有端口,临时库)。
2
+ //
3
+ // 场景:Hub 管理员 admin 的网络 NET 里有 3 个 Agent(X、Y、Z);受限成员 alice 没有任何直接授权。
4
+ // 正向:组授权 ⇒ 看得见、能派活;往组里加节点 ⇒ 下一次请求就可见(动态);直接授权与组授权取并集。
5
+ // 反向:移出组 ⇒ 403 且与不存在的 alias 逐字节相同;删组 ⇒ 访问消失;跨网络 id ⇒ 400;
6
+ // 节点令牌 / 受限成员碰组接口 ⇒ 403。
7
+ // 兼容:旧 app 形状的 PUT(只带 grants)不动组授权;agent_access='all' 的成员不看组。
8
+
9
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
10
+ import { mkdtempSync, rmSync } from "fs";
11
+ import { join } from "path";
12
+ import { tmpdir } from "os";
13
+ import { register } from "./auth.js";
14
+ import { db } from "./db.js";
15
+
16
+ const DIR = mkdtempSync(join(tmpdir(), "anet-agent-groups-"));
17
+ let BASE = "";
18
+ let hub: any = null;
19
+
20
+ const PW = "GroupTestPassw0rd!x";
21
+ let adminToken = "";
22
+ let adminId = "";
23
+ let NET = "";
24
+ let OTHER_NET = "";
25
+ let aliceToken = "";
26
+ let aliceId = "";
27
+ let carolToken = "";
28
+ let carolId = "";
29
+ let nodeToken = "";
30
+
31
+ const X = { alias: "grp-agent-x", node: "node_grp_x" };
32
+ const Y = { alias: "grp-agent-y", node: "node_grp_y" };
33
+ const Z = { alias: "grp-agent-z", node: "node_grp_z" };
34
+ const FOREIGN = { alias: "grp-agent-foreign", node: "node_grp_foreign" };
35
+
36
+ const auth = (token: string) => ({ Authorization: `Bearer ${token}` });
37
+ const json = (token: string) => ({ ...auth(token), "Content-Type": "application/json" });
38
+
39
+ function seedAgent(a: { alias: string; node: string }, net: string) {
40
+ db.run(
41
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id, updated_at) VALUES (?1, ?2, ?2, ?3, ?4, datetime('now'))`,
42
+ [a.node, a.alias, net, adminId],
43
+ );
44
+ db.run(
45
+ `INSERT INTO sessions (resume_id, alias, node_id, status, network_id, updated_at, last_seen_at)
46
+ VALUES (?1, ?2, ?3, 'idle', ?4, datetime('now'), datetime('now'))`,
47
+ [`resume_${a.node}`, a.alias, a.node, net],
48
+ );
49
+ }
50
+
51
+ async function send(token: string, method: string, path: string, payload?: unknown): Promise<{ status: number; body: any; text: string }> {
52
+ const res = await fetch(`${BASE}${path}`, { method, headers: json(token), body: payload === undefined ? undefined : JSON.stringify(payload) });
53
+ const text = await res.text();
54
+ let body: any = null;
55
+ try { body = JSON.parse(text); } catch {}
56
+ return { status: res.status, body, text };
57
+ }
58
+ const get = (token: string, path: string) => send(token, "GET", path);
59
+ const seen = async (token: string) => {
60
+ const r = await get(token, `/api/status?network_id=${NET}`);
61
+ expect(r.status).toBe(200);
62
+ return (r.body.sessions ?? []).map((s: any) => s.alias).filter((a: string) => a.startsWith("grp-agent-")).sort();
63
+ };
64
+ // 每次内容不同:hub 按内容哈希 5 分钟去重(send_dedup),同样的探针第二次会 429。
65
+ let probeSeq = 0;
66
+ const task = (token: string, alias: string) => send(token, "POST", "/api/task", { alias, task: `probe ${alias} #${++probeSeq}`, network_id: NET });
67
+ const grantsPath = (uid: string) => `/api/networks/${NET}/members/${uid}/agent-grants`;
68
+ const groupsPath = `/api/networks/${"__NET__"}/agent-groups`;
69
+ const gp = (suffix = "") => groupsPath.replace("__NET__", NET) + suffix;
70
+
71
+ beforeAll(async () => {
72
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
73
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
74
+ process.env.HOST = "127.0.0.1";
75
+
76
+ const admin = register(`grp_admin_${Date.now()}`, PW, undefined, "Admin");
77
+ expect(admin.ok).toBe(true);
78
+ adminToken = admin.token!;
79
+ adminId = admin.user!.user_id;
80
+ NET = admin.network_id!;
81
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [adminId]);
82
+ for (const a of [X, Y, Z]) seedAgent(a, NET);
83
+
84
+ const mod: any = await import("./server.js");
85
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
86
+ BASE = `http://127.0.0.1:${hub.port}`;
87
+
88
+ // 另一个网络(admin 也是 owner),放一个外网节点,用来测跨网络 id。
89
+ const other = await send(adminToken, "POST", "/api/networks", { name: `grp-other-${Date.now()}` });
90
+ expect(other.status).toBe(200);
91
+ OTHER_NET = other.body.network_id ?? other.body.network?.network_id;
92
+ expect(typeof OTHER_NET).toBe("string");
93
+ seedAgent(FOREIGN, OTHER_NET);
94
+
95
+ const mk = async (username: string, agentAccess?: "all") => {
96
+ const r = await send(adminToken, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role: "member" });
97
+ expect(r.status).toBe(200);
98
+ const login = await send("", "POST", "/api/auth/login", { username, password: PW });
99
+ expect(login.status).toBe(200);
100
+ if (agentAccess) db.run("UPDATE network_members SET agent_access = 'all' WHERE network_id = ?1 AND user_id = ?2", [NET, login.body.user.user_id]);
101
+ return { token: login.body.token as string, id: login.body.user.user_id as string };
102
+ };
103
+ const alice = await mk(`grp_alice_${Date.now()}`);
104
+ aliceToken = alice.token; aliceId = alice.id;
105
+ const carol = await mk(`grp_carol_${Date.now()}`, "all");
106
+ carolToken = carol.token; carolId = carol.id;
107
+
108
+ const nt = await send(adminToken, "POST", "/api/auth/node-token", { network_id: NET, node_name: X.alias, node_id: X.node });
109
+ expect(nt.status).toBe(200);
110
+ nodeToken = nt.body.token;
111
+ }, 30_000);
112
+
113
+ afterAll(() => {
114
+ try { hub?.stop?.(true); } catch {}
115
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
116
+ });
117
+
118
+ let groupId = "";
119
+
120
+ describe("Agent 分组:CRUD 与权限", () => {
121
+ test("owner 建组(带成员);重名 409;外网节点 400 且整批不写", async () => {
122
+ const r = await send(adminToken, "POST", gp(), { name: "前端组", node_ids: [X.node] });
123
+ expect(r.status).toBe(200);
124
+ groupId = r.body.group.group_id;
125
+ expect(r.body.group.node_ids).toEqual([X.node]);
126
+ expect((await send(adminToken, "POST", gp(), { name: "前端组" })).status).toBe(409);
127
+ const bad = await send(adminToken, "POST", gp(), { name: "坏组", node_ids: [Y.node, FOREIGN.node] });
128
+ expect(bad.status).toBe(400);
129
+ expect(bad.body.error).toBe("agent_not_in_network");
130
+ const list = await get(adminToken, gp());
131
+ expect(list.body.groups.map((g: any) => g.name)).toEqual(["前端组"]);
132
+ });
133
+
134
+ test("受限成员、节点令牌碰组接口 ⇒ 403", async () => {
135
+ expect((await get(aliceToken, gp())).status).toBe(403);
136
+ expect((await send(aliceToken, "POST", gp(), { name: "偷偷建" })).status).toBe(403);
137
+ expect((await get(nodeToken, gp())).status).toBe(403);
138
+ });
139
+
140
+ test("审计:建组写 agent_group_created(带 network_id)", () => {
141
+ const row = db.get<{ network_id: string; detail: string }>("SELECT network_id, detail FROM audit_log WHERE action = 'agent_group_created' AND target_id = ?1", groupId);
142
+ expect(row?.network_id).toBe(NET);
143
+ expect(row?.detail).toContain("前端组");
144
+ });
145
+ });
146
+
147
+ describe("组授权:动态生效", () => {
148
+ test("基线:alice 没有任何授权 ⇒ 看不到、发不了", async () => {
149
+ expect(await seen(aliceToken)).toEqual([]);
150
+ expect((await task(aliceToken, X.alias)).status).toBe(403);
151
+ });
152
+
153
+ test("授权给组 ⇒ 看到组里的 X、能派活;GET 回 group_grants", async () => {
154
+ const put = await send(adminToken, "PUT", grantsPath(aliceId), { group_grants: [{ group_id: groupId, can_message: true }] });
155
+ expect(put.status).toBe(200);
156
+ expect(put.body.group_grants).toEqual([{ group_id: groupId, name: "前端组", can_message: true }]);
157
+ expect(put.body.restricted).toBe(true);
158
+ expect(await seen(aliceToken)).toEqual([X.alias]);
159
+ expect((await task(aliceToken, X.alias)).status).toBe(200);
160
+ expect((await task(aliceToken, Y.alias)).status).toBe(403);
161
+ const g = await get(adminToken, grantsPath(aliceId));
162
+ expect(g.body.grants).toEqual([]);
163
+ expect(g.body.group_grants.length).toBe(1);
164
+ });
165
+
166
+ test("往组里加 Y ⇒ alice 下一次请求就看得见 Y(不用改她的授权)", async () => {
167
+ const r = await send(adminToken, "PUT", gp(`/${groupId}/members`), { node_ids: [X.node, Y.node] });
168
+ expect(r.status).toBe(200);
169
+ expect(r.body.added).toEqual([Y.node]);
170
+ expect(await seen(aliceToken)).toEqual([X.alias, Y.alias]);
171
+ expect((await task(aliceToken, Y.alias)).status).toBe(200);
172
+ const audit = db.get<{ detail: string }>("SELECT detail FROM audit_log WHERE action = 'agent_group_members_changed' AND target_id = ?1 ORDER BY id DESC LIMIT 1", groupId);
173
+ expect(JSON.parse(audit!.detail)).toEqual({ added: [Y.node], removed: [] });
174
+ });
175
+
176
+ test("从组里移出 Y ⇒ 看不见,派活 403 且与不存在的 alias 逐字节相同", async () => {
177
+ await send(adminToken, "PUT", gp(`/${groupId}/members`), { node_ids: [X.node] });
178
+ expect(await seen(aliceToken)).toEqual([X.alias]);
179
+ const y = await task(aliceToken, Y.alias);
180
+ const ghost = await task(aliceToken, "grp-agent-does-not-exist");
181
+ expect(y.status).toBe(403);
182
+ expect(y.text).toBe(ghost.text);
183
+ });
184
+
185
+ test("直接授权与组授权取并集;可对话任一来源给了就算", async () => {
186
+ // 组授权只读,Z 直接授权可对话
187
+ const put = await send(adminToken, "PUT", grantsPath(aliceId), {
188
+ grants: [{ node_id: Z.node, can_message: true }],
189
+ group_grants: [{ group_id: groupId, can_message: false }],
190
+ });
191
+ expect(put.status).toBe(200);
192
+ expect(await seen(aliceToken)).toEqual([X.alias, Z.alias]);
193
+ expect((await task(aliceToken, X.alias)).status).toBe(403); // 组只读
194
+ expect((await task(aliceToken, Z.alias)).status).toBe(200); // 直接可对话
195
+ // X 再给一条直接可对话授权 ⇒ 并集 ⇒ 可以发
196
+ await send(adminToken, "PUT", grantsPath(aliceId), { grants: [{ node_id: Z.node, can_message: true }, { node_id: X.node, can_message: true }] });
197
+ expect((await task(aliceToken, X.alias)).status).toBe(200);
198
+ });
199
+
200
+ test("兼容:旧 app 形状的 PUT(只带 grants,不带 group_grants)不动组授权", async () => {
201
+ const before = await get(adminToken, grantsPath(aliceId));
202
+ expect(before.body.group_grants.length).toBe(1);
203
+ const old = await send(adminToken, "PUT", grantsPath(aliceId), { grants: [{ node_id: Z.node, can_message: true }] });
204
+ expect(old.status).toBe(200);
205
+ expect(old.body.group_grants.length).toBe(1);
206
+ expect(await seen(aliceToken)).toEqual([X.alias, Z.alias]);
207
+ });
208
+
209
+ test("组授权:外网 / 不存在的 group_id ⇒ 400,整批不写", async () => {
210
+ const other = await send(adminToken, "POST", `/api/networks/${OTHER_NET}/agent-groups`, { name: "外网组" });
211
+ expect(other.status).toBe(200);
212
+ const bad = await send(adminToken, "PUT", grantsPath(aliceId), { group_grants: [{ group_id: other.body.group.group_id }] });
213
+ expect(bad.status).toBe(400);
214
+ expect(bad.body.error).toBe("group_not_in_network");
215
+ expect((await get(adminToken, grantsPath(aliceId))).body.group_grants.map((g: any) => g.group_id)).toEqual([groupId]);
216
+ });
217
+
218
+ test("agent_access='all' 的成员不看组:照旧全部可见", async () => {
219
+ expect(await seen(carolToken)).toEqual([X.alias, Y.alias, Z.alias]);
220
+ });
221
+
222
+ test("成员列表带 agent_group_count", async () => {
223
+ const r = await get(adminToken, `/api/networks/${NET}/members`);
224
+ const alice = r.body.members.find((m: any) => m.user_id === aliceId);
225
+ expect(alice.agent_group_count).toBe(1);
226
+ });
227
+
228
+ test("删组 ⇒ 组授权随之消失,只剩直接授权;响应带受影响成员;审计", async () => {
229
+ const del = await send(adminToken, "DELETE", gp(`/${groupId}`));
230
+ expect(del.status).toBe(200);
231
+ expect(del.body.affected_user_ids).toEqual([aliceId]);
232
+ expect(await seen(aliceToken)).toEqual([Z.alias]);
233
+ expect((await task(aliceToken, X.alias)).status).toBe(403);
234
+ expect((await get(adminToken, grantsPath(aliceId))).body.group_grants).toEqual([]);
235
+ expect(db.get("SELECT 1 FROM audit_log WHERE action = 'agent_group_deleted' AND target_id = ?1", groupId)).toBeTruthy();
236
+ expect((await send(adminToken, "DELETE", gp(`/${groupId}`))).status).toBe(404);
237
+ });
238
+
239
+ test("移出网络 ⇒ 组授权一并清掉", async () => {
240
+ const g2 = await send(adminToken, "POST", gp(), { name: "第二组", node_ids: [Y.node] });
241
+ await send(adminToken, "PUT", grantsPath(aliceId), { group_grants: [g2.body.group.group_id] });
242
+ expect(db.get("SELECT 1 FROM network_member_group_grants WHERE user_id = ?1", aliceId)).toBeTruthy();
243
+ const rm = await send(adminToken, "DELETE", `/api/networks/${NET}/members/${aliceId}`);
244
+ expect(rm.status).toBe(200);
245
+ expect(db.get("SELECT 1 FROM network_member_group_grants WHERE user_id = ?1", aliceId)).toBeFalsy();
246
+ });
247
+ });