@sleep2agi/commhub-server 0.9.0-preview.89 → 0.9.0-preview.91
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 +1 -1
- package/src/agent-access.ts +4 -0
- package/src/auth.ts +40 -6
- package/src/department-heads-http.test.ts +329 -0
- package/src/department-heads.ts +226 -0
- package/src/departments.ts +7 -1
- package/src/mcp-context-cost-http.test.ts +117 -0
- package/src/network-token-membership-http.test.ts +139 -0
- package/src/observer-avatar-http.test.ts +7 -5
- package/src/requirement-events.ts +2 -1
- package/src/requirement-notify.ts +31 -0
- package/src/requirements-comment-http.test.ts +165 -0
- package/src/requirements-errors-mcp-http.test.ts +3 -2
- package/src/requirements-errors.ts +14 -3
- package/src/requirements-people-mcp-http.test.ts +225 -0
- package/src/requirements-people.ts +127 -0
- package/src/requirements-status-participants-http.test.ts +216 -0
- package/src/requirements.ts +125 -8
- package/src/rest-explicit-columns-http.test.ts +2 -0
- package/src/server.ts +131 -37
- package/src/task-access.ts +68 -10
- package/src/tools.ts +109 -40
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
3
|
+
"version": "0.9.0-preview.91",
|
|
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/agent-access.ts
CHANGED
|
@@ -480,4 +480,8 @@ export const RESTRICTED_MEMBER_TOOLS: ReadonlySet<string> = new Set([
|
|
|
480
480
|
"projects_create",
|
|
481
481
|
"projects_update",
|
|
482
482
|
"requirements_events",
|
|
483
|
+
// #474:评论走同一个 REST 处理 —— 看不见的任务 404、只读角色 403;受限成员的评论和他的其他任务写一样按可见性准入。
|
|
484
|
+
"requirements_comment",
|
|
485
|
+
// #473:通讯录同 /api/requirements/people 的可见范围 —— 受限成员看不见的 Agent 不出现(hiddenNodeFilter)。
|
|
486
|
+
"requirements_people",
|
|
483
487
|
]);
|
package/src/auth.ts
CHANGED
|
@@ -337,12 +337,18 @@ export function isExpiredSessionToken(token: string): boolean {
|
|
|
337
337
|
return !!row;
|
|
338
338
|
}
|
|
339
339
|
|
|
340
|
-
export
|
|
340
|
+
export type TokenRejection = "node_owner_restricted" | "not_network_member";
|
|
341
|
+
|
|
342
|
+
/** 查令牌行并判它能不能用:没有这一行 → null;有但被拒 → { rejected };能用 → { row }。 */
|
|
343
|
+
function lookupToken(token: string): { row: any; rejected?: undefined } | { row?: undefined; rejected: TokenRejection } | null {
|
|
341
344
|
const tHash = hashToken(token);
|
|
342
345
|
const cutoff = idleCutoffOffset();
|
|
343
346
|
const row = db.get<any>(
|
|
344
347
|
`SELECT t.token_id, t.user_id, t.network_id, t.scope, t.name AS token_name,
|
|
345
|
-
u.username, u.display_name, u.email, u.role
|
|
348
|
+
u.username, u.display_name, u.email, u.role,
|
|
349
|
+
CASE WHEN t.network_id IS NULL THEN 1
|
|
350
|
+
WHEN EXISTS (SELECT 1 FROM network_members nm WHERE nm.network_id = t.network_id AND nm.user_id = t.user_id) THEN 1
|
|
351
|
+
ELSE 0 END AS is_member
|
|
346
352
|
FROM api_tokens t JOIN users u ON t.user_id = u.user_id
|
|
347
353
|
WHERE t.token_hash = ?1
|
|
348
354
|
AND (t.expires_at IS NULL OR t.expires_at > datetime('now'))
|
|
@@ -352,10 +358,34 @@ export function resolveToken(token: string): { user: AuthUser; networkId: string
|
|
|
352
358
|
|
|
353
359
|
if (!row) return null;
|
|
354
360
|
|
|
361
|
+
// #488 —— 网络令牌只在它的用户还是这个网络的成员时有效。被移出网络的人留下的令牌(移出前签发的、
|
|
362
|
+
// 或移出时还没自动吊销的老数据)以前照样解析:MCP 每个工具都拒,REST 读却能拿到整个网络的名册。
|
|
363
|
+
if (row.network_id && !Number(row.is_member)) return { rejected: "not_network_member" };
|
|
364
|
+
|
|
355
365
|
// 多用户 Agent 权限:网络令牌(ntok_ / 邀请码令牌)的权限是「这个网络里的一切」,
|
|
356
366
|
// 受限成员在受限网络里不能持有它 —— 否则拿它就绕过了授权过滤。这里是唯一的解析入口,
|
|
357
367
|
// 在这里拒绝,所有 REST / MCP / SSE 路径一起生效(含升级前就签发、后来才被设为受限的令牌)。
|
|
358
|
-
if (row.network_id && isAgentRestricted(row.user_id, row.network_id)) return
|
|
368
|
+
if (row.network_id && isAgentRestricted(row.user_id, row.network_id)) return { rejected: "node_owner_restricted" };
|
|
369
|
+
return { row };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* 令牌存在、但因为主人的网络成员身份被拒时,说清楚为什么(给 401 带上 reason / hint)。
|
|
374
|
+
* 只在调用者真的持有这枚令牌时才有输出 —— 不认识的令牌一律 null,不泄露任何东西。
|
|
375
|
+
*/
|
|
376
|
+
export function tokenRejection(token: string | null | undefined): { reason: TokenRejection; hint: string } | null {
|
|
377
|
+
if (!token) return null;
|
|
378
|
+
const r = lookupToken(token);
|
|
379
|
+
if (!r?.rejected) return null;
|
|
380
|
+
return r.rejected === "not_network_member"
|
|
381
|
+
? { reason: r.rejected, hint: "this token's owner is no longer a member of its network; ask a network admin to re-invite them, then create a new token" }
|
|
382
|
+
: { reason: r.rejected, hint: "this token's owner only has access to granted Agents in this network, and network tokens need full Agent access; ask a network admin to set the owner's Agent access to all" };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export function resolveToken(token: string): { user: AuthUser; networkId: string | null; tokenName: string | null; tokenId: string | null } | null {
|
|
386
|
+
const found = lookupToken(token);
|
|
387
|
+
if (!found?.row) return null;
|
|
388
|
+
const row = found.row;
|
|
359
389
|
|
|
360
390
|
// Update last_used(节流:见 LAST_USED_WRITE_INTERVAL_SECONDS)
|
|
361
391
|
db.run(
|
|
@@ -652,9 +682,13 @@ export function removeNetworkMember(networkId: string, userId: string): { ok: bo
|
|
|
652
682
|
const member = db.get<any>("SELECT role FROM network_members WHERE network_id = ?1 AND user_id = ?2", networkId, userId);
|
|
653
683
|
if (!member) return { ok: false, error: "not a member" };
|
|
654
684
|
if (member.role === "owner") return { ok: false, error: "cannot remove owner" };
|
|
655
|
-
db.
|
|
656
|
-
|
|
657
|
-
|
|
685
|
+
db.transaction(() => {
|
|
686
|
+
db.run("DELETE FROM network_members WHERE network_id = ?1 AND user_id = ?2", [networkId, userId]);
|
|
687
|
+
// #488 —— 他在这个网络里的令牌(节点令牌 / 邀请码令牌)一起吊销;resolveToken 也会拒非成员的令牌,这里让库里的状态如实。
|
|
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
|
+
deleteAgentGrants(networkId, userId);
|
|
690
|
+
deleteTaskGrantsForMember(networkId, userId);
|
|
691
|
+
});
|
|
658
692
|
return { ok: true };
|
|
659
693
|
}
|
|
660
694
|
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
// RFC-040(#455)部门负责人权限 —— 逐条钉矩阵:owner / admin / 负责人 / 上级负责人 / 普通成员 / viewer(当负责人)。
|
|
2
|
+
//
|
|
3
|
+
// 组织架构(测试夹具,全是占位名):
|
|
4
|
+
// 研发(上级负责人 parentHead)── 前端(负责人 head)── 前端一组
|
|
5
|
+
// └─ 后端
|
|
6
|
+
// 销售(无负责人)
|
|
7
|
+
// 只读组(负责人是 viewer —— 不获得任何东西)
|
|
8
|
+
// 成员:head 在前端,member 在前端一组(拥有节点 node_m),be 在后端,sales 在销售,viewer 在只读组,
|
|
9
|
+
// allMember(task_access='all')在销售。除 allMember 外都是「只看相关任务」(scoped)。
|
|
10
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
11
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
12
|
+
import { join } from "path";
|
|
13
|
+
import { tmpdir } from "os";
|
|
14
|
+
import { register, createNetworkTokenForNode } from "./auth.js";
|
|
15
|
+
import { db } from "./db.js";
|
|
16
|
+
|
|
17
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-dept-heads-"));
|
|
18
|
+
let BASE = "";
|
|
19
|
+
let hub: any = null;
|
|
20
|
+
const PW = "DeptHeadsPassw0rd!xyz";
|
|
21
|
+
let NET = "";
|
|
22
|
+
const U: Record<string, { token: string; id: string }> = {};
|
|
23
|
+
const D: Record<string, string> = {};
|
|
24
|
+
const C: Record<string, string> = {};
|
|
25
|
+
const P: Record<string, string> = {};
|
|
26
|
+
let NODE_TOKEN = "";
|
|
27
|
+
|
|
28
|
+
async function send(token: string, method: string, path: string, body?: unknown) {
|
|
29
|
+
const res = await fetch(`${BASE}${path}`, { method, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
|
|
30
|
+
const text = await res.text();
|
|
31
|
+
let json: any = null;
|
|
32
|
+
try { json = JSON.parse(text); } catch {}
|
|
33
|
+
return { status: res.status, body: json };
|
|
34
|
+
}
|
|
35
|
+
const dept = (token: string, method: string, path = "", body?: unknown) => send(token, method, `/api/networks/${NET}/departments${path}`, body);
|
|
36
|
+
const me = async (k: string) => (await send(U[k].token, "GET", "/api/auth/me")).body.networks.find((n: any) => n.network_id === NET).managed_department_ids as string[];
|
|
37
|
+
const visible = async (k: string, query = "") => new Set(((await send(U[k].token, "GET", `/api/requirements?network_id=${NET}${query}`)).body.requirements as any[]).map(r => r.id));
|
|
38
|
+
const card = (k: string) => C[k];
|
|
39
|
+
const userRef = (k: string) => ({ kind: "user", id: U[k].id });
|
|
40
|
+
|
|
41
|
+
beforeAll(async () => {
|
|
42
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
43
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
44
|
+
process.env.HOST = "127.0.0.1";
|
|
45
|
+
const a = register(`dh_owner_${Date.now()}`, PW, undefined, "Owner");
|
|
46
|
+
U.owner = { token: a.token!, id: a.user!.user_id };
|
|
47
|
+
NET = a.network_id!;
|
|
48
|
+
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [U.owner.id]);
|
|
49
|
+
const mod: any = await import("./server.js");
|
|
50
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
51
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
52
|
+
const stamp = Date.now();
|
|
53
|
+
const add = async (k: string, role: string, scoped = true) => {
|
|
54
|
+
const username = `dh_${k}_${stamp}`;
|
|
55
|
+
expect((await send(U.owner.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role })).status).toBe(200);
|
|
56
|
+
const login = await send("", "POST", "/api/auth/login", { username, password: PW });
|
|
57
|
+
U[k] = { token: login.body.token, id: login.body.user.user_id };
|
|
58
|
+
if (scoped && role !== "admin") expect((await send(U.owner.token, "PUT", `/api/networks/${NET}/members/${U[k].id}/task-grants`, { task_access: "scoped" })).status).toBe(200);
|
|
59
|
+
};
|
|
60
|
+
await add("admin", "admin");
|
|
61
|
+
for (const k of ["head", "parentHead", "member", "be", "sales"]) await add(k, "member");
|
|
62
|
+
await add("viewer", "viewer");
|
|
63
|
+
await add("allMember", "member", false);
|
|
64
|
+
|
|
65
|
+
const mkDept = async (k: string, name: string, parent: string | null, leader: string | null) => {
|
|
66
|
+
const r = await dept(U.owner.token, "POST", "", { name, parent_id: parent, leader_user_id: leader ? U[leader].id : null });
|
|
67
|
+
expect(r.status).toBe(201);
|
|
68
|
+
D[k] = r.body.department.id;
|
|
69
|
+
};
|
|
70
|
+
await mkDept("rd", "研发", null, "parentHead");
|
|
71
|
+
await mkDept("fe", "前端", D.rd, "head");
|
|
72
|
+
await mkDept("fe1", "前端一组", D.fe, null);
|
|
73
|
+
await mkDept("be", "后端", D.rd, null);
|
|
74
|
+
await mkDept("sales", "销售", null, null);
|
|
75
|
+
await mkDept("ro", "只读组", null, "viewer");
|
|
76
|
+
const place = async (k: string, d: string) => expect((await send(U.owner.token, "PUT", `/api/networks/${NET}/members/${U[k].id}/department`, { department_id: D[d] })).status).toBe(200);
|
|
77
|
+
await place("head", "fe"); await place("member", "fe1"); await place("be", "be"); await place("sales", "sales"); await place("viewer", "ro"); await place("allMember", "sales");
|
|
78
|
+
await place("parentHead", "rd");
|
|
79
|
+
|
|
80
|
+
// member 拥有的节点:用 owner 建令牌,再把节点主人改成 member(scoped 成员不能自己建节点令牌)。
|
|
81
|
+
const node = createNetworkTokenForNode(U.owner.id, NET, "示例-成员节点", "node_dh_m");
|
|
82
|
+
NODE_TOKEN = node.token!;
|
|
83
|
+
db.run("UPDATE nodes SET owner_user_id = ?1 WHERE node_id = 'node_dh_m'", [U.member.id]);
|
|
84
|
+
|
|
85
|
+
for (const [k, name] of [["p1", "示例项目一"], ["p2", "示例项目二"]] as const) {
|
|
86
|
+
const r = await send(U.owner.token, "POST", `/api/requirements/projects?network_id=${NET}`, { name, network_id: NET });
|
|
87
|
+
expect(r.status).toBe(201);
|
|
88
|
+
P[k] = r.body.project.id;
|
|
89
|
+
}
|
|
90
|
+
const mkCard = async (k: string, fields: Record<string, unknown>) => {
|
|
91
|
+
const r = await send(U.owner.token, "POST", "/api/requirements", { name: `示例卡 ${k}`, network_id: NET, ...fields });
|
|
92
|
+
expect(r.status).toBe(201);
|
|
93
|
+
C[k] = r.body.requirement.id;
|
|
94
|
+
};
|
|
95
|
+
await mkCard("memberOwned", { owner: userRef("member") });
|
|
96
|
+
await mkCard("memberAgent", { agent_owner: { kind: "node", id: "node_dh_m" } });
|
|
97
|
+
await mkCard("salesOwned", { owner: userRef("sales"), project_id: P.p2 });
|
|
98
|
+
await mkCard("beOwned", { owner: userRef("be") });
|
|
99
|
+
await mkCard("salesP1", { owner: userRef("sales"), project_id: P.p1 });
|
|
100
|
+
await mkCard("toDelete", { owner: userRef("member") });
|
|
101
|
+
await mkCard("toDeleteAgent", { agent_owner: { kind: "node", id: "node_dh_m" } });
|
|
102
|
+
}, 60_000);
|
|
103
|
+
|
|
104
|
+
afterAll(() => {
|
|
105
|
+
try { hub?.stop?.(true); } catch {}
|
|
106
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("who is a head (computed per request)", () => {
|
|
110
|
+
test("/api/auth/me managed_department_ids: own departments + all sub-departments; others empty; a viewer leader gets nothing", async () => {
|
|
111
|
+
expect(new Set(await me("head"))).toEqual(new Set([D.fe, D.fe1]));
|
|
112
|
+
expect(new Set(await me("parentHead"))).toEqual(new Set([D.rd, D.fe, D.fe1, D.be]));
|
|
113
|
+
expect(await me("member")).toEqual([]);
|
|
114
|
+
expect(await me("viewer")).toEqual([]);
|
|
115
|
+
expect(await me("owner")).toEqual([]);
|
|
116
|
+
const node = await send(NODE_TOKEN, "GET", "/api/auth/me");
|
|
117
|
+
expect(node.body.networks[0].managed_department_ids).toEqual([]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("departments list: viewer_can per department; project_grants only for owner / admin", async () => {
|
|
121
|
+
const byId = (r: any) => new Map((r.body.departments as any[]).map(d => [d.id, d.viewer_can]));
|
|
122
|
+
const head = await dept(U.head.token, "GET");
|
|
123
|
+
const v = byId(head);
|
|
124
|
+
expect(v.get(D.fe)).toEqual({ manage: false, create_child: true }); // 自己负责的部门:不能改,能在下面建
|
|
125
|
+
expect(v.get(D.fe1)).toEqual({ manage: true, create_child: true });
|
|
126
|
+
expect(v.get(D.be)).toEqual({ manage: false, create_child: false });
|
|
127
|
+
expect("project_grants" in head.body).toBe(false);
|
|
128
|
+
expect(byId(await dept(U.parentHead.token, "GET")).get(D.fe)).toEqual({ manage: true, create_child: true });
|
|
129
|
+
expect(byId(await dept(U.member.token, "GET")).get(D.fe1)).toEqual({ manage: false, create_child: false });
|
|
130
|
+
const owner = await dept(U.owner.token, "GET");
|
|
131
|
+
expect(byId(owner).get(D.sales)).toEqual({ manage: true, create_child: true });
|
|
132
|
+
expect(Array.isArray(owner.body.project_grants)).toBe(true);
|
|
133
|
+
expect(Array.isArray((await dept(U.admin.token, "GET")).body.project_grants)).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("department writes: heads inside their subtree, fixed 403 outside", () => {
|
|
138
|
+
test("create: under own subtree 201; elsewhere / top level → 403 department_scope_denied; non-heads unchanged", async () => {
|
|
139
|
+
const ok = await dept(U.head.token, "POST", "", { name: "前端二组", parent_id: D.fe });
|
|
140
|
+
expect(ok.status).toBe(201);
|
|
141
|
+
D.fe2 = ok.body.department.id;
|
|
142
|
+
for (const parent of [D.be, D.rd, null]) {
|
|
143
|
+
const r = await dept(U.head.token, "POST", "", { name: "越权部门", parent_id: parent });
|
|
144
|
+
expect(r.status).toBe(403);
|
|
145
|
+
expect(r.body.error).toBe("department_scope_denied");
|
|
146
|
+
}
|
|
147
|
+
const leaderOutside = await dept(U.head.token, "POST", "", { name: "前端三组", parent_id: D.fe, leader_user_id: U.sales.id });
|
|
148
|
+
expect(leaderOutside.body.error).toBe("department_scope_denied");
|
|
149
|
+
// 普通成员 / viewer 负责人:与以前逐字节相同。
|
|
150
|
+
for (const k of ["member", "viewer"]) {
|
|
151
|
+
const r = await dept(U[k].token, "POST", "", { name: "x", parent_id: D.fe });
|
|
152
|
+
expect(r.status).toBe(403);
|
|
153
|
+
expect(r.body).toEqual({ ok: false, error: "owner/admin required" });
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("patch: own department is the parent head's; sub-departments rename / move / set leader only inside the subtree", async () => {
|
|
158
|
+
const own = await dept(U.head.token, "PATCH", `/${D.fe}`, { name: "前端改名" });
|
|
159
|
+
expect(own.status).toBe(403);
|
|
160
|
+
expect(own.body.error).toBe("department_scope_denied");
|
|
161
|
+
expect((await dept(U.parentHead.token, "PATCH", `/${D.fe}`, { name: "前端(上级改)" })).status).toBe(200);
|
|
162
|
+
expect((await dept(U.head.token, "PATCH", `/${D.fe1}`, { name: "前端一组(改)" })).status).toBe(200);
|
|
163
|
+
expect((await dept(U.head.token, "PATCH", `/${D.fe2}`, { parent_id: D.fe1 })).status).toBe(200);
|
|
164
|
+
expect((await dept(U.head.token, "PATCH", `/${D.fe2}`, { parent_id: D.be })).body.error).toBe("department_scope_denied");
|
|
165
|
+
expect((await dept(U.head.token, "PATCH", `/${D.fe2}`, { parent_id: null })).body.error).toBe("department_scope_denied");
|
|
166
|
+
expect((await dept(U.head.token, "PATCH", `/${D.be}`, { name: "后端改" })).body.error).toBe("department_scope_denied");
|
|
167
|
+
expect((await dept(U.head.token, "PATCH", `/${D.fe1}`, { leader_user_id: U.member.id })).status).toBe(200);
|
|
168
|
+
expect((await dept(U.head.token, "PATCH", `/${D.fe1}`, { leader_user_id: U.sales.id })).body.error).toBe("department_scope_denied");
|
|
169
|
+
expect((await dept(U.head.token, "PATCH", `/${D.fe1}`, { leader_user_id: null })).status).toBe(200);
|
|
170
|
+
expect((await dept(U.head.token, "PATCH", "/dept_missing", { name: "x" })).status).toBe(404);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("delete: empty sub-department yes, outside no; the 409 not-empty rule still applies", async () => {
|
|
174
|
+
expect((await dept(U.head.token, "DELETE", `/${D.be}`)).body.error).toBe("department_scope_denied");
|
|
175
|
+
expect((await dept(U.head.token, "DELETE", `/${D.fe1}`)).status).toBe(409); // member 在里面
|
|
176
|
+
expect((await dept(U.head.token, "DELETE", `/${D.fe2}`)).status).toBe(200);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("moving people: only from inside the subtree to inside the subtree; in / out (incl. unassigned) is admin-only", async () => {
|
|
180
|
+
const move = (k: string, who: string, to: string | null) => send(U[k].token, "PUT", `/api/networks/${NET}/members/${U[who].id}/department`, { department_id: to });
|
|
181
|
+
expect((await move("head", "member", D.fe)).status).toBe(200);
|
|
182
|
+
expect((await move("head", "member", D.fe1)).status).toBe(200);
|
|
183
|
+
expect((await move("head", "member", null)).body.error).toBe("department_scope_denied");
|
|
184
|
+
expect((await move("head", "member", D.sales)).body.error).toBe("department_scope_denied");
|
|
185
|
+
expect((await move("head", "sales", D.fe)).body.error).toBe("department_scope_denied");
|
|
186
|
+
expect((await move("member", "member", D.fe)).body).toEqual({ ok: false, error: "owner/admin required" });
|
|
187
|
+
expect((await move("admin", "sales", D.fe)).status).toBe(200);
|
|
188
|
+
expect((await move("admin", "sales", D.sales)).status).toBe(200);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("tasks: union with the existing ACL", () => {
|
|
193
|
+
test("a head sees, edits and is offered delete on the department's cards (owner or Agent owner in the subtree), nothing else", async () => {
|
|
194
|
+
const seen = await visible("head");
|
|
195
|
+
expect(seen.has(card("memberOwned"))).toBe(true);
|
|
196
|
+
expect(seen.has(card("memberAgent"))).toBe(true);
|
|
197
|
+
expect(seen.has(card("salesOwned"))).toBe(false);
|
|
198
|
+
expect(seen.has(card("beOwned"))).toBe(false);
|
|
199
|
+
expect((await send(U.head.token, "GET", `/api/requirements/${card("beOwned")}`)).status).toBe(404);
|
|
200
|
+
const row = (await send(U.head.token, "GET", `/api/requirements/${card("memberOwned")}`)).body.requirement;
|
|
201
|
+
expect(row.viewer_can).toMatchObject({ edit: true, delete: true });
|
|
202
|
+
expect((await send(U.head.token, "PATCH", `/api/requirements/${card("memberOwned")}`, { name: "示例卡(负责人改)" })).status).toBe(200);
|
|
203
|
+
const parent = await visible("parentHead");
|
|
204
|
+
expect(parent.has(card("beOwned"))).toBe(true);
|
|
205
|
+
expect(parent.has(card("memberOwned"))).toBe(true);
|
|
206
|
+
expect(parent.has(card("salesOwned"))).toBe(false);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("a head-only edit can hand the card only to the department (or self); clearing is allowed", async () => {
|
|
210
|
+
const out = await send(U.head.token, "PATCH", `/api/requirements/${card("memberAgent")}`, { owner: userRef("sales") });
|
|
211
|
+
expect(out.status).toBe(403);
|
|
212
|
+
expect(out.body.error).toBe("department_scope_denied");
|
|
213
|
+
expect(out.body.field).toBe("owner");
|
|
214
|
+
expect((await send(U.head.token, "PATCH", `/api/requirements/${card("memberAgent")}`, { owner: userRef("member") })).status).toBe(200);
|
|
215
|
+
expect((await send(U.head.token, "PATCH", `/api/requirements/${card("memberAgent")}`, { owner: null })).status).toBe(200);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("viewers never get head power; ordinary members see nothing new", async () => {
|
|
219
|
+
// viewer 负责「只读组」,把 sales 临时放进去:viewer 仍看不见 sales 的卡,也不能删。
|
|
220
|
+
expect((await send(U.owner.token, "PUT", `/api/networks/${NET}/members/${U.sales.id}/department`, { department_id: D.ro })).status).toBe(200);
|
|
221
|
+
expect((await visible("viewer")).has(card("salesOwned"))).toBe(false);
|
|
222
|
+
expect((await send(U.viewer.token, "DELETE", `/api/requirements/${card("salesOwned")}`)).status).not.toBe(200);
|
|
223
|
+
expect((await send(U.owner.token, "PUT", `/api/networks/${NET}/members/${U.sales.id}/department`, { department_id: D.sales })).status).toBe(200);
|
|
224
|
+
const be = await visible("be");
|
|
225
|
+
expect(be.has(card("memberOwned"))).toBe(false);
|
|
226
|
+
expect([...be].every(id => id === card("beOwned"))).toBe(true);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("revoking a head takes effect on the very next request (no cache, no re-login)", async () => {
|
|
230
|
+
expect((await dept(U.owner.token, "PATCH", `/${D.fe}`, { leader_user_id: null })).status).toBe(200);
|
|
231
|
+
expect(await me("head")).toEqual([]);
|
|
232
|
+
expect((await visible("head")).has(card("memberOwned"))).toBe(false);
|
|
233
|
+
expect((await dept(U.head.token, "POST", "", { name: "撤了还想建", parent_id: D.fe })).body).toEqual({ ok: false, error: "owner/admin required" });
|
|
234
|
+
expect((await send(U.head.token, "PATCH", `/api/requirements/${card("memberOwned")}`, { name: "撤了还想改" })).status).toBe(404);
|
|
235
|
+
expect((await dept(U.owner.token, "PATCH", `/${D.fe}`, { leader_user_id: U.head.id })).status).toBe(200);
|
|
236
|
+
expect((await visible("head")).has(card("memberOwned"))).toBe(true);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("union never revokes: personal project grants and task_access='all' are untouched by departments", async () => {
|
|
240
|
+
// member 有按人授权 p1(可改)。把他调来调去、设部门授权、撤负责人,都不影响这份授权。
|
|
241
|
+
expect((await send(U.owner.token, "PUT", `/api/networks/${NET}/members/${U.member.id}/task-grants`, { project_grants: [{ project_id: P.p1, can_edit: true }] })).status).toBe(200);
|
|
242
|
+
expect((await visible("member")).has(card("salesP1"))).toBe(true);
|
|
243
|
+
expect((await send(U.owner.token, "PUT", `/api/networks/${NET}/members/${U.member.id}/department`, { department_id: D.be })).status).toBe(200);
|
|
244
|
+
expect((await dept(U.owner.token, "PUT", `/${D.be}/project-grants`, { project_grants: [] })).status).toBe(200);
|
|
245
|
+
expect((await visible("member")).has(card("salesP1"))).toBe(true);
|
|
246
|
+
expect((await send(U.member.token, "PATCH", `/api/requirements/${card("salesP1")}`, { name: "按人授权可改" })).status).toBe(200);
|
|
247
|
+
expect((await send(U.owner.token, "PUT", `/api/networks/${NET}/members/${U.member.id}/department`, { department_id: D.fe1 })).status).toBe(200);
|
|
248
|
+
// task_access='all' 的老成员:看见全部,和部门无关。
|
|
249
|
+
const all = await visible("allMember");
|
|
250
|
+
for (const k of ["memberOwned", "salesOwned", "beOwned"]) expect(all.has(card(k))).toBe(true);
|
|
251
|
+
expect((await send(U.owner.token, "PUT", `/api/networks/${NET}/members/${U.member.id}/task-grants`, { project_grants: [] })).status).toBe(200);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe("department project grants", () => {
|
|
256
|
+
test("owner / admin only; whole-batch validation", async () => {
|
|
257
|
+
expect((await dept(U.head.token, "GET", `/${D.rd}/project-grants`)).body).toEqual({ ok: false, error: "owner/admin required" });
|
|
258
|
+
expect((await dept(U.parentHead.token, "PUT", `/${D.rd}/project-grants`, { project_grants: [{ project_id: P.p2 }] })).status).toBe(403);
|
|
259
|
+
const bad = await dept(U.owner.token, "PUT", `/${D.rd}/project-grants`, { project_grants: [{ project_id: P.p2 }, { project_id: "proj_not_here" }] });
|
|
260
|
+
expect(bad.status).toBe(400);
|
|
261
|
+
expect(bad.body.error).toBe("project_not_in_network");
|
|
262
|
+
expect((await dept(U.owner.token, "GET", `/${D.rd}/project-grants`)).body.project_grants).toEqual([]);
|
|
263
|
+
expect((await dept(U.owner.token, "GET", "/dept_missing/project-grants")).status).toBe(404);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("a grant to a department cascades to its sub-departments, not to siblings; can_edit decides edit", async () => {
|
|
267
|
+
expect((await visible("member")).has(card("salesOwned"))).toBe(false);
|
|
268
|
+
expect((await dept(U.admin.token, "PUT", `/${D.rd}/project-grants`, { project_grants: [{ project_id: P.p2, can_edit: false }] })).status).toBe(200);
|
|
269
|
+
expect((await visible("member")).has(card("salesOwned"))).toBe(true); // member 在 研发 › 前端 › 前端一组
|
|
270
|
+
expect((await visible("sales")).has(card("salesOwned"))).toBe(true); // 自己的卡,本来就看得见
|
|
271
|
+
expect((await send(U.member.token, "PATCH", `/api/requirements/${card("salesOwned")}`, { name: "只读授权" })).status).toBe(403);
|
|
272
|
+
expect((await dept(U.owner.token, "PUT", `/${D.rd}/project-grants`, { project_grants: [{ project_id: P.p2, can_edit: true }] })).status).toBe(200);
|
|
273
|
+
expect((await send(U.member.token, "PATCH", `/api/requirements/${card("salesOwned")}`, { name: "部门授权可改" })).status).toBe(200);
|
|
274
|
+
// 授权给 后端:前端一组的 member 拿不到。
|
|
275
|
+
expect((await dept(U.owner.token, "PUT", `/${D.rd}/project-grants`, { project_grants: [] })).status).toBe(200);
|
|
276
|
+
expect((await dept(U.owner.token, "PUT", `/${D.be}/project-grants`, { project_grants: [{ project_id: P.p2 }] })).status).toBe(200);
|
|
277
|
+
expect((await visible("member")).has(card("salesOwned"))).toBe(false);
|
|
278
|
+
expect((await visible("be")).has(card("salesOwned"))).toBe(true);
|
|
279
|
+
expect((await dept(U.owner.token, "GET")).body.project_grants).toEqual([{ department_id: D.be, project_id: P.p2, can_edit: false }]);
|
|
280
|
+
// 项目列表也跟着授权走。
|
|
281
|
+
const projects = (await send(U.be.token, "GET", `/api/requirements/projects?network_id=${NET}`)).body.projects.map((p: any) => p.id);
|
|
282
|
+
expect(projects).toContain(P.p2);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe("delete by a head: audit + DM", () => {
|
|
287
|
+
test("head deletes a department card → requirement_deleted_by_leader + a DM to the card's owner (or the Agent's owner)", async () => {
|
|
288
|
+
const inbox = (uid: string) => db.all<{ content: string }>("SELECT content FROM user_inbox WHERE user_id = ?1 AND content LIKE '%部门负责人%'", uid);
|
|
289
|
+
const before = inbox(U.member.id).length;
|
|
290
|
+
expect((await send(U.head.token, "DELETE", `/api/requirements/${card("toDelete")}`)).status).toBe(200);
|
|
291
|
+
expect((await send(U.head.token, "DELETE", `/api/requirements/${card("toDeleteAgent")}`)).status).toBe(200);
|
|
292
|
+
const audits = db.all<{ action: string; target_id: string; detail: string }>("SELECT action, target_id, detail FROM audit_log WHERE action = 'requirement_deleted_by_leader' ORDER BY id");
|
|
293
|
+
expect(audits.map(a => a.target_id)).toEqual([card("toDelete"), card("toDeleteAgent")]);
|
|
294
|
+
expect(JSON.parse(audits[0].detail).notified).toBe(U.member.id);
|
|
295
|
+
expect(JSON.parse(audits[1].detail).notified).toBe(U.member.id); // Agent 的主人
|
|
296
|
+
const dms = inbox(U.member.id);
|
|
297
|
+
expect(dms.length).toBe(before + 2);
|
|
298
|
+
expect(dms.at(-1)!.content).toContain("删除了你负责的任务");
|
|
299
|
+
// 不是本部门的卡:负责人删不了(看不见 = 404)。普通成员删别人的卡照旧被拒。
|
|
300
|
+
expect((await send(U.head.token, "DELETE", `/api/requirements/${card("beOwned")}`)).status).toBe(404);
|
|
301
|
+
expect((await send(U.be.token, "DELETE", `/api/requirements/${card("salesOwned")}`)).status).toBe(403);
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
describe("department_id filter and the read-only node list", () => {
|
|
306
|
+
test("GET /api/requirements?department_id= narrows to the subtree's cards, within what the caller can see", async () => {
|
|
307
|
+
const all = await visible("owner", `&department_id=${D.fe}`);
|
|
308
|
+
expect(all.has(card("memberOwned"))).toBe(true);
|
|
309
|
+
expect(all.has(card("memberAgent"))).toBe(true);
|
|
310
|
+
expect(all.has(card("beOwned"))).toBe(false);
|
|
311
|
+
expect((await visible("owner", `&department_id=${D.rd}`)).has(card("beOwned"))).toBe(true);
|
|
312
|
+
expect((await visible("be", `&department_id=${D.rd}`)).has(card("memberOwned"))).toBe(false); // 仍受可见范围约束
|
|
313
|
+
const bad = await send(U.owner.token, "GET", `/api/requirements?network_id=${NET}&department_id=dept_missing`);
|
|
314
|
+
expect(bad.status).toBe(400);
|
|
315
|
+
expect(bad.body.error).toBe("department_not_found");
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("GET …/departments/:dept/nodes: heads (subtree) and owner/admin; read-only status + health", async () => {
|
|
319
|
+
const r = await dept(U.head.token, "GET", `/${D.fe}/nodes`);
|
|
320
|
+
expect(r.status).toBe(200);
|
|
321
|
+
expect(r.body.nodes.map((n: any) => n.node_id)).toEqual(["node_dh_m"]);
|
|
322
|
+
expect(Object.keys(r.body.nodes[0]).sort()).toEqual(["alias", "degraded", "display_name", "health", "last_seen_at", "node_id", "owner_user_id", "status"]);
|
|
323
|
+
expect((await dept(U.head.token, "GET", `/${D.be}/nodes`)).body.error).toBe("department_scope_denied");
|
|
324
|
+
expect((await dept(U.member.token, "GET", `/${D.fe}/nodes`)).body.error).toBe("department_scope_denied");
|
|
325
|
+
expect((await dept(U.owner.token, "GET", `/${D.rd}/nodes`)).body.nodes.length).toBe(1);
|
|
326
|
+
expect((await dept(U.head.token, "POST", `/${D.fe}/nodes`, {})).status).toBe(405);
|
|
327
|
+
expect((await send(NODE_TOKEN, "GET", `/api/networks/${NET}/departments/${D.fe}/nodes`)).body.error).toBe("department_scope_denied");
|
|
328
|
+
});
|
|
329
|
+
});
|