@sleep2agi/commhub-server 0.9.0-preview.92 → 0.9.0-preview.93

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.92",
3
+ "version": "0.9.0-preview.93",
4
4
  "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/auth.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * V3 Auth module — user registration, login, token management
3
3
  */
4
- import { db, generateId, hashPassword, verifyPassword, hashToken, generateToken, generateUserToken, generateNetworkToken, uuidv4 } from "./db.js"; import { deleteDepartmentsForNetwork } from "./departments.js"; // #419: also ensures network_members.department_id exists (same line on purpose: docs pin auth.ts line numbers)
4
+ import { db, generateId, hashPassword, verifyPassword, hashToken, generateToken, generateUserToken, generateNetworkToken, uuidv4 } from "./db.js"; import { deleteDepartmentsForNetwork, removeMemberFromChatGroups } from "./departments.js"; // #419: also ensures network_members.department_id exists (same line on purpose: docs pin auth.ts line numbers)
5
5
  import { WEAK_PASSWORDS } from "./password-dict.js";
6
6
  import { NETWORK_REST_COLUMNS, NETWORK_REST_SELECT, sqlColumns } from "./rest-projections.js";
7
7
  import { deleteAgentGrants, isAgentRestricted } from "./agent-access.js"; import { deleteTaskGrantsForMember, NEW_MEMBER_TASK_ACCESS, type TaskAccessMode } from "./task-access.js"; // 一行两个 import:文档钉着 auth.ts 的行号
@@ -687,7 +687,7 @@ export function removeNetworkMember(networkId: string, userId: string): { ok: bo
687
687
  // #488 —— 他在这个网络里的令牌(节点令牌 / 邀请码令牌)一起吊销;resolveToken 也会拒非成员的令牌,这里让库里的状态如实。
688
688
  db.run("UPDATE api_tokens SET revoked_at = datetime('now') WHERE network_id = ?1 AND user_id = ?2 AND revoked_at IS NULL", [networkId, userId]);
689
689
  deleteAgentGrants(networkId, userId);
690
- deleteTaskGrantsForMember(networkId, userId);
690
+ deleteTaskGrantsForMember(networkId, userId); removeMemberFromChatGroups(networkId, userId); // RFC-042:他在本网络所有群里的行(不论来源)。同一行:文档钉着 auth.ts 的行号
691
691
  });
692
692
  return { ok: true };
693
693
  }
