@sleep2agi/commhub-server 0.9.0-preview.13 → 0.9.0-preview.130

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 (317) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +99 -11
  3. package/package.json +7 -7
  4. package/src/ack-create-request-transport.test.ts +182 -0
  5. package/src/ack-create-request.test.ts +50 -7
  6. package/src/admin-delete-network-http.test.ts +228 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/adoption-candidates-http.test.ts +137 -0
  9. package/src/adoption-candidates.ts +158 -0
  10. package/src/agent-access-batch.test.ts +210 -0
  11. package/src/agent-access.ts +487 -0
  12. package/src/agent-acl-groups-http.test.ts +247 -0
  13. package/src/agent-acl-http.test.ts +659 -0
  14. package/src/agent-acl-slice3-http.test.ts +267 -0
  15. package/src/agent-groups-perf.test.ts +223 -0
  16. package/src/agent-notice.ts +48 -0
  17. package/src/agent-team-whoami-http.test.ts +98 -0
  18. package/src/agent-teams-http.test.ts +323 -0
  19. package/src/agent-teams.ts +244 -0
  20. package/src/alias-filter.test.ts +72 -0
  21. package/src/alias-filter.ts +42 -0
  22. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  23. package/src/api-host-supervisors-fallback.test.ts +271 -0
  24. package/src/api-host-supervisors-runtime-readiness.test.ts +195 -0
  25. package/src/api-nodes-shape.test.ts +26 -10
  26. package/src/auth-password-change-cli-tokens-http.test.ts +294 -0
  27. package/src/auth-sessions-http.test.ts +250 -0
  28. package/src/auth-validate.test.ts +5 -1
  29. package/src/auth-weak-password-login.test.ts +176 -0
  30. package/src/auth.ts +598 -49
  31. package/src/avatar-validate.test.ts +163 -0
  32. package/src/avatar-validate.ts +103 -0
  33. package/src/binding-generation-http.test.ts +121 -0
  34. package/src/blocked-exit-on-reply.test.ts +122 -0
  35. package/src/boot-side-effect.test.ts +124 -0
  36. package/src/clone-node.test.ts +248 -0
  37. package/src/clone-node.ts +289 -0
  38. package/src/codex-config-flags.ts +77 -0
  39. package/src/codex-fork-contract.ts +23 -0
  40. package/src/codex-fork-migration.test.ts +38 -0
  41. package/src/commhub-bin-args.test.ts +85 -0
  42. package/src/config-apply-sec1.test.ts +107 -0
  43. package/src/config-apply-validate.test.ts +134 -1
  44. package/src/config-apply-validate.ts +111 -5
  45. package/src/create-node-blocked-gate.test.ts +356 -0
  46. package/src/create-node-network-id.test.ts +79 -0
  47. package/src/create-node-tool-schema.test.ts +48 -0
  48. package/src/create-node-unicode-name.test.ts +120 -0
  49. package/src/create-node-validate.test.ts +193 -7
  50. package/src/create-node-validate.ts +227 -15
  51. package/src/create-node-workdir.test.ts +289 -0
  52. package/src/create-node.ts +77 -23
  53. package/src/daemon-provider-snapshots.test.ts +421 -0
  54. package/src/daemon-provider-snapshots.ts +327 -0
  55. package/src/daemon-token-owner-http.test.ts +236 -0
  56. package/src/dashboard-slash-routing-http.test.ts +107 -0
  57. package/src/db-adapter-guard.test.ts +148 -0
  58. package/src/db-adapter-translate.test.ts +128 -0
  59. package/src/db-adapter.ts +323 -87
  60. package/src/db-timestamp.test.ts +38 -0
  61. package/src/db-timestamp.ts +15 -0
  62. package/src/db.ts +930 -35
  63. package/src/delivered-without-push.test.ts +140 -0
  64. package/src/department-groups-http.test.ts +209 -0
  65. package/src/department-groups-sync-http.test.ts +328 -0
  66. package/src/department-groups.ts +434 -0
  67. package/src/department-heads-http.test.ts +330 -0
  68. package/src/department-heads.ts +226 -0
  69. package/src/departments-http.test.ts +185 -0
  70. package/src/departments.ts +260 -0
  71. package/src/desktop-message-seam.test.ts +150 -0
  72. package/src/desktop-message.test.ts +368 -0
  73. package/src/dm-file-scope-http.test.ts +178 -0
  74. package/src/ext-token-shared.test.ts +60 -0
  75. package/src/ext-validation-regression.test.ts +131 -0
  76. package/src/external-schedule-edits-http.test.ts +321 -0
  77. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  78. package/src/external-schedule-edits.ts +327 -0
  79. package/src/file-download-authz-dev-open.test.ts +135 -0
  80. package/src/file-download-authz.test.ts +404 -0
  81. package/src/file-download-date-bucket.test.ts +436 -0
  82. package/src/file-network-scope-dev-open.test.ts +97 -0
  83. package/src/file-network-scope.test.ts +732 -0
  84. package/src/group-membership-events-http.test.ts +264 -0
  85. package/src/group-messages-http.test.ts +482 -0
  86. package/src/group-messages.ts +297 -0
  87. package/src/health-redaction.test.ts +642 -0
  88. package/src/http-gzip.test.ts +94 -0
  89. package/src/http-gzip.ts +112 -0
  90. package/src/hub-provider-config.test.ts +139 -0
  91. package/src/hub-provider-config.ts +105 -0
  92. package/src/hub-timestamp.test.ts +38 -0
  93. package/src/hub-timestamp.ts +27 -0
  94. package/src/human-dm.ts +215 -0
  95. package/src/inbox-caller.ts +49 -0
  96. package/src/inbox-count-sse.test.ts +310 -0
  97. package/src/inbox-count.ts +29 -0
  98. package/src/index.ts +15 -2362
  99. package/src/lifecycle-guard.ts +1 -1
  100. package/src/mcp-context-cost-http.test.ts +123 -0
  101. package/src/mcp-projects-events-http.test.ts +162 -0
  102. package/src/mcp-route-label-http.test.ts +120 -0
  103. package/src/mcp-write-network-resolution.test.ts +432 -0
  104. package/src/member-audit-network-id-http.test.ts +93 -0
  105. package/src/member-presence-http.test.ts +210 -0
  106. package/src/messages-ack-by-agent-http.test.ts +142 -0
  107. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  108. package/src/migrate-sqlite-to-pg.ts +263 -0
  109. package/src/model-auth-notify-http.test.ts +181 -0
  110. package/src/model-auth-notify.ts +116 -0
  111. package/src/network-name-http.test.ts +75 -0
  112. package/src/network-scope.ts +276 -0
  113. package/src/network-token-membership-http.test.ts +139 -0
  114. package/src/node-alias-control.ts +12 -0
  115. package/src/node-attrs-validate.ts +88 -0
  116. package/src/node-attrs.test.ts +217 -0
  117. package/src/node-caller-identity-http.test.ts +781 -0
  118. package/src/node-create-request-status-http.test.ts +105 -0
  119. package/src/node-daemon-bindings.test.ts +132 -0
  120. package/src/node-daemon-bindings.ts +130 -0
  121. package/src/node-files-transport.test.ts +344 -0
  122. package/src/node-health-codex-login.test.ts +120 -0
  123. package/src/node-health-dispatch-http.test.ts +279 -0
  124. package/src/node-health-guard.ts +105 -0
  125. package/src/node-health-passthrough.test.ts +115 -0
  126. package/src/node-health-store.ts +101 -0
  127. package/src/node-id-alias.test.ts +190 -0
  128. package/src/node-identity-conflict-http.test.ts +269 -0
  129. package/src/node-identity-conflict.ts +142 -0
  130. package/src/node-identity-mismatch.test.ts +74 -0
  131. package/src/node-lifecycle-controllable.test.ts +64 -0
  132. package/src/node-lifecycle-controllable.ts +53 -0
  133. package/src/node-lifecycle-read-http.test.ts +245 -0
  134. package/src/node-lifecycle-read.ts +121 -0
  135. package/src/node-logs-transport.test.ts +263 -0
  136. package/src/node-permissions-http.test.ts +276 -0
  137. package/src/node-permissions.ts +285 -0
  138. package/src/node-request-retention.test.ts +281 -0
  139. package/src/node-request-retention.ts +120 -0
  140. package/src/node-skills-transport.test.ts +209 -0
  141. package/src/node-token-ownership.ts +51 -0
  142. package/src/node-token-privilege-http.test.ts +158 -0
  143. package/src/observer-avatar-http.test.ts +431 -0
  144. package/src/observer-push.test.ts +252 -0
  145. package/src/opencode-v2-catalog.test.ts +268 -0
  146. package/src/opencode-v2-catalog.ts +352 -0
  147. package/src/package-version-consistency.test.ts +25 -0
  148. package/src/peer-reply-atomic.test.ts +455 -0
  149. package/src/peer-reply-capability.test.ts +70 -0
  150. package/src/pg-worker.ts +87 -0
  151. package/src/project-dir-warn-clear-http.test.ts +132 -0
  152. package/src/project-viewer-can-http.test.ts +114 -0
  153. package/src/push.ts +429 -10
  154. package/src/redact-tokens.test.ts +65 -0
  155. package/src/redact-tokens.ts +40 -0
  156. package/src/rename.ts +22 -2
  157. package/src/reply-target-derivation.test.ts +96 -0
  158. package/src/report-status-handover-capabilities-http.test.ts +124 -0
  159. package/src/report-status-host-schema.test.ts +235 -0
  160. package/src/report-status-resume-id-handover.test.ts +150 -0
  161. package/src/require-explicit-test-db.ts +9 -0
  162. package/src/requirement-due-reminders-http.test.ts +620 -0
  163. package/src/requirement-due-reminders.ts +416 -0
  164. package/src/requirement-events-http.test.ts +268 -0
  165. package/src/requirement-events.test.ts +45 -0
  166. package/src/requirement-events.ts +144 -0
  167. package/src/requirement-notify.ts +165 -0
  168. package/src/requirement-participant-notify-http.test.ts +309 -0
  169. package/src/requirement-tag-ops-http.test.ts +266 -0
  170. package/src/requirement-tags.ts +89 -0
  171. package/src/requirements-abandoned-http.test.ts +236 -0
  172. package/src/requirements-comment-http.test.ts +167 -0
  173. package/src/requirements-completed-at-migration.test.ts +70 -0
  174. package/src/requirements-errors-mcp-http.test.ts +164 -0
  175. package/src/requirements-errors.ts +143 -0
  176. package/src/requirements-etag-http.test.ts +91 -0
  177. package/src/requirements-http.test.ts +709 -0
  178. package/src/requirements-last-event-http.test.ts +349 -0
  179. package/src/requirements-list-mcp-slim-http.test.ts +155 -0
  180. package/src/requirements-list-slim-http.test.ts +319 -0
  181. package/src/requirements-mcp.test.ts +163 -0
  182. package/src/requirements-migrate.ts +259 -0
  183. package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
  184. package/src/requirements-people-http.test.ts +114 -0
  185. package/src/requirements-people-mcp-http.test.ts +225 -0
  186. package/src/requirements-people.ts +127 -0
  187. package/src/requirements-priority-lowest.test.ts +114 -0
  188. package/src/requirements-search-http.test.ts +182 -0
  189. package/src/requirements-search.test.ts +32 -0
  190. package/src/requirements-search.ts +145 -0
  191. package/src/requirements-seq-http.test.ts +193 -0
  192. package/src/requirements-seq-migration.test.ts +63 -0
  193. package/src/requirements-start-date.test.ts +125 -0
  194. package/src/requirements-stats-http.test.ts +288 -0
  195. package/src/requirements-stats.ts +167 -0
  196. package/src/requirements-status-participants-http.test.ts +222 -0
  197. package/src/requirements-upgrade-e2e.test.ts +126 -0
  198. package/src/requirements-upgrade-migration.test.ts +98 -0
  199. package/src/requirements.ts +1806 -0
  200. package/src/resolve-port.test.ts +57 -0
  201. package/src/resolve-port.ts +44 -0
  202. package/src/response-charset.test.ts +130 -0
  203. package/src/rest-explicit-columns-http.test.ts +222 -0
  204. package/src/rest-identity.test.ts +129 -0
  205. package/src/rest-identity.ts +125 -0
  206. package/src/rest-projections.ts +68 -0
  207. package/src/rest-write-network-resolution.test.ts +133 -0
  208. package/src/restricted-files.ts +76 -0
  209. package/src/retention.ts +15 -3
  210. package/src/route-callers-http.test.ts +120 -0
  211. package/src/route-timing.test.ts +211 -0
  212. package/src/route-timing.ts +258 -0
  213. package/src/rules-file-duplicate-alias.test.ts +234 -0
  214. package/src/rules-file-session-target.test.ts +207 -0
  215. package/src/rules-file-transport.test.ts +237 -0
  216. package/src/runtime-label.test.ts +22 -0
  217. package/src/runtime-label.ts +21 -0
  218. package/src/runtime-readiness.ts +90 -0
  219. package/src/schedule-agent-mcp-http.test.ts +381 -0
  220. package/src/schedule-agent.ts +192 -0
  221. package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
  222. package/src/scheduled-failures-http.test.ts +233 -0
  223. package/src/scheduled-failures.ts +179 -0
  224. package/src/scheduled-offline.test.ts +131 -0
  225. package/src/scheduled-offline.ts +66 -0
  226. package/src/scheduled-reply-unread-http.test.ts +212 -0
  227. package/src/scheduled-run-terminal.test.ts +268 -0
  228. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  229. package/src/scheduled-stuck-http.test.ts +238 -0
  230. package/src/scheduled-stuck.ts +153 -0
  231. package/src/scheduled-tasks-http.test.ts +638 -0
  232. package/src/scheduled-tasks.ts +766 -0
  233. package/src/scoped-secrets.ts +124 -0
  234. package/src/send-dedup-request-id.test.ts +229 -0
  235. package/src/send-reply-agent-warning.test.ts +202 -0
  236. package/src/send-reply-alias-optional.test.ts +131 -0
  237. package/src/send-reply-attachments.test.ts +458 -0
  238. package/src/send_dedup.test.ts +54 -0
  239. package/src/send_dedup.ts +52 -6
  240. package/src/serve-error.test.ts +38 -0
  241. package/src/serve-error.ts +27 -0
  242. package/src/server.ts +5257 -0
  243. package/src/session-task-on-dispatch.ts +14 -0
  244. package/src/session-token-ownership-http.test.ts +122 -0
  245. package/src/shared/codex-inventory.ts +41 -0
  246. package/src/shared/daemon-provider-login.ts +31 -0
  247. package/src/shared/daemon-provider-snapshot.ts +52 -0
  248. package/src/shared/external-schedule-contract.ts +60 -0
  249. package/src/shared/node-name.test.ts +62 -0
  250. package/src/shared/node-name.ts +161 -0
  251. package/src/shared/provider-application.ts +29 -0
  252. package/src/shared/provider-collection.ts +14 -0
  253. package/src/shared/provider-initialization.ts +8 -0
  254. package/src/shared/reserved-env-drift.test.ts +10 -0
  255. package/src/shared/scoped-secret-contract.ts +5 -0
  256. package/src/shared/task-content-limit-drift.test.ts +28 -0
  257. package/src/shared/task-content-limit.ts +25 -0
  258. package/src/side-thread-command-transport.test.ts +312 -0
  259. package/src/side-thread-command-transport.ts +318 -0
  260. package/src/side-thread-http-integration.test.ts +233 -0
  261. package/src/side-thread-http.ts +504 -0
  262. package/src/side-thread-production.test.ts +72 -0
  263. package/src/side-thread-production.ts +118 -0
  264. package/src/side-thread.test.ts +1067 -0
  265. package/src/side-thread.ts +2499 -0
  266. package/src/skillhub-http.test.ts +108 -0
  267. package/src/sqlite-close-checkpoint.test.ts +78 -0
  268. package/src/start-node.test.ts +264 -0
  269. package/src/status-alias-filter-http.test.ts +106 -0
  270. package/src/status-alias-resolver-http.test.ts +163 -0
  271. package/src/status-alias-resolver.ts +19 -0
  272. package/src/status-node-id-filter-http.test.ts +161 -0
  273. package/src/status-read-cache-http.test.ts +229 -0
  274. package/src/status-read-cache.ts +122 -0
  275. package/src/stop-delete-node.test.ts +509 -1
  276. package/src/task-access-http.test.ts +402 -0
  277. package/src/task-access.ts +420 -0
  278. package/src/task-auth-origin-http.test.ts +91 -0
  279. package/src/task-auth-origin.test.ts +24 -0
  280. package/src/task-auth-origin.ts +7 -0
  281. package/src/task-consumption.test.ts +482 -0
  282. package/src/task-diagnostic-http.test.ts +149 -0
  283. package/src/task-diagnostic.test.ts +115 -0
  284. package/src/task-diagnostic.ts +118 -0
  285. package/src/task-expiry-notice-http.test.ts +287 -0
  286. package/src/task-expiry-notice.ts +315 -0
  287. package/src/task-idempotency-mcp.test.ts +142 -0
  288. package/src/task-idempotency.test.ts +60 -0
  289. package/src/task-idempotency.ts +75 -0
  290. package/src/task-late-replies.ts +54 -0
  291. package/src/task-late-reply-http.test.ts +152 -0
  292. package/src/task-lifecycle-watcher.test.ts +204 -0
  293. package/src/task-lifecycle-watcher.ts +49 -0
  294. package/src/task-network-resolution-http.test.ts +141 -0
  295. package/src/task-orphan-http.test.ts +287 -0
  296. package/src/task-orphan.ts +119 -0
  297. package/src/task-queue-ahead-http.test.ts +309 -0
  298. package/src/task-queue-ahead-scale.test.ts +236 -0
  299. package/src/task-queue-ahead.ts +186 -0
  300. package/src/task-rejection-log-http.test.ts +160 -0
  301. package/src/task-rejection-log.ts +49 -0
  302. package/src/task-stale-open-http.test.ts +290 -0
  303. package/src/task-stale-open.ts +103 -0
  304. package/src/tasks-pagination-http.test.ts +141 -0
  305. package/src/tool-audience-http.test.ts +220 -0
  306. package/src/tool-audience.ts +118 -0
  307. package/src/tools-list-json-schema.test.ts +67 -0
  308. package/src/tools.ts +3927 -323
  309. package/src/upload-early-reject-guard.test.ts +155 -0
  310. package/src/uploads-http.test.ts +97 -15
  311. package/src/uploads.ts +103 -6
  312. package/src/user-avatar-http.test.ts +74 -0
  313. package/src/user-avatar-migration.test.ts +25 -0
  314. package/src/user-inbox-by-agent-http.test.ts +161 -0
  315. package/src/user-inbox-netid-notnull.test.ts +122 -0
  316. package/src/user-inbox-read-http.test.ts +223 -0
  317. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,402 @@
