@sleep2agi/commhub-server 0.9.0-preview.115 → 0.9.0-preview.117
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-team-whoami-http.test.ts +98 -0
- package/src/agent-teams-http.test.ts +323 -0
- package/src/agent-teams.ts +244 -0
- package/src/auth.ts +8 -5
- package/src/db.ts +3 -0
- package/src/department-heads-http.test.ts +2 -1
- package/src/node-permissions.ts +1 -1
- package/src/requirements-comment-http.test.ts +2 -0
- package/src/requirements-stats-http.test.ts +1 -1
- package/src/requirements-status-participants-http.test.ts +2 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +9 -5
- package/src/scheduled-offline.test.ts +131 -0
- package/src/scheduled-offline.ts +66 -0
- package/src/scheduled-tasks.ts +33 -4
- package/src/server.ts +61 -4
- package/src/task-access-http.test.ts +38 -15
- package/src/task-access.ts +13 -6
- package/src/task-expiry-notice.ts +9 -7
- package/src/task-orphan-http.test.ts +287 -0
- package/src/task-orphan.ts +119 -0
- package/src/tool-audience.ts +1 -1
- package/src/tools.ts +6 -0
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.117",
|
|
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",
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { beforeAll, afterAll, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
process.env.COMMHUB_DB ||= `${mkdtempSync('/tmp/team-whoami-')}/hub.db`;
|
|
5
|
+
let db: any, hub: any, base: string, owner: any, outside: any;
|
|
6
|
+
let selfToken: string, otherToken: string, outsideToken: string, unboundToken: string;
|
|
7
|
+
const SELF = 'node_team_whoami_self', OTHER = 'node_team_whoami_other', FOREIGN = 'node_team_whoami_foreign';
|
|
8
|
+
const TEAM = 'team_whoami_child', ROOT = 'team_whoami_root';
|
|
9
|
+
|
|
10
|
+
async function rpc(token: string, method = 'tools/call', params: unknown = { name: 'org_whoami', arguments: {} }) {
|
|
11
|
+
const response = await fetch(`${base}/mcp`, { method: 'POST', headers: {
|
|
12
|
+
Authorization: `Bearer ${token}`, 'Content-Type': 'application/json',
|
|
13
|
+
Accept: 'application/json, text/event-stream', 'MCP-Protocol-Version': '2025-03-26',
|
|
14
|
+
}, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) });
|
|
15
|
+
expect(response.status).toBe(200);
|
|
16
|
+
const text = await response.text();
|
|
17
|
+
const line = text.split('\n').filter(x => x.startsWith('data:')).at(-1);
|
|
18
|
+
const body = JSON.parse(line ? line.slice(5) : text);
|
|
19
|
+
expect(body.error).toBeUndefined();
|
|
20
|
+
return body.result;
|
|
21
|
+
}
|
|
22
|
+
async function who(token = selfToken, args: Record<string, unknown> = {}) {
|
|
23
|
+
const result = await rpc(token, 'tools/call', { name: 'org_whoami', arguments: args });
|
|
24
|
+
return JSON.parse(result.content[0].text);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
beforeAll(async () => {
|
|
28
|
+
({ db } = await import('./db.js'));
|
|
29
|
+
const { register, createNetworkTokenForNode } = await import('./auth.js');
|
|
30
|
+
owner = register('team_whoami_owner', 'Fixture-Passw0rd!xyz');
|
|
31
|
+
outside = register('team_whoami_outside', 'Fixture-Passw0rd!xyz');
|
|
32
|
+
db.run("UPDATE users SET display_name = 'Team owner' WHERE user_id = ?1", [owner.user.user_id]);
|
|
33
|
+
const mint = (u: any, alias: string, node?: string) => {
|
|
34
|
+
const r = createNetworkTokenForNode(u.user.user_id, u.network_id, alias, node);
|
|
35
|
+
expect(r.error ?? null).toBe(null); return r.token!;
|
|
36
|
+
};
|
|
37
|
+
selfToken = mint(owner, 'team-self', SELF);
|
|
38
|
+
otherToken = mint(owner, 'team-other', OTHER);
|
|
39
|
+
outsideToken = mint(outside, 'team-self', FOREIGN); // Same alias in a different network.
|
|
40
|
+
unboundToken = mint(owner, 'team-unregistered');
|
|
41
|
+
const { bootServer } = await import('./server.js');
|
|
42
|
+
hub = bootServer({ port: 0, hostname: '127.0.0.1' });
|
|
43
|
+
base = `http://127.0.0.1:${hub.port}`;
|
|
44
|
+
for (const [net, id, name, parent] of [
|
|
45
|
+
[owner.network_id, ROOT, 'Parent', null], [owner.network_id, TEAM, 'My team', ROOT],
|
|
46
|
+
[outside.network_id, TEAM, 'Foreign team', null],
|
|
47
|
+
]) db.run('INSERT INTO network_agent_teams(network_id,team_id,name,parent_id) VALUES(?1,?2,?3,?4)', [net,id,name,parent]);
|
|
48
|
+
db.run('UPDATE network_agent_teams SET lead_node_id=?1,owner_user_id=?2 WHERE network_id=?3 AND team_id=?4', [SELF,owner.user.user_id,owner.network_id,TEAM]);
|
|
49
|
+
for (const [net, node] of [[owner.network_id, SELF], [outside.network_id, FOREIGN]]) {
|
|
50
|
+
db.run('INSERT INTO network_agent_team_members(network_id,node_id,team_id) VALUES(?1,?2,?3)', [net,node,TEAM]);
|
|
51
|
+
}
|
|
52
|
+
// Even a stale/corrupt membership must not project a node from another network.
|
|
53
|
+
db.run('INSERT INTO network_agent_team_members(network_id,node_id,team_id) VALUES(?1,?2,?3)', [owner.network_id,FOREIGN,TEAM]);
|
|
54
|
+
}, 60_000);
|
|
55
|
+
afterAll(() => hub?.stop(true));
|
|
56
|
+
|
|
57
|
+
test('exact node team, nearest-first ancestors, Agent lead and private human display name', async () => {
|
|
58
|
+
const r = await who();
|
|
59
|
+
expect(r.ok).toBe(true); expect(r.source).toBe('node'); expect(r.node_id).toBe(SELF);
|
|
60
|
+
expect(r.team).toEqual({ id: TEAM, name: 'My team' });
|
|
61
|
+
expect(r.ancestors).toEqual([{ id: ROOT, name: 'Parent' }]);
|
|
62
|
+
expect(r.lead.node_id).toBe(SELF);
|
|
63
|
+
expect(r.owner).toEqual({ user_id: owner.user.user_id, display_name: 'Team owner' });
|
|
64
|
+
expect(r.agents.map((n: any) => n.node_id)).toEqual([SELF]);
|
|
65
|
+
expect(r.truncated).toBe(false);
|
|
66
|
+
expect(JSON.stringify(r)).not.toContain('team_whoami_owner');
|
|
67
|
+
});
|
|
68
|
+
test('same-id foreign team and foreign node cannot cross network; supplied identity is ignored', async () => {
|
|
69
|
+
const r = await who(selfToken, { network_id: outside.network_id, node_id: FOREIGN, alias: 'team-other' });
|
|
70
|
+
expect(r.node_id).toBe(SELF); expect(r.team.name).toBe('My team');
|
|
71
|
+
expect(JSON.stringify(r)).not.toContain(FOREIGN);
|
|
72
|
+
const b = await who(outsideToken);
|
|
73
|
+
expect(b.team.name).toBe('Foreign team'); expect(b.node_id).toBe(FOREIGN);
|
|
74
|
+
expect(b.ancestors).toEqual([]); expect(JSON.stringify(b)).not.toContain(SELF);
|
|
75
|
+
});
|
|
76
|
+
test('unassigned node does not inherit its owner or another node team', async () => {
|
|
77
|
+
expect(await who(otherToken)).toEqual({ ok: true, source: 'none', node_id: OTHER, team: null,
|
|
78
|
+
ancestors: [], lead: null, owner: null, agents: [], truncated: false });
|
|
79
|
+
expect((await who(unboundToken)).error).toBe('node_identity_unbound');
|
|
80
|
+
});
|
|
81
|
+
test('user credentials cannot list or call; node credentials can list', async () => {
|
|
82
|
+
expect((await rpc(owner.token, 'tools/list', {})).tools.some((t: any) => t.name === 'org_whoami')).toBe(false);
|
|
83
|
+
expect((await who(owner.token)).error).toBe('network_token_required');
|
|
84
|
+
expect((await rpc(selfToken, 'tools/list', {})).tools.some((t: any) => t.name === 'org_whoami')).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
test('foreign lead and human owner references are not returned', async () => {
|
|
87
|
+
db.run('UPDATE network_agent_teams SET lead_node_id=?1,owner_user_id=?2 WHERE network_id=?3 AND team_id=?4', [FOREIGN,outside.user.user_id,owner.network_id,TEAM]);
|
|
88
|
+
try { const r = await who(); expect(r.lead).toBe(null); expect(r.owner).toBe(null); }
|
|
89
|
+
finally { db.run('UPDATE network_agent_teams SET lead_node_id=?1,owner_user_id=?2 WHERE network_id=?3 AND team_id=?4', [SELF,owner.user.user_id,owner.network_id,TEAM]); }
|
|
90
|
+
});
|
|
91
|
+
test('large teams are bounded to 50 and explicitly truncated', async () => {
|
|
92
|
+
for (let i = 0; i < 51; i++) {
|
|
93
|
+
const id = `node_team_whoami_cap_${i}`;
|
|
94
|
+
db.run('INSERT INTO nodes(node_id,node_name,alias,network_id) VALUES(?1,?1,?1,?2)', [id,owner.network_id]);
|
|
95
|
+
db.run('INSERT INTO network_agent_team_members(network_id,node_id,team_id) VALUES(?1,?2,?3)', [owner.network_id,id,TEAM]);
|
|
96
|
+
}
|
|
97
|
+
const r = await who(); expect(r.agents.length).toBe(50); expect(r.truncated).toBe(true);
|
|
98
|
+
});
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// #764:Agent 团队 —— GET/POST/PATCH/DELETE /api/networks/:id/agent-teams[/:team_id] + PUT …/nodes/:node_id/agent-team。
|
|
2
|
+
// HTTP 集成测试(真实 Bun.serve,私有端口,临时库;PG 阶梯里用同一个文件跑真 PostgreSQL)。
|
|
3
|
+
//
|
|
4
|
+
// 夹具(全是占位名):网络 owner;成员 ownerA(管「平台」子树)、ownerB(管「运维」,和平台是兄弟)、member;
|
|
5
|
+
// 节点 n1..n3(owner 建),nodeTok 是 n1 的节点令牌;另一个网络 NET2(owner2)有自己的团队和节点。
|
|
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-agent-teams-"));
|
|
14
|
+
let BASE = "";
|
|
15
|
+
let hub: any = null;
|
|
16
|
+
const PW = "AgentTeamsPassw0rd!xyz";
|
|
17
|
+
let NET = "";
|
|
18
|
+
let NET2 = "";
|
|
19
|
+
const U: Record<string, { token: string; id: string }> = {};
|
|
20
|
+
const T: Record<string, string> = {};
|
|
21
|
+
let NODE_TOKEN = "";
|
|
22
|
+
const N = { n1: "node_at_1", n2: "node_at_2", n3: "node_at_3", x1: "node_at_x1" };
|
|
23
|
+
let before: Record<string, string> = {};
|
|
24
|
+
|
|
25
|
+
async function raw(token: string, method: string, path: string, body?: unknown) {
|
|
26
|
+
const res = await fetch(`${BASE}${path}`, { method, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
|
|
27
|
+
return { status: res.status, text: await res.text() };
|
|
28
|
+
}
|
|
29
|
+
async function send(token: string, method: string, path: string, body?: unknown) {
|
|
30
|
+
const r = await raw(token, method, path, body);
|
|
31
|
+
let json: any = null;
|
|
32
|
+
try { json = JSON.parse(r.text); } catch {}
|
|
33
|
+
return { status: r.status, body: json };
|
|
34
|
+
}
|
|
35
|
+
const tok = (who: string) => (who === "node" ? NODE_TOKEN : U[who].token);
|
|
36
|
+
const create = (who: string, name: string, parent_id?: string | null, net = NET) => send(tok(who), "POST", `/api/networks/${net}/agent-teams`, { name, parent_id });
|
|
37
|
+
const patch = (who: string, id: string, body: unknown, net = NET) => send(tok(who), "PATCH", `/api/networks/${net}/agent-teams/${id}`, body);
|
|
38
|
+
const del = (who: string, id: string) => send(tok(who), "DELETE", `/api/networks/${NET}/agent-teams/${id}`);
|
|
39
|
+
const assign = (who: string, node: string, team_id: string | null, net = NET) => send(tok(who), "PUT", `/api/networks/${net}/nodes/${node}/agent-team`, { team_id });
|
|
40
|
+
const teams = async (who = "owner") => (await send(tok(who), "GET", `/api/networks/${NET}/agent-teams`)).body.teams as any[];
|
|
41
|
+
const team = async (id: string) => (await teams()).find((t) => t.id === id);
|
|
42
|
+
const teamOf = async (node: string) => (await teams()).find((t) => t.members.some((m: any) => m.node_id === node))?.id ?? null;
|
|
43
|
+
const denied = (r: { status: number; body: any }) => { expect(r.status).toBe(403); expect(r.body.error).toBe("agent_team_scope_denied"); };
|
|
44
|
+
const snapshot = async () => ({
|
|
45
|
+
departments: (await raw(U.owner.token, "GET", `/api/networks/${NET}/departments`)).text,
|
|
46
|
+
humans: (await raw(U.owner.token, "GET", `/api/networks/${NET}/humans`)).text,
|
|
47
|
+
node: (await raw(U.owner.token, "GET", `/api/nodes/${N.n1}/config`)).text,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
beforeAll(async () => {
|
|
51
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
52
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
53
|
+
process.env.HOST = "127.0.0.1";
|
|
54
|
+
const stamp = Date.now();
|
|
55
|
+
const a = register(`at_owner_${stamp}`, PW, undefined, "Owner");
|
|
56
|
+
U.owner = { token: a.token!, id: a.user!.user_id };
|
|
57
|
+
NET = a.network_id!;
|
|
58
|
+
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [U.owner.id]);
|
|
59
|
+
const b = register(`at_owner2_${stamp}`, PW, undefined, "Owner2");
|
|
60
|
+
U.owner2 = { token: b.token!, id: b.user!.user_id };
|
|
61
|
+
NET2 = b.network_id!;
|
|
62
|
+
const mod: any = await import("./server.js");
|
|
63
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
64
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
65
|
+
for (const k of ["ownerA", "ownerB", "member"]) {
|
|
66
|
+
const username = `at_${k}_${stamp}`;
|
|
67
|
+
expect((await send(U.owner.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role: "member" })).status).toBe(200);
|
|
68
|
+
const login = await send("", "POST", "/api/auth/login", { username, password: PW });
|
|
69
|
+
U[k] = { token: login.body.token, id: login.body.user.user_id };
|
|
70
|
+
}
|
|
71
|
+
db.run("UPDATE users SET display_name = ?1 WHERE user_id = ?2", ["平台负责人", U.ownerA.id]);
|
|
72
|
+
for (const [alias, id] of [["示例节点一", N.n1], ["示例节点二", N.n2], ["示例节点三", N.n3]]) {
|
|
73
|
+
const t = createNetworkTokenForNode(U.owner.id, NET, alias, id);
|
|
74
|
+
expect(t.error ?? null).toBe(null);
|
|
75
|
+
if (id === N.n1) NODE_TOKEN = t.token!;
|
|
76
|
+
}
|
|
77
|
+
expect(createNetworkTokenForNode(U.owner2.id, NET2, "外网节点", N.x1).error ?? null).toBe(null);
|
|
78
|
+
db.run("UPDATE nodes SET display_name = ?1 WHERE node_id = ?2", ["节点一号", N.n1]);
|
|
79
|
+
// 成员默认「只看授权 Agent」:ownerA 看得见 n1、n2(看不见 n3),ownerB 看得见 n2、n3。
|
|
80
|
+
const grant = (k: string, ids: string[]) => send(U.owner.token, "PUT", `/api/networks/${NET}/members/${U[k].id}/agent-grants`, { agent_access: "granted", grants: ids.map((node_id) => ({ node_id })) });
|
|
81
|
+
expect((await grant("ownerA", [N.n1, N.n2])).status).toBe(200);
|
|
82
|
+
expect((await grant("ownerB", [N.n2, N.n3])).status).toBe(200);
|
|
83
|
+
before = await snapshot();
|
|
84
|
+
}, 60_000);
|
|
85
|
+
|
|
86
|
+
afterAll(() => {
|
|
87
|
+
try { hub?.stop?.(true); } catch {}
|
|
88
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("#764 CRUD (network owner)", () => {
|
|
92
|
+
test("empty tree, then create roots and children", async () => {
|
|
93
|
+
expect(await teams()).toEqual([]);
|
|
94
|
+
const p = await create("owner", "平台");
|
|
95
|
+
expect(p.status).toBe(201);
|
|
96
|
+
T.plat = p.body.team.id;
|
|
97
|
+
T.ops = (await create("owner", "运维")).body.team.id;
|
|
98
|
+
T.api = (await create("owner", "接口", T.plat)).body.team.id;
|
|
99
|
+
T.web = (await create("owner", "前端", T.plat)).body.team.id;
|
|
100
|
+
const t = await team(T.api);
|
|
101
|
+
expect(t).toMatchObject({ id: T.api, name: "接口", parent_id: T.plat, lead: null, owner: null, members: [] });
|
|
102
|
+
});
|
|
103
|
+
test("validation: empty name 400, duplicate sibling 409, unknown parent 400, wrong method 405", async () => {
|
|
104
|
+
expect((await create("owner", " ")).body.error).toBe("invalid_team_name");
|
|
105
|
+
const dup = await create("owner", "接口", T.plat);
|
|
106
|
+
expect(dup.status).toBe(409); expect(dup.body.error).toBe("team_name_taken");
|
|
107
|
+
expect((await create("owner", "接口", T.ops)).status).toBe(201); // 不同上级可以同名
|
|
108
|
+
expect((await create("owner", "x", "team_nope")).body.error).toBe("parent_not_found");
|
|
109
|
+
expect((await send(U.owner.token, "PUT", `/api/networks/${NET}/agent-teams`, {})).status).toBe(405);
|
|
110
|
+
});
|
|
111
|
+
test("rename, sort, lead, owner; GET shows display names", async () => {
|
|
112
|
+
expect((await patch("owner", T.plat, { name: "平台组", sort: 5, lead_node_id: N.n1, owner_user_id: U.ownerA.id })).status).toBe(200);
|
|
113
|
+
expect((await patch("owner", T.ops, { owner_user_id: U.ownerB.id })).status).toBe(200);
|
|
114
|
+
const t = await team(T.plat);
|
|
115
|
+
expect(t.name).toBe("平台组"); expect(t.sort).toBe(5);
|
|
116
|
+
expect(t.lead).toEqual({ node_id: N.n1, alias: "示例节点一", display_name: "节点一号" });
|
|
117
|
+
expect(t.owner).toEqual({ user_id: U.ownerA.id, display_name: "平台负责人" });
|
|
118
|
+
expect((await team(T.ops)).owner).toEqual({ user_id: U.ownerB.id, display_name: "" });
|
|
119
|
+
const nope = await patch("owner", T.plat, { lead_node_id: "node_nope" });
|
|
120
|
+
expect(nope.status).toBe(404); expect(nope.body.error).toBe("node_not_found");
|
|
121
|
+
expect((await patch("owner", T.plat, { owner_user_id: U.owner2.id })).body.error).toBe("owner_not_member");
|
|
122
|
+
expect((await patch("owner", T.plat, {})).body.error).toBe("empty_patch");
|
|
123
|
+
});
|
|
124
|
+
test("cycle rejected (self and descendant)", async () => {
|
|
125
|
+
for (const p of [T.plat, T.api]) {
|
|
126
|
+
const r = await patch("owner", T.plat, { parent_id: p });
|
|
127
|
+
expect(r.status).toBe(400); expect(r.body.error).toBe("team_cycle");
|
|
128
|
+
}
|
|
129
|
+
expect((await team(T.plat)).parent_id).toBe(null);
|
|
130
|
+
});
|
|
131
|
+
test("delete with children rejected; leaf delete clears memberships", async () => {
|
|
132
|
+
const r = await del("owner", T.plat);
|
|
133
|
+
expect(r.status).toBe(409); expect(r.body.error).toBe("team_has_children"); expect(r.body.children).toBe(2);
|
|
134
|
+
const tmp = (await create("owner", "临时", T.ops)).body.team.id;
|
|
135
|
+
expect((await assign("owner", N.n3, tmp)).status).toBe(200);
|
|
136
|
+
expect(await teamOf(N.n3)).toBe(tmp);
|
|
137
|
+
expect((await del("owner", tmp)).status).toBe(200);
|
|
138
|
+
expect(await teamOf(N.n3)).toBe(null);
|
|
139
|
+
expect((await del("owner", tmp)).status).toBe(404);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("#764 membership set / move / clear", () => {
|
|
144
|
+
test("set, move, clear", async () => {
|
|
145
|
+
const r = await assign("owner", N.n2, T.api);
|
|
146
|
+
expect(r.status).toBe(200);
|
|
147
|
+
expect(r.body).toEqual({ ok: true, node_id: N.n2, team_id: T.api });
|
|
148
|
+
expect((await team(T.api)).members).toEqual([{ node_id: N.n2, alias: "示例节点二", display_name: null }]);
|
|
149
|
+
expect((await assign("owner", N.n2, T.ops)).status).toBe(200);
|
|
150
|
+
expect(await teamOf(N.n2)).toBe(T.ops);
|
|
151
|
+
expect((await team(T.api)).members).toEqual([]);
|
|
152
|
+
expect((await assign("owner", N.n2, null)).status).toBe(200);
|
|
153
|
+
expect(await teamOf(N.n2)).toBe(null);
|
|
154
|
+
expect((await assign("owner", N.n2, "team_nope")).body.error).toBe("team_not_found");
|
|
155
|
+
expect((await assign("owner", "node_nope", T.api)).status).toBe(404);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("#764 subtree owner vs sibling owner", () => {
|
|
160
|
+
test("ownerA manages the 平台 subtree", async () => {
|
|
161
|
+
expect((await patch("ownerA", T.plat, { name: "平台", lead_node_id: N.n2 })).status).toBe(200);
|
|
162
|
+
const c = await create("ownerA", "网关", T.api);
|
|
163
|
+
expect(c.status).toBe(201);
|
|
164
|
+
T.gw = c.body.team.id;
|
|
165
|
+
expect((await patch("ownerA", T.gw, { parent_id: T.web })).status).toBe(200); // 子树内挪动
|
|
166
|
+
expect((await patch("ownerA", T.web, { owner_user_id: U.ownerA.id })).status).toBe(200); // 下级的 owner 可以换
|
|
167
|
+
expect((await assign("ownerA", N.n1, T.gw)).status).toBe(200);
|
|
168
|
+
expect((await assign("ownerA", N.n1, T.api)).status).toBe(200);
|
|
169
|
+
expect((await assign("ownerA", N.n1, null)).status).toBe(200);
|
|
170
|
+
expect((await del("ownerA", T.gw)).status).toBe(200);
|
|
171
|
+
});
|
|
172
|
+
test("ownerA cannot change its own team's owner, move it out, delete it, or create a root", async () => {
|
|
173
|
+
denied(await patch("ownerA", T.plat, { owner_user_id: U.ownerA.id }));
|
|
174
|
+
denied(await patch("ownerA", T.plat, { parent_id: T.ops }));
|
|
175
|
+
denied(await patch("ownerA", T.api, { parent_id: T.ops })); // 挪出子树
|
|
176
|
+
denied(await patch("ownerA", T.api, { parent_id: null }));
|
|
177
|
+
denied(await del("ownerA", T.plat));
|
|
178
|
+
denied(await create("ownerA", "根"));
|
|
179
|
+
});
|
|
180
|
+
test("sibling owner (ownerB) denied on the 平台 subtree", async () => {
|
|
181
|
+
denied(await patch("ownerB", T.plat, { name: "抢" }));
|
|
182
|
+
denied(await patch("ownerB", T.api, { lead_node_id: N.n3 }));
|
|
183
|
+
denied(await create("ownerB", "插", T.plat));
|
|
184
|
+
denied(await del("ownerB", T.web));
|
|
185
|
+
denied(await assign("ownerB", N.n3, T.api));
|
|
186
|
+
expect((await assign("owner", N.n3, T.api)).status).toBe(200);
|
|
187
|
+
denied(await assign("ownerB", N.n3, T.ops)); // 从别人的子树拉走
|
|
188
|
+
denied(await assign("ownerB", N.n3, null));
|
|
189
|
+
expect(await teamOf(N.n3)).toBe(T.api);
|
|
190
|
+
expect((await assign("ownerB", N.n2, T.ops)).status).toBe(200); // 自己子树里可以
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("#764 read-only callers", () => {
|
|
195
|
+
test("plain member and node token: GET ok, every write denied", async () => {
|
|
196
|
+
for (const who of ["member", "node"]) {
|
|
197
|
+
expect((await send(tok(who), "GET", `/api/networks/${NET}/agent-teams`)).status).toBe(200);
|
|
198
|
+
denied(await create(who, "x"));
|
|
199
|
+
denied(await create(who, "x", T.plat));
|
|
200
|
+
denied(await patch(who, T.plat, { name: "x" }));
|
|
201
|
+
denied(await del(who, T.web));
|
|
202
|
+
denied(await assign(who, N.n1, T.plat));
|
|
203
|
+
}
|
|
204
|
+
expect((await teams("node")).find((t) => t.id === T.plat).lead.node_id).toBe(N.n2);
|
|
205
|
+
});
|
|
206
|
+
test("agent-restricted member does not see ungranted nodes", async () => {
|
|
207
|
+
const t = (await teams("member")).find((x) => x.id === T.plat);
|
|
208
|
+
expect(t.lead).toBe(null);
|
|
209
|
+
expect((await teams("member")).every((x) => x.members.length === 0)).toBe(true);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe("#764 restricted subtree owner: invisible nodes are the same as missing ones", () => {
|
|
214
|
+
test("cannot set an invisible lead; cannot assign an invisible node; 404 identical to a nonexistent node", async () => {
|
|
215
|
+
expect((await assign("owner", N.n3, null)).status).toBe(200);
|
|
216
|
+
const leadHidden = await raw(U.ownerA.token, "PATCH", `/api/networks/${NET}/agent-teams/${T.api}`, { lead_node_id: N.n3 });
|
|
217
|
+
const leadMissing = await raw(U.ownerA.token, "PATCH", `/api/networks/${NET}/agent-teams/${T.api}`, { lead_node_id: "node_nope" });
|
|
218
|
+
expect(leadHidden.status).toBe(404);
|
|
219
|
+
expect(leadHidden.text).toBe(leadMissing.text);
|
|
220
|
+
expect((await team(T.api)).lead).toBe(null);
|
|
221
|
+
const putHidden = await raw(U.ownerA.token, "PUT", `/api/networks/${NET}/nodes/${N.n3}/agent-team`, { team_id: T.api });
|
|
222
|
+
const putMissing = await raw(U.ownerA.token, "PUT", `/api/networks/${NET}/nodes/node_nope/agent-team`, { team_id: T.api });
|
|
223
|
+
expect(putHidden.status).toBe(404);
|
|
224
|
+
expect(putHidden.text).toBe(putMissing.text.replace("node_nope", N.n3));
|
|
225
|
+
expect(await teamOf(N.n3)).toBe(null);
|
|
226
|
+
// 能看见的节点照常可设
|
|
227
|
+
const ok = await patch("ownerA", T.api, { lead_node_id: N.n1 });
|
|
228
|
+
expect(ok.status).toBe(200); expect(ok.body.team.lead.node_id).toBe(N.n1);
|
|
229
|
+
});
|
|
230
|
+
test("PATCH response never echoes an invisible lead", async () => {
|
|
231
|
+
expect((await patch("owner", T.web, { lead_node_id: N.n3 })).status).toBe(200);
|
|
232
|
+
const r = await patch("ownerA", T.web, { name: "前端组" });
|
|
233
|
+
expect(r.status).toBe(200);
|
|
234
|
+
expect(r.body.team.lead).toBe(null);
|
|
235
|
+
expect(JSON.stringify(r.body)).not.toContain(N.n3);
|
|
236
|
+
});
|
|
237
|
+
test("owner_not_member is only reported after the permission check", async () => {
|
|
238
|
+
denied(await patch("ownerA", T.plat, { owner_user_id: "user_nope" }));
|
|
239
|
+
denied(await patch("ownerB", T.plat, { owner_user_id: "user_nope" }));
|
|
240
|
+
denied(await patch("member", T.plat, { owner_user_id: "user_nope" }));
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe("#764 network owner (not a Hub admin)", () => {
|
|
245
|
+
test("owner2 manages its own network's teams end to end", async () => {
|
|
246
|
+
expect(db.get<{ role: string }>("SELECT role FROM users WHERE user_id = ?1", U.owner2.id)?.role).not.toBe("admin");
|
|
247
|
+
const c = await create("owner2", "外网根", null, NET2);
|
|
248
|
+
expect(c.status).toBe(201);
|
|
249
|
+
const id = c.body.team.id;
|
|
250
|
+
const child = (await create("owner2", "外网子", id, NET2)).body.team.id;
|
|
251
|
+
expect((await patch("owner2", child, { name: "外网子组", lead_node_id: N.x1, owner_user_id: U.owner2.id }, NET2)).status).toBe(200);
|
|
252
|
+
expect((await assign("owner2", N.x1, child, NET2)).status).toBe(200);
|
|
253
|
+
expect((await assign("owner2", N.x1, null, NET2)).status).toBe(200);
|
|
254
|
+
expect((await send(U.owner2.token, "DELETE", `/api/networks/${NET2}/agent-teams/${child}`)).status).toBe(200);
|
|
255
|
+
expect((await send(U.owner2.token, "DELETE", `/api/networks/${NET2}/agent-teams/${id}`)).status).toBe(200);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe("#764 cross-network", () => {
|
|
260
|
+
test("foreign network's ids are not usable here", async () => {
|
|
261
|
+
const foreign = (await create("owner2", "外网团队", null, NET2)).body.team.id;
|
|
262
|
+
expect((await patch("owner", foreign, { name: "x" })).status).toBe(404);
|
|
263
|
+
expect((await del("owner", foreign)).status).toBe(404);
|
|
264
|
+
expect((await create("owner", "x", foreign)).body.error).toBe("parent_not_found");
|
|
265
|
+
expect((await patch("owner", T.api, { parent_id: foreign })).body.error).toBe("parent_not_found");
|
|
266
|
+
expect((await assign("owner", N.n1, foreign)).body.error).toBe("team_not_found");
|
|
267
|
+
expect((await assign("owner", N.x1, T.api)).status).toBe(404);
|
|
268
|
+
expect((await patch("owner", T.api, { lead_node_id: N.x1 })).status).toBe(404);
|
|
269
|
+
expect((await assign("owner2", N.x1, T.api, NET2)).body.error).toBe("team_not_found");
|
|
270
|
+
// 非成员 / 别的网络的节点令牌
|
|
271
|
+
expect((await send(U.owner2.token, "GET", `/api/networks/${NET}/agent-teams`)).status).toBe(403);
|
|
272
|
+
expect((await send(NODE_TOKEN, "GET", `/api/networks/${NET2}/agent-teams`)).status).toBe(403);
|
|
273
|
+
expect((await (await send(U.owner2.token, "GET", `/api/networks/${NET2}/agent-teams`)).body.teams).length).toBe(1);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe("#764 node delete cleans up", () => {
|
|
278
|
+
test("deleting a node removes its membership and nulls the lead", async () => {
|
|
279
|
+
expect((await assign("owner", N.n2, T.plat)).status).toBe(200);
|
|
280
|
+
expect((await send(U.owner.token, "DELETE", `/api/nodes/${N.n2}`)).status).toBe(200);
|
|
281
|
+
expect(await teamOf(N.n2)).toBe(null);
|
|
282
|
+
expect((await team(T.plat)).lead).toBe(null);
|
|
283
|
+
expect(db.get("SELECT 1 AS x FROM network_agent_team_members WHERE node_id = ?1", N.n2) ?? null).toBe(null);
|
|
284
|
+
expect(db.get("SELECT 1 AS x FROM network_agent_teams WHERE lead_node_id = ?1", N.n2) ?? null).toBe(null);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
describe("#764 network deletion removes team rows", () => {
|
|
289
|
+
const rowsIn = (net: string) => ({
|
|
290
|
+
teams: db.get<{ n: number }>("SELECT COUNT(*) AS n FROM network_agent_teams WHERE network_id = ?1", net)!.n,
|
|
291
|
+
members: db.get<{ n: number }>("SELECT COUNT(*) AS n FROM network_agent_team_members WHERE network_id = ?1", net)!.n,
|
|
292
|
+
});
|
|
293
|
+
const seed = async (who: string) => {
|
|
294
|
+
const r = register(`at_${who}_${Date.now()}`, PW, undefined, who);
|
|
295
|
+
U[who] = { token: r.token!, id: r.user!.user_id };
|
|
296
|
+
const team_id = (await create(who, "将删", null, r.network_id!)).body.team.id;
|
|
297
|
+
db.run("INSERT INTO network_agent_team_members (network_id, node_id, team_id) VALUES (?1, ?2, ?3)", [r.network_id!, `node_gone_${who}`, team_id]);
|
|
298
|
+
expect(rowsIn(r.network_id!)).toEqual({ teams: 1, members: 1 });
|
|
299
|
+
return r.network_id!;
|
|
300
|
+
};
|
|
301
|
+
test("Hub admin deleting an empty network (NETWORK_CLEANUP_TABLES)", async () => {
|
|
302
|
+
const net = await seed("owner3");
|
|
303
|
+
const r = await send(U.owner.token, "DELETE", `/api/networks/${net}`);
|
|
304
|
+
expect(r.status).toBe(200);
|
|
305
|
+
expect(r.body.cleaned.network_agent_teams).toBe(1);
|
|
306
|
+
expect(r.body.cleaned.network_agent_team_members).toBe(1);
|
|
307
|
+
expect(rowsIn(net)).toEqual({ teams: 0, members: 0 });
|
|
308
|
+
});
|
|
309
|
+
test("owner deleting its own network", async () => {
|
|
310
|
+
const net = await seed("owner4");
|
|
311
|
+
expect((await send(U.owner4.token, "DELETE", `/api/networks/${net}`)).status).toBe(200);
|
|
312
|
+
expect(rowsIn(net)).toEqual({ teams: 0, members: 0 });
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
describe("#764 existing endpoints unchanged", () => {
|
|
317
|
+
test("departments / humans / node config byte-identical after all team operations", async () => {
|
|
318
|
+
const after = await snapshot();
|
|
319
|
+
expect(after.departments).toBe(before.departments);
|
|
320
|
+
expect(after.humans).toBe(before.humans);
|
|
321
|
+
expect(after.node).toBe(before.node);
|
|
322
|
+
});
|
|
323
|
+
});
|