@@ -0,0 +1,209 @@
1
+ // RFC-042(看板 #457)部门群,第一个 PR:建群 / 查群 / 权限 / 删部门解除关联。
2
+ // HTTP 集成测试(真实 Bun.serve,私有端口,临时库;PostgreSQL 由 test2123 梯子用 COMMHUB_TEST_PG_URL 跑同一个文件)。
3
+ // 钉住:谁能建(owner / admin / Hub 管理员 / 部门(含上级)负责人)、谁能看(群成员 + 管理者,别人 404)、
4
+ // Agent(节点令牌)一律 403、建群时的成员 = 部门子树成员 ∪ 子树负责人、一个部门一个群、删部门只解除关联。
5
+
6
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
7
+ import { mkdtempSync, rmSync } from "fs";
8
+ import { join } from "path";
9
+ import { tmpdir } from "os";
10
+ import { register, createNetworkTokenForNode } from "./auth.js";
11
+ import { db } from "./db.js";
12
+
13
+ const DIR = mkdtempSync(join(tmpdir(), "anet-dept-groups-"));
14
+ let BASE = "";
15
+ let hub: any = null;
16
+ const PW = "DeptGroupsPassw0rd!x";
17
+ let NET = "";
18
+ const U: Record<string, { token: string; id: string }> = {};
19
+ const DEPT: Record<string, string> = {};
20
+
21
+ async function send(token: string, method: string, path: string, body?: unknown) {
22
+ const res = await fetch(`${BASE}${path}`, { method, headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
23
+ const text = await res.text();
24
+ let json: any = null;
25
+ try { json = JSON.parse(text); } catch {}
26
+ return { status: res.status, body: json };
27
+ }
28
+ const D = (suffix = "") => `/api/networks/${NET}/departments${suffix}`;
29
+ const G = (deptKey: string) => D(`/${DEPT[deptKey]}/group`);
30
+ const CG = (suffix = "") => `/api/networks/${NET}/chat-groups${suffix}`;
31
+ const ids = (members: Array<{ user_id: string }>) => members.map((m) => m.user_id).sort();
32
+
33
+ beforeAll(async () => {
34
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
35
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
36
+ process.env.HOST = "127.0.0.1";
37
+ const a = register(`dg_owner_${Date.now()}`, PW, undefined, "Owner");
38
+ expect(a.ok).toBe(true);
39
+ U.owner = { token: a.token!, id: a.user!.user_id };
40
+ NET = a.network_id!;
41
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [U.owner.id]);
42
+ const mod: any = await import("./server.js");
43
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
44
+ BASE = `http://127.0.0.1:${hub.port}`;
45
+ const add = async (username: string, role: string) => {
46
+ expect((await send(U.owner.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role })).status).toBe(200);
47
+ const login = await send("", "POST", "/api/auth/login", { username, password: PW });
48
+ expect(login.status).toBe(200);
49
+ return { token: login.body.token as string, id: login.body.user.user_id as string };
50
+ };
51
+ const stamp = Date.now();
52
+ U.admin = await add(`dg_admin_${stamp}`, "admin");
53
+ U.head = await add(`dg_head_${stamp}`, "member"); // 研发部负责人
54
+ U.alice = await add(`dg_alice_${stamp}`, "member"); // 研发部成员
55
+ U.bob = await add(`dg_bob_${stamp}`, "member"); // 后端组成员 + 后端组负责人
56
+ U.carol = await add(`dg_carol_${stamp}`, "member"); // 未分配
57
+ U.viewer = await add(`dg_viewer_${stamp}`, "viewer"); // 研发部成员,只读
58
+ U.node = { token: createNetworkTokenForNode(U.owner.id, NET, "dg-node", "node_dg").token!, id: "node_dg" };
59
+ const o = register(`dg_outsider_${stamp}`, PW);
60
+ U.outsider = { token: o.token!, id: o.user!.user_id };
61
+
62
+ const mk = async (name: string, extra: Record<string, unknown> = {}) => {
63
+ const r = await send(U.owner.token, "POST", D(), { name, ...extra });
64
+ expect(r.status).toBe(201);
65
+ return r.body.department.id as string;
66
+ };
67
+ DEPT.rd = await mk("研发部", { leader_user_id: U.head.id });
68
+ DEPT.backend = await mk("后端组", { parent_id: DEPT.rd, leader_user_id: U.bob.id });
69
+ DEPT.market = await mk("市场部");
70
+ const place = async (who: string, dept: string | null) =>
71
+ expect((await send(U.owner.token, "PUT", `/api/networks/${NET}/members/${U[who].id}/department`, { department_id: dept })).status).toBe(200);
72
+ await place("head", DEPT.rd);
73
+ await place("alice", DEPT.rd);
74
+ await place("viewer", DEPT.rd);
75
+ await place("bob", DEPT.backend);
76
+ }, 30_000);
77
+
78
+ afterAll(() => {
79
+ try { hub?.stop?.(true); } catch {}
80
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
81
+ });
82
+
83
+ describe("who can create a department group", () => {
84
+ test("members, viewers, outsiders, nodes and unauthenticated callers cannot", async () => {
85
+ expect((await send(U.carol.token, "POST", G("rd"), {})).body.error).toBe("owner/admin required");
86
+ expect((await send(U.carol.token, "POST", G("rd"), {})).status).toBe(403);
87
+ expect((await send(U.alice.token, "POST", G("rd"), {})).status).toBe(403);
88
+ expect((await send(U.viewer.token, "POST", G("rd"), {})).status).toBe(403);
89
+ expect((await send(U.outsider.token, "POST", G("rd"), {})).status).toBe(403);
90
+ const node = await send(U.node.token, "POST", G("rd"), {});
91
+ expect(node.status).toBe(403);
92
+ expect(node.body.error).toBe("humans_only");
93
+ expect((await send("", "POST", G("rd"), {})).status).toBe(401);
94
+ expect(Number(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM chat_groups WHERE network_id = ?1", NET)?.n ?? 0)).toBe(0);
95
+ });
96
+
97
+ test("a head cannot create one outside their subtree (sub-department head → parent or sibling)", async () => {
98
+ for (const key of ["rd", "market"]) {
99
+ const r = await send(U.bob.token, "POST", G(key), {});
100
+ expect(r.status).toBe(403);
101
+ expect(r.body.error).toBe("department_scope_denied");
102
+ }
103
+ });
104
+
105
+ test("owner creates the 研发部 group: name defaults to the department, members = subtree members ∪ subtree heads", async () => {
106
+ const r = await send(U.owner.token, "POST", G("rd"));
107
+ expect(r.status).toBe(201);
108
+ expect(r.body.group.name).toBe("研发部");
109
+ expect(r.body.group.department_id).toBe(DEPT.rd);
110
+ expect(r.body.group.id).toMatch(/^grp_[0-9a-f]{20}$/);
111
+ // 研发部:head / alice / viewer;后端组(下级):bob。未分配的 carol、不在部门里的 owner / admin 不进。
112
+ expect(ids(r.body.members)).toEqual([U.alice.id, U.bob.id, U.head.id, U.viewer.id].sort());
113
+ expect(r.body.group.member_count).toBe(4);
114
+ for (const m of r.body.members) expect(m.source).toBe("department");
115
+ });
116
+
117
+ test("one group per department: a second create → 409 with the existing group_id", async () => {
118
+ const existing = (await send(U.owner.token, "GET", G("rd"))).body.group.id;
119
+ for (const who of ["owner", "admin", "head"]) {
120
+ const r = await send(U[who].token, "POST", G("rd"), { name: "又一个" });
121
+ expect(r.status).toBe(409);
122
+ expect(r.body.error).toBe("department_group_exists");
123
+ expect(r.body.group_id).toBe(existing);
124
+ }
125
+ });
126
+
127
+ test("a head can create one for a department in their subtree, with a custom name", async () => {
128
+ const r = await send(U.head.token, "POST", G("backend"), { name: " 后端小群 " });
129
+ expect(r.status).toBe(201);
130
+ expect(r.body.group.name).toBe("后端小群");
131
+ // 后端组只有 bob(他同时是负责人);研发部负责人在上级部门,不在这个子树里。
132
+ expect(ids(r.body.members)).toEqual([U.bob.id]);
133
+ const audit = db.get<{ detail: string }>("SELECT detail FROM audit_log WHERE network_id = ?1 AND action = 'department_group_created' ORDER BY id DESC LIMIT 1", NET);
134
+ expect(JSON.parse(audit!.detail)).toMatchObject({ id: DEPT.backend, via: "leader" });
135
+ });
136
+
137
+ test("admin can create; bad names and unknown departments are rejected", async () => {
138
+ expect((await send(U.admin.token, "POST", G("market"), { name: 42 })).body.error).toBe("invalid_group_name");
139
+ expect((await send(U.admin.token, "POST", G("market"), { name: "x".repeat(41) })).status).toBe(400);
140
+ expect((await send(U.admin.token, "POST", G("market"), { name: " " })).status).toBe(400);
141
+ expect((await send(U.admin.token, "POST", D("/dept_nope/group"), {})).status).toBe(404);
142
+ const r = await send(U.admin.token, "POST", G("market"), { name: "市场部群" });
143
+ expect(r.status).toBe(201);
144
+ expect(r.body.members).toEqual([]);
145
+ });
146
+ });
147
+
148
+ describe("who can read", () => {
149
+ test("GET …/departments/:id/group: group members and managers see it; others get 404; nodes 403", async () => {
150
+ for (const who of ["owner", "admin", "head", "alice", "bob", "viewer"]) {
151
+ const r = await send(U[who].token, "GET", G("rd"));
152
+ expect(r.status).toBe(200);
153
+ expect(ids(r.body.members)).toHaveLength(4);
154
+ }
155
+ const carol = await send(U.carol.token, "GET", G("rd"));
156
+ expect(carol.status).toBe(404);
157
+ expect(carol.body.error).toBe("department_group_not_found");
158
+ expect((await send(U.node.token, "GET", G("rd"))).status).toBe(403);
159
+ expect((await send(U.outsider.token, "GET", G("rd"))).status).toBe(403);
160
+ });
161
+
162
+ test("GET …/chat-groups lists my groups; owner / admin see all", async () => {
163
+ const names = async (who: string) => (await send(U[who].token, "GET", CG())).body.groups.map((g: any) => g.name).sort();
164
+ expect(await names("alice")).toEqual(["研发部"]);
165
+ expect(await names("bob")).toEqual(["后端小群", "研发部"].sort());
166
+ expect(await names("carol")).toEqual([]);
167
+ expect(await names("owner")).toEqual(["后端小群", "市场部群", "研发部"].sort());
168
+ const owner = (await send(U.owner.token, "GET", CG())).body.groups;
169
+ expect(owner.every((g: any) => g.is_member === false)).toBe(true);
170
+ expect((await send(U.node.token, "GET", CG())).status).toBe(403);
171
+ expect((await send(U.outsider.token, "GET", CG())).status).toBe(403);
172
+ expect((await send(U.alice.token, "POST", CG(), {})).status).toBe(405);
173
+ });
174
+
175
+ test("GET …/chat-groups/:id: members see the roster; a non-member gets 404 (existence not leaked)", async () => {
176
+ const gid = (await send(U.owner.token, "GET", G("rd"))).body.group.id;
177
+ const alice = await send(U.alice.token, "GET", CG(`/${gid}`));
178
+ expect(alice.status).toBe(200);
179
+ expect(alice.body.is_member).toBe(true);
180
+ expect(ids(alice.body.members)).toContain(U.alice.id);
181
+ expect((await send(U.carol.token, "GET", CG(`/${gid}`))).status).toBe(404);
182
+ expect((await send(U.carol.token, "GET", CG("/grp_doesnotexist"))).status).toBe(404);
183
+ });
184
+ });
185
+
186
+ describe("deleting a department unlinks its group", () => {
187
+ test("group and members stay with department_id = null; the id can get a fresh group later", async () => {
188
+ // carol 当负责人但不放进部门:部门是空的(能删),而她在 roster 里。(第 2 个 PR 起调出部门会被同步移出群,
189
+ // 所以「删部门时群里还有人」只会是负责人或手动拉的人。)
190
+ const tmp = (await send(U.owner.token, "POST", D(), { name: "临时项目组", id: "dept_tmp_dg", leader_user_id: U.carol.id })).body.department.id as string;
191
+ const created = await send(U.owner.token, "POST", D(`/${tmp}/group`), {});
192
+ expect(created.status).toBe(201);
193
+ expect(ids(created.body.members)).toEqual([U.carol.id]);
194
+ expect((await send(U.owner.token, "DELETE", D(`/${tmp}`))).status).toBe(200);
195
+
196
+ const after = await send(U.owner.token, "GET", CG(`/${created.body.group.id}`));
197
+ expect(after.status).toBe(200);
198
+ expect(after.body.group.department_id).toBe(null);
199
+ expect(ids(after.body.members)).toEqual([U.carol.id]);
200
+ // 解除关联后不再对账:carol 仍是这个群的成员(她已不是任何部门的负责人)。
201
+ expect((await send(U.carol.token, "GET", CG(`/${created.body.group.id}`))).status).toBe(200);
202
+
203
+ // 同一个部门 id 再建:不被已解除关联的旧群挡住(唯一索引允许多个 NULL)。
204
+ expect((await send(U.owner.token, "POST", D(), { name: "临时项目组", id: "dept_tmp_dg" })).status).toBe(201);
205
+ const again = await send(U.owner.token, "POST", D(`/${tmp}/group`), {});
206
+ expect(again.status).toBe(201);
207
+ expect(again.body.group.id).not.toBe(created.body.group.id);
208
+ });
209
+ });
@@ -0,0 +1,328 @@
1
+ // RFC-042(看板 #457)部门群,第二个 PR:成员同步 + 手动成员 + 改群名。
2
+ // HTTP 集成测试(真实 Bun.serve,私有端口,临时库;PostgreSQL 由 test2123 梯子用 COMMHUB_TEST_PG_URL 跑同一个文件)。
3
+ // 钉住 §4 的每个触发点:调进 / 调出 / 子树内调动、建带负责人的子部门、改上级、换负责人、删部门、移出网络、读时兜底对账;
4
+ // manual 行永远不被同步动(手动拉的人进部门后仍是 manual,离开部门也留在群里);手动移部门来源的人 → 409;
5
+ // 谁能管群(owner / admin / 该部门(含上级)负责人),看得见管不了 → 403,看不见 → 404,Agent → 403 humans_only。
6
+
7
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
8
+ import { mkdtempSync, rmSync } from "fs";
9
+ import { join } from "path";
10
+ import { tmpdir } from "os";
11
+ import { register, createNetworkTokenForNode } from "./auth.js";
12
+ import { db } from "./db.js";
13
+
14
+ const DIR = mkdtempSync(join(tmpdir(), "anet-dept-groups-sync-"));
15
+ let BASE = "";
16
+ let hub: any = null;
17
+ const PW = "DeptGroupsSyncPassw0rd!x";
18
+ let NET = "";
19
+ const U: Record<string, { token: string; id: string }> = {};
20
+ const DEPT: Record<string, string> = {};
21
+ const GRP: Record<string, string> = {};
22
+
23
+ async function send(token: string, method: string, path: string, body?: unknown) {
24
+ const res = await fetch(`${BASE}${path}`, { method, headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
25
+ const text = await res.text();
26
+ let json: any = null;
27
+ try { json = JSON.parse(text); } catch {}
28
+ return { status: res.status, body: json };
29
+ }
30
+ const D = (suffix = "") => `/api/networks/${NET}/departments${suffix}`;
31
+ const CG = (suffix = "") => `/api/networks/${NET}/chat-groups${suffix}`;
32
+ const place = async (who: string, dept: string | null) =>
33
+ expect((await send(U.owner.token, "PUT", `/api/networks/${NET}/members/${U[who].id}/department`, { department_id: dept })).status).toBe(200);
34
+ const patchDept = async (key: string, body: Record<string, unknown>) =>
35
+ expect((await send(U.owner.token, "PATCH", D(`/${DEPT[key]}`), body)).status).toBe(200);
36
+ /** 直接读库(不经过 GET,GET 会顺手对账,会把要测的「写操作时同步」掩盖掉)。user_id → source。 */
37
+ function rows(groupKey: string): Record<string, string> {
38
+ const out: Record<string, string> = {};
39
+ for (const r of db.all<{ user_id: string; source: string }>("SELECT user_id, source FROM chat_group_members WHERE group_id = ?1", GRP[groupKey])) out[r.user_id] = r.source;
40
+ return out;
41
+ }
42
+ const who = (groupKey: string) => Object.keys(rows(groupKey)).map((id) => Object.keys(U).find((k) => U[k].id === id) ?? id).sort();
43
+
44
+ beforeAll(async () => {
45
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
46
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
47
+ process.env.HOST = "127.0.0.1";
48
+ const a = register(`dgs_owner_${Date.now()}`, PW, undefined, "Owner");
49
+ expect(a.ok).toBe(true);
50
+ U.owner = { token: a.token!, id: a.user!.user_id };
51
+ NET = a.network_id!;
52
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [U.owner.id]);
53
+ const mod: any = await import("./server.js");
54
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
55
+ BASE = `http://127.0.0.1:${hub.port}`;
56
+ const add = async (username: string, role: string) => {
57
+ expect((await send(U.owner.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role })).status).toBe(200);
58
+ const login = await send("", "POST", "/api/auth/login", { username, password: PW });
59
+ expect(login.status).toBe(200);
60
+ return { token: login.body.token as string, id: login.body.user.user_id as string };
61
+ };
62
+ const stamp = Date.now();
63
+ U.admin = await add(`dgs_admin_${stamp}`, "admin");
64
+ U.head = await add(`dgs_head_${stamp}`, "member"); // 研发部负责人,研发部成员
65
+ U.alice = await add(`dgs_alice_${stamp}`, "member"); // 研发部成员
66
+ U.bob = await add(`dgs_bob_${stamp}`, "member"); // 后端组成员 + 后端组负责人
67
+ U.carol = await add(`dgs_carol_${stamp}`, "member"); // 未分配,拿来调来调去
68
+ U.dave = await add(`dgs_dave_${stamp}`, "member"); // 市场部成员
69
+ U.erin = await add(`dgs_erin_${stamp}`, "member"); // 未分配,用来测「移出网络」
70
+ U.viewer = await add(`dgs_viewer_${stamp}`, "viewer"); // 研发部成员,只读
71
+ U.node = { token: createNetworkTokenForNode(U.owner.id, NET, "dgs-node", "node_dgs").token!, id: "node_dgs" };
72
+ const o = register(`dgs_outsider_${stamp}`, PW);
73
+ U.outsider = { token: o.token!, id: o.user!.user_id };
74
+
75
+ const mk = async (name: string, extra: Record<string, unknown> = {}) => {
76
+ const r = await send(U.owner.token, "POST", D(), { name, ...extra });
77
+ expect(r.status).toBe(201);
78
+ return r.body.department.id as string;
79
+ };
80
+ DEPT.rd = await mk("研发部", { leader_user_id: U.head.id });
81
+ DEPT.backend = await mk("后端组", { parent_id: DEPT.rd, leader_user_id: U.bob.id });
82
+ DEPT.market = await mk("市场部");
83
+ await place("head", DEPT.rd);
84
+ await place("alice", DEPT.rd);
85
+ await place("viewer", DEPT.rd);
86
+ await place("bob", DEPT.backend);
87
+ await place("dave", DEPT.market);
88
+ for (const key of ["rd", "backend", "market"]) {
89
+ const r = await send(U.owner.token, "POST", D(`/${DEPT[key]}/group`), {});
90
+ expect(r.status).toBe(201);
91
+ GRP[key] = r.body.group.id;
92
+ }
93
+ }, 30_000);
94
+
95
+ afterAll(() => {
96
+ try { hub?.stop?.(true); } catch {}
97
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
98
+ });
99
+
100
+ describe("seed", () => {
101
+ test("groups start from the roster", () => {
102
+ expect(who("rd")).toEqual(["alice", "bob", "head", "viewer"]);
103
+ expect(who("backend")).toEqual(["bob"]);
104
+ expect(who("market")).toEqual(["dave"]);
105
+ });
106
+ });
107
+
108
+ describe("sync on moving a member (PUT …/members/:uid/department)", () => {
109
+ test("move in: joins the department group and every ancestor department group, source='department'", async () => {
110
+ await place("carol", DEPT.backend);
111
+ expect(rows("backend")[U.carol.id]).toBe("department");
112
+ expect(rows("rd")[U.carol.id]).toBe("department");
113
+ expect(rows("market")[U.carol.id]).toBeUndefined();
114
+ });
115
+
116
+ test("move within the subtree: leaves the sub-department group, stays in the parent group", async () => {
117
+ await place("carol", DEPT.rd);
118
+ expect(rows("backend")[U.carol.id]).toBeUndefined();
119
+ expect(rows("rd")[U.carol.id]).toBe("department");
120
+ });
121
+
122
+ test("move across: leaves 研发部, joins 市场部", async () => {
123
+ await place("carol", DEPT.market);
124
+ expect(rows("rd")[U.carol.id]).toBeUndefined();
125
+ expect(rows("market")[U.carol.id]).toBe("department");
126
+ });
127
+
128
+ test("move out (unassigned): removed from the group", async () => {
129
+ await place("carol", null);
130
+ expect(rows("market")[U.carol.id]).toBeUndefined();
131
+ expect(who("market")).toEqual(["dave"]);
132
+ });
133
+ });
134
+
135
+ describe("manual members are never touched by sync", () => {
136
+ test("a manual member who later joins the department keeps one row, still 'manual'; leaving the department keeps them in", async () => {
137
+ const add = await send(U.owner.token, "POST", CG(`/${GRP.market}/members`), { user_id: U.carol.id });
138
+ expect(add.status).toBe(201);
139
+ expect(add.body.member).toMatchObject({ user_id: U.carol.id, source: "manual" });
140
+ await place("carol", DEPT.market);
141
+ expect(rows("market")[U.carol.id]).toBe("manual");
142
+ expect(Number(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM chat_group_members WHERE group_id = ?1 AND user_id = ?2", GRP.market, U.carol.id)?.n)).toBe(1);
143
+ await place("carol", null);
144
+ expect(rows("market")[U.carol.id]).toBe("manual");
145
+ });
146
+
147
+ test("manual remove works for a manual row; a department-sourced member → 409 department_member and stays", async () => {
148
+ const dep = await send(U.owner.token, "DELETE", CG(`/${GRP.market}/members/${U.dave.id}`));
149
+ expect(dep.status).toBe(409);
150
+ expect(dep.body.error).toBe("department_member");
151
+ expect(rows("market")[U.dave.id]).toBe("department");
152
+ const man = await send(U.owner.token, "DELETE", CG(`/${GRP.market}/members/${U.carol.id}`));
153
+ expect(man.status).toBe(200);
154
+ expect(rows("market")[U.carol.id]).toBeUndefined();
155
+ expect((await send(U.owner.token, "DELETE", CG(`/${GRP.market}/members/${U.carol.id}`))).status).toBe(404);
156
+ const audit = db.get<{ detail: string }>("SELECT detail FROM audit_log WHERE network_id = ?1 AND action = 'chat_group_member_removed' ORDER BY id DESC LIMIT 1", NET);
157
+ expect(JSON.parse(audit!.detail)).toMatchObject({ group_id: GRP.market, user_id: U.carol.id });
158
+ });
159
+ });
160
+
161
+ describe("sync on department changes", () => {
162
+ test("re-parent (PATCH parent_id): the moved subtree's people leave the old ancestor group and join the new one", async () => {
163
+ await patchDept("backend", { parent_id: DEPT.market });
164
+ expect(rows("rd")[U.bob.id]).toBeUndefined();
165
+ expect(rows("market")[U.bob.id]).toBe("department");
166
+ expect(who("backend")).toEqual(["bob"]);
167
+ await patchDept("backend", { parent_id: DEPT.rd });
168
+ expect(rows("rd")[U.bob.id]).toBe("department");
169
+ expect(rows("market")[U.bob.id]).toBeUndefined();
170
+ });
171
+
172
+ test("set / change / clear the head: the head joins and leaves with the leadership", async () => {
173
+ await patchDept("market", { leader_user_id: U.carol.id }); // carol 未分配,只当负责人
174
+ expect(rows("market")[U.carol.id]).toBe("department");
175
+ await patchDept("market", { leader_user_id: U.erin.id });
176
+ expect(rows("market")[U.carol.id]).toBeUndefined();
177
+ expect(rows("market")[U.erin.id]).toBe("department");
178
+ await patchDept("market", { leader_user_id: null });
179
+ expect(rows("market")[U.erin.id]).toBeUndefined();
180
+ // 换的负责人本来就是部门成员 → 还在群里(按成员身份)。
181
+ await patchDept("rd", { leader_user_id: U.alice.id });
182
+ expect(rows("rd")[U.head.id]).toBe("department");
183
+ expect(rows("rd")[U.alice.id]).toBe("department");
184
+ await patchDept("rd", { leader_user_id: U.head.id });
185
+ });
186
+
187
+ test("renaming / re-sorting a department does not touch membership", async () => {
188
+ const before = rows("rd");
189
+ await patchDept("rd", { name: "研发中心", sort: 3 });
190
+ expect(rows("rd")).toEqual(before);
191
+ await patchDept("rd", { name: "研发部" });
192
+ });
193
+
194
+ test("creating a sub-department with a head adds that head to the ancestor groups; deleting it removes them", async () => {
195
+ const r = await send(U.owner.token, "POST", D(), { name: "前端组", parent_id: DEPT.rd, leader_user_id: U.carol.id });
196
+ expect(r.status).toBe(201);
197
+ DEPT.fe = r.body.department.id;
198
+ expect(rows("rd")[U.carol.id]).toBe("department");
199
+ expect(rows("backend")[U.carol.id]).toBeUndefined();
200
+ expect((await send(U.owner.token, "DELETE", D(`/${DEPT.fe}`))).status).toBe(200);
201
+ expect(rows("rd")[U.carol.id]).toBeUndefined();
202
+ });
203
+
204
+ test("an unlinked group (department deleted) is no longer synced", async () => {
205
+ const tmp = (await send(U.owner.token, "POST", D(), { name: "临时组", leader_user_id: U.erin.id })).body.department.id as string;
206
+ const g = await send(U.owner.token, "POST", D(`/${tmp}/group`), {});
207
+ expect(g.status).toBe(201);
208
+ GRP.tmp = g.body.group.id;
209
+ expect((await send(U.owner.token, "DELETE", D(`/${tmp}`))).status).toBe(200);
210
+ expect(who("tmp")).toEqual(["erin"]);
211
+ // 读也不对账(部门已不存在,roster 为空,对账会把 erin 踢掉)。
212
+ expect((await send(U.erin.token, "GET", CG(`/${GRP.tmp}`))).status).toBe(200);
213
+ expect(who("tmp")).toEqual(["erin"]);
214
+ // 解除关联的群里,原部门来源的行可以手动移掉(不会再被加回来)。
215
+ expect((await send(U.owner.token, "DELETE", CG(`/${GRP.tmp}/members/${U.erin.id}`))).status).toBe(200);
216
+ });
217
+ });
218
+
219
+ describe("removing someone from the network", () => {
220
+ test("drops every group row they had in this network, whatever the source", async () => {
221
+ await place("erin", DEPT.backend);
222
+ expect((await send(U.owner.token, "POST", CG(`/${GRP.market}/members`), { user_id: U.erin.id })).status).toBe(201);
223
+ await patchDept("market", { leader_user_id: U.erin.id });
224
+ expect(rows("backend")[U.erin.id]).toBe("department");
225
+ expect(rows("rd")[U.erin.id]).toBe("department");
226
+ expect(rows("market")[U.erin.id]).toBe("manual");
227
+ expect((await send(U.owner.token, "DELETE", `/api/networks/${NET}/members/${U.erin.id}`)).status).toBe(200);
228
+ expect(Number(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM chat_group_members WHERE network_id = ?1 AND user_id = ?2", NET, U.erin.id)?.n)).toBe(0);
229
+ // 她仍挂着市场部负责人(leader_user_id 没清),但已不是网络成员:读时对账也不会把她加回来。
230
+ expect((await send(U.owner.token, "GET", CG(`/${GRP.market}`))).status).toBe(200);
231
+ expect(rows("market")[U.erin.id]).toBeUndefined();
232
+ await patchDept("market", { leader_user_id: null });
233
+ });
234
+ });
235
+
236
+ describe("read-time reconcile (safety net for drift)", () => {
237
+ test("GET …/chat-groups/:gid and GET …/departments/:id/group put missing roster rows back and drop stale department rows", async () => {
238
+ db.run("DELETE FROM chat_group_members WHERE group_id = ?1 AND user_id = ?2", [GRP.rd, U.alice.id]);
239
+ db.run("INSERT INTO chat_group_members (group_id, user_id, network_id, source) VALUES (?1, ?2, ?3, 'department')", [GRP.rd, U.dave.id, NET]);
240
+ const r = await send(U.owner.token, "GET", CG(`/${GRP.rd}`));
241
+ expect(r.status).toBe(200);
242
+ expect(rows("rd")[U.alice.id]).toBe("department");
243
+ expect(rows("rd")[U.dave.id]).toBeUndefined();
244
+ // 漂移掉的人自己读:先对账再判成员,所以 alice 读得到。
245
+ db.run("DELETE FROM chat_group_members WHERE group_id = ?1 AND user_id = ?2", [GRP.rd, U.alice.id]);
246
+ expect((await send(U.alice.token, "GET", D(`/${DEPT.rd}/group`))).status).toBe(200);
247
+ expect(rows("rd")[U.alice.id]).toBe("department");
248
+ });
249
+ });
250
+
251
+ describe("rename + manual members: permissions", () => {
252
+ test("owner, admin and the department's head (incl. an ancestor head) can rename; bad names → 400", async () => {
253
+ expect((await send(U.owner.token, "PATCH", CG(`/${GRP.rd}`), { name: " 研发群 " })).body.group.name).toBe("研发群");
254
+ expect((await send(U.admin.token, "PATCH", CG(`/${GRP.rd}`), { name: "研发部" })).status).toBe(200);
255
+ const h = await send(U.head.token, "PATCH", CG(`/${GRP.backend}`), { name: "后端群" });
256
+ expect(h.status).toBe(200);
257
+ expect(h.body.group.name).toBe("后端群");
258
+ const audit = db.get<{ detail: string }>("SELECT detail FROM audit_log WHERE network_id = ?1 AND action = 'chat_group_renamed' ORDER BY id DESC LIMIT 1", NET);
259
+ expect(JSON.parse(audit!.detail)).toMatchObject({ group_id: GRP.backend, via: "leader" });
260
+ for (const name of [42, "", " ", "x".repeat(41), undefined]) {
261
+ const r = await send(U.owner.token, "PATCH", CG(`/${GRP.rd}`), name === undefined ? {} : { name });
262
+ expect(r.status).toBe(400);
263
+ expect(r.body.error).toBe("invalid_group_name");
264
+ }
265
+ });
266
+
267
+ test("a member who can see but not manage → 403 group_manage_denied; a non-member → 404; nodes → 403 humans_only", async () => {
268
+ // bob 管后端组(下级),管不了研发部的群,但他是研发部群的成员。
269
+ for (const [method, path, body] of [
270
+ ["PATCH", CG(`/${GRP.rd}`), { name: "x" }],
271
+ ["POST", CG(`/${GRP.rd}/members`), { user_id: U.carol.id }],
272
+ ["DELETE", CG(`/${GRP.rd}/members/${U.alice.id}`), undefined],
273
+ ] as const) {
274
+ for (const k of ["bob", "alice", "viewer"]) {
275
+ const r = await send(U[k].token, method, path, body);
276
+ expect(r.status).toBe(403);
277
+ expect(r.body.error).toBe("group_manage_denied");
278
+ }
279
+ const carol = await send(U.carol.token, method, path, body);
280
+ expect(carol.status).toBe(404);
281
+ expect(carol.body.error).toBe("group_not_found");
282
+ const node = await send(U.node.token, method, path, body);
283
+ expect(node.status).toBe(403);
284
+ expect(node.body.error).toBe("humans_only");
285
+ expect((await send(U.outsider.token, method, path, body)).status).toBe(403);
286
+ expect((await send("", method, path, body)).status).toBe(401);
287
+ }
288
+ // 不存在的群,管理员也是 404。
289
+ expect((await send(U.owner.token, "PATCH", CG("/grp_doesnotexist"), { name: "x" })).status).toBe(404);
290
+ expect(who("rd")).toEqual(["alice", "bob", "head", "viewer"]);
291
+ });
292
+
293
+ test("a head reads groups in their subtree, and can add / remove manual members there", async () => {
294
+ // head 不是后端组群的成员,但后端组在他的子树里:读得到。
295
+ const read = await send(U.head.token, "GET", CG(`/${GRP.backend}`));
296
+ expect(read.status).toBe(200);
297
+ expect(read.body.is_member).toBe(false);
298
+ const add = await send(U.head.token, "POST", CG(`/${GRP.backend}/members`), { user_id: U.dave.id });
299
+ expect(add.status).toBe(201);
300
+ expect(rows("backend")[U.dave.id]).toBe("manual");
301
+ const audit = db.get<{ detail: string }>("SELECT detail FROM audit_log WHERE network_id = ?1 AND action = 'chat_group_member_added' ORDER BY id DESC LIMIT 1", NET);
302
+ expect(JSON.parse(audit!.detail)).toMatchObject({ group_id: GRP.backend, user_id: U.dave.id, via: "leader" });
303
+ expect((await send(U.bob.token, "DELETE", CG(`/${GRP.backend}/members/${U.dave.id}`))).status).toBe(200);
304
+ // 越界:head 管不了市场部的群(不是成员 → 404)。
305
+ expect((await send(U.head.token, "POST", CG(`/${GRP.market}/members`), { user_id: U.alice.id })).status).toBe(404);
306
+ });
307
+
308
+ test("add: bad input, non-network users, and existing members are refused", async () => {
309
+ expect((await send(U.owner.token, "POST", CG(`/${GRP.rd}/members`), {})).body.error).toBe("user_id_required");
310
+ expect((await send(U.owner.token, "POST", CG(`/${GRP.rd}/members`), { user_id: 7 })).status).toBe(400);
311
+ const outsider = await send(U.owner.token, "POST", CG(`/${GRP.rd}/members`), { user_id: U.outsider.id });
312
+ expect(outsider.status).toBe(400);
313
+ expect(outsider.body.error).toBe("not_network_member");
314
+ expect((await send(U.owner.token, "POST", CG(`/${GRP.rd}/members`), { user_id: U.node.id })).body.error).toBe("not_network_member");
315
+ const dup = await send(U.owner.token, "POST", CG(`/${GRP.rd}/members`), { user_id: U.alice.id });
316
+ expect(dup.status).toBe(409);
317
+ expect(dup.body.error).toBe("already_group_member");
318
+ expect(rows("rd")[U.alice.id]).toBe("department");
319
+ expect((await send(U.owner.token, "POST", CG(`/${GRP.rd}/members`), "not json")).status).toBe(400);
320
+ });
321
+
322
+ test("unsupported methods → 405", async () => {
323
+ expect((await send(U.owner.token, "PUT", CG(`/${GRP.rd}`), { name: "x" })).status).toBe(405);
324
+ expect((await send(U.owner.token, "DELETE", CG(`/${GRP.rd}`))).status).toBe(405);
325
+ expect((await send(U.owner.token, "GET", CG(`/${GRP.rd}/members`))).status).toBe(405);
326
+ expect((await send(U.owner.token, "POST", CG(), {})).status).toBe(405);
327
+ });
328
+ });