1
+ // 任务(需求卡)的人员权限(RFC-038 §9)—— HTTP 集成测试(真实 Bun.serve,私有端口,临时库)。
2
+ //
3
+ // 场景:Hub 管理员 admin 的网络 NET。成员:
4
+ // alice —— 新建成员,经授权接口显式设成 scoped,授权 P1(只看)、P2(可改);
5
+ // (#746 起新成员默认值 NEW_MEMBER_TASK_ACCESS 就是 'scoped',见「默认值与授权接口」)
6
+ // carol —— 升级前的老成员(task_access='all'):行为必须与今天逐字相同;
7
+ // vic —— 经授权接口显式设成 scoped 的 viewer,授权 P3(只看)。
8
+ // admin 建的卡:c_own(负责人 alice)、c_part(参与人 alice)、c_p1 / c_p2 / c_p3(在项目里)、c_other(与 alice 无关)。
9
+ // 正向:相关卡与授权项目看得见;负责 / 自建 / can_edit 能改;老成员与 owner 照旧;MCP 与 REST 一致。
10
+ // 反向:看不见的卡在 GET / PATCH / DELETE / 勾子任务 / #N 上与不存在的卡逐字节相同;只读卡 403;
11
+ // 往没授权的项目建卡、挂到看不见的父卡、撞别人的 client_id、external_ref / upsert、项目管理都被挡。
12
+
13
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
14
+ import { mkdtempSync, rmSync } from "fs";
15
+ import { join } from "path";
16
+ import { tmpdir } from "os";
17
+ import { issueUserToken, register } from "./auth.js";
18
+ import { db } from "./db.js";
19
+ import { NEW_MEMBER_TASK_ACCESS } from "./task-access.js";
20
+
21
+ const DIR = mkdtempSync(join(tmpdir(), "anet-task-access-"));
22
+ let BASE = "";
23
+ let hub: any = null;
24
+ const PW = "TaskAccessPassw0rd!x";
25
+ let NET = "";
26
+ let admin = { token: "", id: "" };
27
+ let alice = { token: "", id: "" };
28
+ let carol = { token: "", id: "" };
29
+ let vic = { token: "", id: "" };
30
+ const P: Record<string, string> = {};
31
+ const C: Record<string, { id: string; seq: number }> = {};
32
+
33
+ type R = { status: number; body: any; text: string };
34
+ async function send(token: string, method: string, path: string, payload?: unknown): Promise<R> {
35
+ const res = await fetch(`${BASE}${path}`, {
36
+ method,
37
+ headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), "Content-Type": "application/json" },
38
+ body: payload === undefined ? undefined : JSON.stringify(payload),
39
+ });
40
+ const text = await res.text();
41
+ let body: any = null;
42
+ try { body = JSON.parse(text); } catch {}
43
+ return { status: res.status, body, text };
44
+ }
45
+ const get = (t: string, p: string) => send(t, "GET", p);
46
+ const listIds = async (t: string, qs = "") => {
47
+ const r = await get(t, `/api/requirements?network_id=${NET}${qs}`);
48
+ expect(r.status).toBe(200);
49
+ return (r.body.requirements as any[]).map(x => x.name).filter((n: string) => n.startsWith("ta-")).sort();
50
+ };
51
+ async function mcp(token: string, name: string, args: Record<string, unknown>): Promise<any> {
52
+ const res = await fetch(`${BASE}/mcp`, {
53
+ method: "POST",
54
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
55
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
56
+ });
57
+ const raw = await res.text();
58
+ const lines = raw.split("\n").filter(x => x.startsWith("data:"));
59
+ const payload = lines.length ? JSON.parse(lines.at(-1)!.slice(5).trim()) : JSON.parse(raw);
60
+ return JSON.parse(payload.result.content[0].text);
61
+ }
62
+ const userRef = (id: string) => ({ kind: "user", id });
63
+
64
+ beforeAll(async () => {
65
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
66
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
67
+ process.env.HOST = "127.0.0.1";
68
+ const a = register(`ta_admin_${Date.now()}`, PW, undefined, "Admin");
69
+ expect(a.ok).toBe(true);
70
+ admin = { token: a.token!, id: a.user!.user_id };
71
+ NET = a.network_id!;
72
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [admin.id]);
73
+ const mod: any = await import("./server.js");
74
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
75
+ BASE = `http://127.0.0.1:${hub.port}`;
76
+
77
+ const mk = async (username: string, role: string) => {
78
+ const r = await send(admin.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role });
79
+ expect(r.status).toBe(200);
80
+ const login = await send("", "POST", "/api/auth/login", { username, password: PW });
81
+ expect(login.status).toBe(200);
82
+ return { token: login.body.token as string, id: login.body.user.user_id as string };
83
+ };
84
+ const stamp = Date.now();
85
+ alice = await mk(`ta_alice_${stamp}`, "member");
86
+ carol = await mk(`ta_carol_${stamp}`, "member");
87
+ vic = await mk(`ta_vic_${stamp}`, "viewer");
88
+ // carol = 升级前就在网络里的成员(ALTER 默认 'all')。
89
+ db.run("UPDATE network_members SET task_access = 'all' WHERE network_id = ?1 AND user_id = ?2", [NET, carol.id]);
90
+
91
+ for (const name of ["ta-P1", "ta-P2", "ta-P3", "ta-P4"]) {
92
+ const r = await send(admin.token, "POST", "/api/requirements/projects", { network_id: NET, name });
93
+ expect(r.status).toBe(201);
94
+ P[name] = r.body.project.id;
95
+ }
96
+ const card = async (name: string, extra: Record<string, unknown> = {}) => {
97
+ const r = await send(admin.token, "POST", "/api/requirements", { network_id: NET, name, ...extra });
98
+ expect(r.status).toBe(201);
99
+ C[name] = { id: r.body.requirement.id, seq: r.body.requirement.seq };
100
+ };
101
+ await card("ta-own", { owner: userRef(alice.id), checklist: [{ id: "i1", text: "x", done: false }] });
102
+ await card("ta-part", { participants: [userRef(alice.id)], checklist: [{ id: "i1", text: "x", done: false }] });
103
+ await card("ta-p1", { project_id: P["ta-P1"] });
104
+ await card("ta-p2", { project_id: P["ta-P2"] });
105
+ await card("ta-p3", { project_id: P["ta-P3"] });
106
+ await card("ta-other", { tags: ["secret-tag"], client_id: "admin-client-1" });
107
+
108
+ const g = await send(admin.token, "PUT", `/api/networks/${NET}/members/${alice.id}/task-grants`, { task_access: "scoped", project_grants: [{ project_id: P["ta-P1"] }, { project_id: P["ta-P2"], can_edit: true }] });
109
+ expect(g.status).toBe(200);
110
+ const gv = await send(admin.token, "PUT", `/api/networks/${NET}/members/${vic.id}/task-grants`, { task_access: "scoped", project_grants: [P["ta-P3"]] });
111
+ expect(gv.status).toBe(200);
112
+ }, 30_000);
113
+
114
+ afterAll(() => {
115
+ try { hub?.stop?.(true); } catch {}
116
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
117
+ });
118
+
119
+ describe("默认值与授权接口", () => {
120
+ test("#746 新成员默认 scoped:管理员建号 / POST members / 邀请码加入都落在 scoped —— 只看自己的卡与授权项目;admin 角色照旧 all", async () => {
121
+ expect(NEW_MEMBER_TASK_ACCESS).toBe("scoped");
122
+ const stamp = Date.now();
123
+ const names = async (t: string) => {
124
+ const r = await get(t, `/api/requirements?network_id=${NET}`);
125
+ expect(r.status).toBe(200);
126
+ return (r.body.requirements as any[]).map(x => x.name).filter((n: string) => n.startsWith("ta-") || n.startsWith("dm-")).sort();
127
+ };
128
+ // 管理员建号
129
+ const u1 = { username: `ta_new1_${stamp}`, password: PW, network_id: NET, role: "member" };
130
+ expect((await send(admin.token, "POST", "/api/admin/users", u1)).status).toBe(200);
131
+ const l1 = await send("", "POST", "/api/auth/login", { username: u1.username, password: PW });
132
+ // POST /members 不带 task_access
133
+ const u2 = register(`ta_new2_${stamp}`, PW);
134
+ expect((await send(admin.token, "POST", `/api/networks/${NET}/members`, { user_id: u2.user!.user_id, role: "member" })).status).toBe(200);
135
+ // 邀请码加入(member 与 viewer 两种邀请)
136
+ const inv = await send(admin.token, "POST", `/api/networks/${NET}/invite`, { role: "member" });
137
+ const u3 = register(`ta_new3_${stamp}`, PW);
138
+ expect((await send(u3.token!, "POST", "/api/networks/join", { invite_code: inv.body.invite_code })).status).toBe(200);
139
+ const paths: Array<[string, string, string]> = [["admin-users", l1.body.user.user_id, l1.body.token], ["post-members", u2.user!.user_id, u2.token!], ["invite", u3.user!.user_id, u3.token!]];
140
+ for (const [path, id, token] of paths) {
141
+ const g = await get(admin.token, `/api/networks/${NET}/members/${id}/task-grants`);
142
+ expect([path, g.body.task_access, g.body.restricted]).toEqual([path, "scoped", true]);
143
+ const me = await get(token, "/api/auth/me");
144
+ expect(me.body.networks.find((n: any) => n.network_id === NET).task_access).toBe("scoped");
145
+ // 自己负责的卡 + 授权项目 P1 看得见;别人的无关卡(ta-other)列表里没有、直取也是 404
146
+ expect((await send(admin.token, "POST", "/api/requirements", { network_id: NET, name: `dm-own-${path}`, owner: userRef(id) })).status).toBe(201);
147
+ expect((await send(admin.token, "PUT", `/api/networks/${NET}/members/${id}/task-grants`, { project_grants: [P["ta-P1"]] })).status).toBe(200);
148
+ expect([path, await names(token)]).toEqual([path, [`dm-own-${path}`, "ta-p1"]]);
149
+ expect((await get(token, `/api/requirements/${C["ta-other"].id}?network_id=${NET}`)).status).toBe(404);
150
+ }
151
+ // 管理员仍能经现有接口把成员放宽到 all,之后看见全部卡
152
+ expect((await send(admin.token, "PUT", `/api/networks/${NET}/members/${u2.user!.user_id}/task-grants`, { task_access: "all" })).status).toBe(200);
153
+ expect((await names(u2.token!))).toContain("ta-other");
154
+ // admin 角色(POST members / 邀请码)照旧存 'all';不受这一列约束
155
+ const a1 = register(`ta_new5_${stamp}`, PW);
156
+ expect((await send(admin.token, "POST", `/api/networks/${NET}/members`, { user_id: a1.user!.user_id, role: "admin" })).status).toBe(200);
157
+ const invA = await send(admin.token, "POST", `/api/networks/${NET}/invite`, { role: "admin" });
158
+ const a2 = register(`ta_new6_${stamp}`, PW);
159
+ expect((await send(a2.token!, "POST", "/api/networks/join", { invite_code: invA.body.invite_code })).status).toBe(200);
160
+ for (const a of [a1, a2]) {
161
+ const g = await get(admin.token, `/api/networks/${NET}/members/${a.user!.user_id}/task-grants`);
162
+ expect([g.body.task_access, g.body.restricted]).toEqual(["all", false]);
163
+ expect(await names(a.token!)).toContain("ta-other");
164
+ }
165
+ // 升级前的老成员(carol,直接写 'all' 的行)照旧全看见
166
+ expect(await names(carol.token)).toContain("ta-other");
167
+ // POST /members 显式传 all 仍然生效
168
+ const u4 = register(`ta_new4_${stamp}`, PW);
169
+ expect((await send(admin.token, "POST", `/api/networks/${NET}/members`, { user_id: u4.user!.user_id, role: "member", task_access: "all" })).status).toBe(200);
170
+ expect((await get(admin.token, `/api/networks/${NET}/members/${u4.user!.user_id}/task-grants`)).body.task_access).toBe("all");
171
+ for (const id of [l1.body.user.user_id, u2.user!.user_id, u3.user!.user_id, u4.user!.user_id, a1.user!.user_id, a2.user!.user_id]) {
172
+ expect((await send(admin.token, "DELETE", `/api/networks/${NET}/members/${id}`)).status).toBe(200);
173
+ }
174
+ });
175
+
176
+ test("经授权接口显式设成 scoped 的成员:task_access / restricted / 授权与改默认值之前逐字相同;升级前的行是 'all'", async () => {
177
+ const r = await get(admin.token, `/api/networks/${NET}/members/${alice.id}/task-grants`);
178
+ expect(r.body.task_access).toBe("scoped");
179
+ expect(r.body.restricted).toBe(true);
180
+ expect(r.body.project_grants).toEqual([{ project_id: P["ta-P1"], can_edit: false }, { project_id: P["ta-P2"], can_edit: true }].sort((x, y) => x.project_id.localeCompare(y.project_id)));
181
+ expect((await get(admin.token, `/api/networks/${NET}/members/${carol.id}/task-grants`)).body.task_access).toBe("all");
182
+ });
183
+
184
+ test("/members 与 /auth/me 带上 task_access;owner 恒 all", async () => {
185
+ const m = await get(admin.token, `/api/networks/${NET}/members`);
186
+ const row = (id: string) => m.body.members.find((x: any) => x.user_id === id);
187
+ expect(row(alice.id).task_access).toBe("scoped");
188
+ expect(row(alice.id).task_project_count).toBe(2);
189
+ expect(row(carol.id).task_access).toBe("all");
190
+ expect(row(admin.id).task_access).toBe("all");
191
+ const me = await get(alice.token, "/api/auth/me");
192
+ expect(me.body.networks.find((n: any) => n.network_id === NET).task_access).toBe("scoped");
193
+ });
194
+
195
+ test("授权接口:外网 / 不存在的项目 400 且整批不写;不传的字段保持原样;普通成员与节点令牌 403;审计", async () => {
196
+ const bad = await send(admin.token, "PUT", `/api/networks/${NET}/members/${alice.id}/task-grants`, { project_grants: [P["ta-P4"], "proj_does_not_exist"] });
197
+ expect(bad.status).toBe(400);
198
+ expect(bad.body.error).toBe("project_not_in_network");
199
+ expect((await get(admin.token, `/api/networks/${NET}/members/${alice.id}/task-grants`)).body.project_grants.length).toBe(2);
200
+ const keep = await send(admin.token, "PUT", `/api/networks/${NET}/members/${alice.id}/task-grants`, { task_access: "scoped" });
201
+ expect(keep.body.project_grants.length).toBe(2);
202
+ expect((await send(alice.token, "GET", `/api/networks/${NET}/members/${alice.id}/task-grants`)).status).toBe(403);
203
+ expect((await send(carol.token, "PUT", `/api/networks/${NET}/members/${alice.id}/task-grants`, { task_access: "all" })).status).toBe(403);
204
+ const nt = await send(admin.token, "POST", "/api/auth/node-token", { network_id: NET, node_name: "ta-node", node_id: "node_ta_1" });
205
+ expect((await get(nt.body.token, `/api/networks/${NET}/members/${alice.id}/task-grants`)).status).toBe(403);
206
+ expect(db.get("SELECT 1 FROM audit_log WHERE action = 'member_task_grants_changed' AND network_id = ?1", NET)).toBeTruthy();
207
+ });
208
+ });
209
+
210
+ describe("可见性", () => {
211
+ test("alice 只看见:负责 / 参与 / 授权项目的卡;不看见无关的与没授权项目的", async () => {
212
+ expect(await listIds(alice.token)).toEqual(["ta-own", "ta-p1", "ta-p2", "ta-part"]);
213
+ });
214
+ test("viewer_can:scoped 调用者每张卡带上能不能改 / 删;老成员与 owner 不带", async () => {
215
+ const r = await get(alice.token, `/api/requirements?network_id=${NET}`);
216
+ const by = (n: string) => (r.body.requirements as any[]).find(x => x.name === n)?.viewer_can;
217
+ expect(by("ta-own")).toEqual({ edit: true, delete: true });
218
+ // 参与人:整卡只读,但状态和检查项能改(edit_fields;旧 App 不认这个键,照旧画只读)。
219
+ expect(by("ta-part")).toEqual({ edit: false, delete: false, edit_fields: ["column", "checklist"] });
220
+ expect(by("ta-p1")).toEqual({ edit: false, delete: false });
221
+ expect(by("ta-p2")).toEqual({ edit: true, delete: false });
222
+ expect((await get(vic.token, `/api/requirements/${C["ta-p3"].id}?network_id=${NET}`)).body.requirement.viewer_can).toEqual({ edit: false, delete: false });
223
+ const carolList = await get(carol.token, `/api/requirements?network_id=${NET}`);
224
+ expect((carolList.body.requirements as any[]).every(x => x.viewer_can === undefined)).toBe(true);
225
+ expect((await get(admin.token, `/api/requirements/${C["ta-own"].id}`)).body.requirement.viewer_can).toBeUndefined();
226
+ });
227
+ test("viewer vic 只看见授权项目 P3 的卡", async () => {
228
+ expect(await listIds(vic.token)).toEqual(["ta-p3"]);
229
+ });
230
+ test("老成员 carol 与 owner 照旧看全部", async () => {
231
+ const all = ["ta-other", "ta-own", "ta-p1", "ta-p2", "ta-p3", "ta-part"];
232
+ expect(await listIds(carol.token)).toEqual(all);
233
+ expect(await listIds(admin.token)).toEqual(all);
234
+ });
235
+ test("搜索也按可见性过滤", async () => {
236
+ expect(await listIds(alice.token, "&q=ta-other")).toEqual([]);
237
+ expect(await listIds(carol.token, "&q=ta-other")).toEqual(["ta-other"]);
238
+ });
239
+ test("看不见的卡:GET / #N 与不存在的卡逐字节相同", async () => {
240
+ const hidden = await get(alice.token, `/api/requirements/${C["ta-other"].id}?network_id=${NET}`);
241
+ const ghost = await get(alice.token, `/api/requirements/req_does_not_exist?network_id=${NET}`);
242
+ expect(hidden.status).toBe(404);
243
+ expect(hidden.text).toBe(ghost.text);
244
+ const bySeq = await get(alice.token, `/api/requirements/%23${C["ta-other"].seq}?network_id=${NET}`);
245
+ const noSeq = await get(alice.token, `/api/requirements/%2399999?network_id=${NET}`);
246
+ expect(bySeq.status).toBe(404);
247
+ expect(bySeq.text).toBe(noSeq.text);
248
+ expect((await get(carol.token, `/api/requirements/${C["ta-other"].id}?network_id=${NET}`)).status).toBe(200);
249
+ });
250
+ test("标签只从看得见的卡汇总", async () => {
251
+ expect((await get(alice.token, `/api/requirements/tags?network_id=${NET}`)).body.tags).not.toContain("secret-tag");
252
+ expect((await get(carol.token, `/api/requirements/tags?network_id=${NET}`)).body.tags).toContain("secret-tag");
253
+ });
254
+ test("项目列表:alice 只看授权的 P1 / P2", async () => {
255
+ const r = await get(alice.token, `/api/requirements/projects?network_id=${NET}`);
256
+ expect(r.body.projects.map((p: any) => p.name).sort()).toEqual(["ta-P1", "ta-P2"]);
257
+ expect((await get(carol.token, `/api/requirements/projects?network_id=${NET}`)).body.projects.length).toBe(4);
258
+ });
259
+ });
260
+
261
+ describe("写入", () => {
262
+ test("能改:负责的、可改项目里的;只读:只看项目里的(403 task_read_only);参与的只能改状态 / 检查项;看不见的与不存在的同一个 404", async () => {
263
+ expect((await send(alice.token, "PATCH", `/api/requirements/${C["ta-own"].id}?network_id=${NET}`, { column: "doing" })).status).toBe(200);
264
+ expect((await send(alice.token, "PATCH", `/api/requirements/${C["ta-p2"].id}?network_id=${NET}`, { column: "doing" })).status).toBe(200);
265
+ const p1 = await send(alice.token, "PATCH", `/api/requirements/${C["ta-p1"].id}?network_id=${NET}`, { column: "doing" });
266
+ expect(p1.status).toBe(403);
267
+ expect(p1.body).toEqual({ ok: false, error: "task_read_only" });
268
+ const part = await send(alice.token, "PATCH", `/api/requirements/${C["ta-part"].id}?network_id=${NET}`, { name: "renamed" });
269
+ expect(part.status).toBe(403);
270
+ expect(part.body.error).toBe("task_read_only");
271
+ expect(part.body.field).toBe("name");
272
+ const hidden = await send(alice.token, "PATCH", `/api/requirements/${C["ta-other"].id}?network_id=${NET}`, { column: "doing" });
273
+ const ghost = await send(alice.token, "PATCH", `/api/requirements/req_does_not_exist?network_id=${NET}`, { column: "doing" });
274
+ expect(hidden.status).toBe(404);
275
+ expect(hidden.text).toBe(ghost.text);
276
+ });
277
+
278
+ test("勾子任务:负责的能勾,参与的能勾,只看项目的 403,看不见的 404", async () => {
279
+ expect((await send(alice.token, "PATCH", `/api/requirements/${C["ta-own"].id}/checklist/i1?network_id=${NET}`, { done: true })).status).toBe(200);
280
+ expect((await send(alice.token, "PATCH", `/api/requirements/${C["ta-part"].id}/checklist/i1?network_id=${NET}`, { done: true })).status).toBe(200);
281
+ const ro = await send(alice.token, "PATCH", `/api/requirements/${C["ta-p1"].id}/checklist/i1?network_id=${NET}`, { done: true });
282
+ expect(ro.status).toBe(403);
283
+ expect(ro.body.error).toBe("task_read_only");
284
+ expect((await send(alice.token, "PATCH", `/api/requirements/${C["ta-other"].id}/checklist/i1?network_id=${NET}`, { done: true })).status).toBe(404);
285
+ });
286
+
287
+ test("挪项目:只能挪进 can_edit 的项目;没授权的与不存在的同一个错误", async () => {
288
+ const r = await send(alice.token, "PATCH", `/api/requirements/${C["ta-own"].id}?network_id=${NET}`, { project_id: P["ta-P1"] });
289
+ const ghost = await send(alice.token, "PATCH", `/api/requirements/${C["ta-own"].id}?network_id=${NET}`, { project_id: "proj_nope" });
290
+ expect(r.status).toBe(400);
291
+ expect(r.text).toBe(ghost.text);
292
+ });
293
+
294
+ test("新建:能建;自建的卡看得见也能改;建进只看项目 / 看不见的父卡 / 别人的 client_id / external_ref 都被挡", async () => {
295
+ const mine = await send(alice.token, "POST", "/api/requirements", { network_id: NET, name: "ta-alice-own" });
296
+ expect(mine.status).toBe(201);
297
+ expect(await listIds(alice.token)).toContain("ta-alice-own");
298
+ expect((await send(alice.token, "PATCH", `/api/requirements/${mine.body.requirement.id}?network_id=${NET}`, { priority: "high" })).status).toBe(200);
299
+ expect((await send(alice.token, "POST", "/api/requirements", { network_id: NET, name: "ta-into-p2", project_id: P["ta-P2"] })).status).toBe(201);
300
+ const intoP1 = await send(alice.token, "POST", "/api/requirements", { network_id: NET, name: "ta-into-p1", project_id: P["ta-P1"] });
301
+ const intoNone = await send(alice.token, "POST", "/api/requirements", { network_id: NET, name: "ta-into-none", project_id: "proj_nope" });
302
+ expect(intoP1.status).toBe(400);
303
+ expect(intoP1.text).toBe(intoNone.text);
304
+ const hiddenParent = await send(alice.token, "POST", "/api/requirements", { network_id: NET, name: "ta-child", parent_id: C["ta-other"].id });
305
+ const ghostParent = await send(alice.token, "POST", "/api/requirements", { network_id: NET, name: "ta-child", parent_id: "req_does_not_exist" });
306
+ expect(hiddenParent.status).toBe(400);
307
+ expect(hiddenParent.text).toBe(ghostParent.text);
308
+ const replay = await send(alice.token, "POST", "/api/requirements", { network_id: NET, name: "ta-replay", client_id: "admin-client-1" });
309
+ expect(replay.status).toBe(409);
310
+ expect(replay.body.error).toBe("client_id_taken");
311
+ expect(replay.text).not.toContain("ta-other");
312
+ const ext = await send(alice.token, "POST", "/api/requirements", { network_id: NET, name: "ta-ext", external_ref: "github:o/r#1" });
313
+ expect(ext.status).toBe(403);
314
+ expect(ext.body.error).toBe("external_ref_not_allowed");
315
+ });
316
+
317
+ test("upsert 与项目管理:scoped 成员 403(固定错误,不看参数)", async () => {
318
+ const up = await send(alice.token, "POST", "/api/requirements/upsert", { network_id: NET, name: "x", external_ref: "github:o/r#2" });
319
+ expect(up.status).toBe(403);
320
+ expect(up.body.error).toBe("upsert_not_allowed");
321
+ expect((await send(alice.token, "POST", `/api/requirements/projects?network_id=${NET}`, { name: "ta-alice-proj" })).status).toBe(403);
322
+ expect((await send(alice.token, "PATCH", `/api/requirements/projects/${P["ta-P2"]}?network_id=${NET}`, { name: "renamed" })).status).toBe(403);
323
+ });
324
+
325
+ test("删除:只能删负责 / 自建的;可改项目里的也不能删(403 task_delete_denied);删卡写审计", async () => {
326
+ const p2 = await send(alice.token, "DELETE", `/api/requirements/${C["ta-p2"].id}?network_id=${NET}`);
327
+ expect(p2.status).toBe(403);
328
+ expect(p2.body.error).toBe("task_delete_denied");
329
+ const hidden = await send(alice.token, "DELETE", `/api/requirements/${C["ta-other"].id}?network_id=${NET}`);
330
+ const ghost = await send(alice.token, "DELETE", `/api/requirements/req_does_not_exist?network_id=${NET}`);
331
+ expect(hidden.status).toBe(404);
332
+ expect(hidden.text).toBe(ghost.text);
333
+ expect((await send(alice.token, "DELETE", `/api/requirements/${C["ta-own"].id}?network_id=${NET}`)).status).toBe(200);
334
+ const audit = db.get<{ detail: string; network_id: string }>("SELECT detail, network_id FROM audit_log WHERE action = 'requirement_deleted' AND target_id = ?1", C["ta-own"].id);
335
+ expect(audit?.network_id).toBe(NET);
336
+ expect(audit?.detail).toContain("ta-own");
337
+ });
338
+
339
+ test("被拒的写入审计限频:同一 (用户, 卡) 连续两次只记一条", async () => {
340
+ await send(alice.token, "PATCH", `/api/requirements/${C["ta-p1"].id}?network_id=${NET}`, { name: "a" });
341
+ await send(alice.token, "PATCH", `/api/requirements/${C["ta-p1"].id}?network_id=${NET}`, { name: "b" });
342
+ const n = db.get<{ c: number }>("SELECT COUNT(*) AS c FROM audit_log WHERE action = 'task_access_denied' AND user_id = ?1 AND target_id = ?2", alice.id, C["ta-p1"].id)?.c;
343
+ expect(n).toBe(1);
344
+ });
345
+
346
+ test("viewer 什么都不能写(沿用 permission_denied)", async () => {
347
+ expect((await send(vic.token, "PATCH", `/api/requirements/${C["ta-p3"].id}?network_id=${NET}`, { column: "doing" })).status).toBe(403);
348
+ });
349
+
350
+ test("老成员 carol 照旧:能改、能删别人的卡、能管项目、能 upsert", async () => {
351
+ expect((await send(carol.token, "PATCH", `/api/requirements/${C["ta-other"].id}?network_id=${NET}`, { column: "doing" })).status).toBe(200);
352
+ expect((await send(carol.token, "POST", `/api/requirements/projects?network_id=${NET}`, { name: "ta-carol-proj" })).status).toBe(201);
353
+ expect((await send(carol.token, "POST", "/api/requirements/upsert", { network_id: NET, name: "ta-carol-up", external_ref: "github:o/r#3" })).status).toBe(201);
354
+ expect((await send(carol.token, "DELETE", `/api/requirements/${C["ta-p3"].id}?network_id=${NET}`)).status).toBe(200);
355
+ });
356
+ });
357
+
358
+ describe("MCP 与 REST 一致", () => {
359
+ test("requirements_list / requirements_get 对 alice 同样过滤", async () => {
360
+ const list = await mcp(alice.token, "requirements_list", { network_id: NET });
361
+ const names = (list.requirements as any[]).map(x => x.name).filter((n: string) => n.startsWith("ta-")).sort();
362
+ expect(names).toEqual(await listIds(alice.token));
363
+ const hidden = await mcp(alice.token, "requirements_get", { id: C["ta-other"].id, network_id: NET });
364
+ expect(hidden.ok).toBe(false);
365
+ expect(hidden.error).toBe("requirement_not_found");
366
+ });
367
+ });
368
+
369
+ describe("LIKE 通配符不越权(参与人 / 负责人 id 里有 % 或 _)", () => {
370
+ // 可见性子句用 LIKE … ESCAPE 匹配 `"kind":"user","id":"<uid>"`;id 里的 % / _ 必须按字面匹配。
371
+ // 造两个用户:pct 的 id 带 % 与 _,twin 的 id 在「不转义」时恰好能被 pct 的模式匹配上。
372
+ const PCT = "u_ta%pct_1";
373
+ const TWIN = "u_taXpctA1";
374
+ let pctToken = "";
375
+ beforeAll(async () => {
376
+ for (const [id, name] of [[PCT, "ta_pct"], [TWIN, "ta_twin"]] as const) {
377
+ db.run("INSERT INTO users (user_id, username, password_hash, display_name, role) VALUES (?1, ?2, 'x', ?2, 'user')", [id, `${name}_${Date.now()}`]);
378
+ db.run("INSERT INTO network_members (network_id, user_id, role, agent_access, task_access) VALUES (?1, ?2, 'member', 'granted', 'scoped')", [NET, id]);
379
+ }
380
+ pctToken = issueUserToken(PCT).token;
381
+ for (const [name, ref] of [["ta-twin-owned", TWIN], ["ta-pct-owned", PCT]] as const) {
382
+ const r = await send(admin.token, "POST", "/api/requirements", { network_id: NET, name, owner: userRef(ref), participants: [userRef(ref)] });
383
+ expect(r.status).toBe(201);
384
+ C[name] = { id: r.body.requirement.id, seq: r.body.requirement.seq };
385
+ }
386
+ });
387
+ test("id 带 % / _ 的成员只看见自己的卡,看不见被通配符「撞上」的那张", async () => {
388
+ const mine = await listIds(pctToken);
389
+ expect(mine).toContain("ta-pct-owned");
390
+ expect(mine).not.toContain("ta-twin-owned");
391
+ expect((await get(pctToken, `/api/requirements/${C["ta-twin-owned"].id}?network_id=${NET}`)).status).toBe(404);
392
+ });
393
+ });
394
+
395
+ describe("清理", () => {
396
+ test("删项目清掉项目授权;移出成员清掉他的全部授权", async () => {
397
+ expect((await send(admin.token, "DELETE", `/api/requirements/projects/${P["ta-P1"]}?network_id=${NET}`)).status).toBe(200);
398
+ expect(db.get("SELECT 1 FROM network_member_project_grants WHERE project_id = ?1", P["ta-P1"])).toBeFalsy();
399
+ expect((await send(admin.token, "DELETE", `/api/networks/${NET}/members/${vic.id}`)).status).toBe(200);
400
+ expect(db.get("SELECT 1 FROM network_member_project_grants WHERE user_id = ?1", vic.id)).toBeFalsy();
401
+ });
402
+ });