@sleep2agi/commhub-server 0.9.0-preview.66 → 0.9.0-preview.68
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 +267 -0
- package/src/agent-acl-http.test.ts +510 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/auth.ts +152 -7
- package/src/create-node-workdir.test.ts +2 -1
- package/src/db.ts +35 -1
- package/src/external-schedule-edits-http.test.ts +3 -2
- package/src/file-download-authz.test.ts +2 -1
- package/src/file-network-scope.test.ts +6 -5
- package/src/health-redaction.test.ts +3 -2
- package/src/human-dm.ts +149 -0
- package/src/network-scope.ts +126 -3
- package/src/node-token-privilege-http.test.ts +149 -0
- package/src/observer-avatar-http.test.ts +2 -1
- package/src/push.ts +43 -2
- package/src/rename.ts +7 -2
- package/src/requirement-tags.ts +16 -0
- package/src/requirements-http.test.ts +278 -12
- package/src/requirements-mcp.test.ts +135 -0
- package/src/requirements-migrate.ts +7 -0
- package/src/requirements-upgrade-e2e.test.ts +1 -1
- package/src/requirements-upgrade-migration.test.ts +9 -0
- package/src/requirements.ts +479 -137
- package/src/rest-explicit-columns-http.test.ts +1 -1
- package/src/restricted-files.ts +73 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-tasks-http.test.ts +92 -1
- package/src/scheduled-tasks.ts +57 -10
- package/src/server.ts +345 -41
- package/src/tools.ts +202 -31
package/src/rename.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { randomUUID } from "crypto";
|
|
16
16
|
import { db } from "./db";
|
|
17
17
|
import { getUserNetworkRole, getNetworkMembers } from "./auth";
|
|
18
|
+
import { canSeeAgent, isAgentRestricted } from "./agent-access";
|
|
18
19
|
import { pushEvent } from "./push";
|
|
19
20
|
import { eventBus } from "./event_bus";
|
|
20
21
|
|
|
@@ -35,7 +36,8 @@ export interface CanonicalAlias {
|
|
|
35
36
|
|
|
36
37
|
function hasWriteAccess(userId: string, networkId: string): boolean {
|
|
37
38
|
const role = getUserNetworkRole(userId, networkId);
|
|
38
|
-
|
|
39
|
+
// 改名是对 Agent 的写操作;受限成员(只看授权 Agent 的成员)不能改。
|
|
40
|
+
return !!role && role !== "viewer" && !isAgentRestricted(userId, networkId);
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
// Resolve committed alias renames (old -> new), following short chains such
|
|
@@ -214,8 +216,11 @@ export function commitRename(userId: string, txnId: string): RenameResult {
|
|
|
214
216
|
// without this it would never receive node.renamed (#84 SSE channel fix:
|
|
215
217
|
// N站马 confirmed the dashboard listens on the user channel). All members get
|
|
216
218
|
// it, not just the owner, since any member's dashboard should reflect the rename.
|
|
219
|
+
// 多用户 Agent 权限:受限成员只收到他被授权的那个 Agent 的改名(否则等于把 alias 广播给他)。
|
|
217
220
|
for (const member of getNetworkMembers(txn.network_id)) {
|
|
218
|
-
if (member.username)
|
|
221
|
+
if (!member.username) continue;
|
|
222
|
+
if (!canSeeAgent(member.user_id, txn.network_id, { alias: txn.new_alias, nodeId: renamedNode?.node_id ?? null })) continue;
|
|
223
|
+
pushEvent(member.username, renamedEvent, txn.network_id);
|
|
219
224
|
}
|
|
220
225
|
|
|
221
226
|
return { ok: true, txn_id: txnId };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Small, network-local labels; count Unicode code points rather than UTF-16 units. */
|
|
2
|
+
export function normalizeTags(value: unknown): string[] | null {
|
|
3
|
+
if (!Array.isArray(value) || value.length > 10) return null;
|
|
4
|
+
const tags: string[] = [];
|
|
5
|
+
for (const valueTag of value) {
|
|
6
|
+
if (typeof valueTag !== "string") return null;
|
|
7
|
+
const tag = valueTag.trim();
|
|
8
|
+
if (!tag || [...tag].length > 20 || /[\u0000-\u001f\u007f]/u.test(tag)) return null;
|
|
9
|
+
if (!tags.includes(tag)) tags.push(tag);
|
|
10
|
+
}
|
|
11
|
+
return tags;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function storedTags(value: string | null): string[] {
|
|
15
|
+
try { return normalizeTags(JSON.parse(value || "[]")) ?? []; } catch { return []; }
|
|
16
|
+
}
|
|
@@ -55,6 +55,84 @@ afterAll(() => {
|
|
|
55
55
|
describe("requirements stay on the hub", () => {
|
|
56
56
|
let id = "";
|
|
57
57
|
|
|
58
|
+
test("name limit is consistent across create, PATCH and both upsert paths", async () => {
|
|
59
|
+
const send = (path: string, method: string, body: unknown) => api(ownerToken, path, { method, body: JSON.stringify(body) });
|
|
60
|
+
const ref = 'github:test/name-limit#1';
|
|
61
|
+
const created = await send('/api/requirements/upsert', 'POST', { name: '字'.repeat(80), external_ref: ref });
|
|
62
|
+
expect(created.status).toBe(201);
|
|
63
|
+
const path = `/api/requirements/${created.body.requirement.id}`;
|
|
64
|
+
for (const name of ['a'.repeat(150), '字'.repeat(81), ' ', null, 12]) {
|
|
65
|
+
for (const [url, method, extra] of [
|
|
66
|
+
['/api/requirements', 'POST', {}],
|
|
67
|
+
[path, 'PATCH', {}],
|
|
68
|
+
['/api/requirements/upsert', 'POST', { external_ref: ref }],
|
|
69
|
+
['/api/requirements/upsert', 'POST', { external_ref: 'github:test/name-limit#2' }],
|
|
70
|
+
] as const) {
|
|
71
|
+
const result = await send(url, method, { ...extra, name });
|
|
72
|
+
expect(result.status).toBe(400);
|
|
73
|
+
expect(result.body.error).toBe('invalid_name');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
expect((await api(ownerToken, path)).body.requirement.name).toBe('字'.repeat(80));
|
|
77
|
+
const boundary = await send(path, 'PATCH', { name: ` ${'界'.repeat(80)} ` });
|
|
78
|
+
expect(boundary.status).toBe(200);
|
|
79
|
+
expect(boundary.body.requirement.name).toBe('界'.repeat(80));
|
|
80
|
+
expect((await send(path, 'PATCH', { priority: 'high' })).body.requirement.name).toBe('界'.repeat(80));
|
|
81
|
+
await api(ownerToken, path, { method: 'DELETE' });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("archived=true returns only archived tasks and default excludes them", async () => {
|
|
85
|
+
const create = async (token: string, name: string) => api(token, '/api/requirements', { method: 'POST', body: JSON.stringify({ name }) });
|
|
86
|
+
const active = (await create(ownerToken, 'archive-filter-active')).body.requirement.id;
|
|
87
|
+
const archived = (await create(ownerToken, 'archive-filter-archived')).body.requirement.id;
|
|
88
|
+
const foreign = (await create(otherToken, 'archive-filter-foreign')).body.requirement.id;
|
|
89
|
+
for (const [token, taskId] of [[ownerToken, archived], [otherToken, foreign]]) {
|
|
90
|
+
expect((await api(token, `/api/requirements/${taskId}`, { method: 'PATCH', body: JSON.stringify({ archived: true }) })).status).toBe(200);
|
|
91
|
+
}
|
|
92
|
+
const ids = async (query: string) => {
|
|
93
|
+
const result = await api(ownerToken, `/api/requirements${query}${query ? '&' : '?'}network_id=${ownerNetwork}`);
|
|
94
|
+
expect(result.status).toBe(200);
|
|
95
|
+
return result.body.requirements.map((task: any) => task.id);
|
|
96
|
+
};
|
|
97
|
+
expect(await ids('?archived=true')).toEqual([archived]);
|
|
98
|
+
expect(await ids('?archived=true&include_archived=1')).toEqual([archived]);
|
|
99
|
+
const defaults = await ids('');
|
|
100
|
+
expect(defaults).toContain(active);
|
|
101
|
+
expect(defaults).not.toContain(archived);
|
|
102
|
+
expect(defaults).not.toContain(foreign);
|
|
103
|
+
expect(await ids('?archived=false')).toEqual(defaults);
|
|
104
|
+
const all = await ids('?include_archived=1');
|
|
105
|
+
expect(all).toContain(active);
|
|
106
|
+
expect(all).toContain(archived);
|
|
107
|
+
expect(all).not.toContain(foreign);
|
|
108
|
+
for (const [token, taskId] of [[ownerToken, active], [ownerToken, archived], [otherToken, foreign]]) {
|
|
109
|
+
await api(token, `/api/requirements/${taskId}`, { method: 'DELETE' });
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("tags validate, aggregate within network, and survive old PATCH shapes", async () => {
|
|
114
|
+
const created = await api(ownerToken, '/api/requirements', { method: 'POST', body: JSON.stringify({ name: 'tagged task', tags: [' UI ', 'UI', '交付'] }) });
|
|
115
|
+
expect(created.status).toBe(201);
|
|
116
|
+
expect(created.body.requirement.tags).toEqual(['UI', '交付']);
|
|
117
|
+
const path = `/api/requirements/${created.body.requirement.id}`;
|
|
118
|
+
const old = await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ column: 'doing', name: 'old app edit' }) });
|
|
119
|
+
expect(old.status).toBe(200);
|
|
120
|
+
expect(old.body.requirement.tags).toEqual(['UI', '交付']);
|
|
121
|
+
for (const tags of [null, 'UI', [12], [''], ['a'.repeat(21)], Array.from({ length: 11 }, (_, i) => `t${i}`)]) {
|
|
122
|
+
const invalid = await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ tags }) });
|
|
123
|
+
expect(invalid.status).toBe(400);
|
|
124
|
+
expect(invalid.body.error).toBe('invalid_tags');
|
|
125
|
+
}
|
|
126
|
+
const own = await api(ownerToken, '/api/requirements/tags');
|
|
127
|
+
expect(own.body.tags).toEqual(['UI', '交付']);
|
|
128
|
+
expect((await api(otherToken, '/api/requirements/tags')).body.tags).toEqual([]);
|
|
129
|
+
expect((await api(nodeToken, '/api/requirements/tags')).body.tags).toEqual(['UI', '交付']);
|
|
130
|
+
expect((await api(viewerToken, `${path}?network_id=${ownerNetwork}`, { method: 'PATCH', body: JSON.stringify({ tags: [] }) })).status).toBe(403);
|
|
131
|
+
const cleared = await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ tags: [] }) });
|
|
132
|
+
expect(cleared.body.requirement.tags).toEqual([]);
|
|
133
|
+
expect((await api(ownerToken, '/api/requirements/tags')).body.tags).toEqual([]);
|
|
134
|
+
});
|
|
135
|
+
|
|
58
136
|
test("typed assignments validate network membership, preserve kinds and clear explicitly", async () => {
|
|
59
137
|
const { db } = await import('./db.js');
|
|
60
138
|
const ownerId = db.get<{ owner_id: string }>('SELECT owner_id FROM networks WHERE network_id=?1', ownerNetwork)!.owner_id;
|
|
@@ -76,7 +154,8 @@ describe("requirements stay on the hub", () => {
|
|
|
76
154
|
const humanAsAgent = await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ agent_owner: user }) });
|
|
77
155
|
expect(humanAsAgent.status).toBe(400);
|
|
78
156
|
expect(humanAsAgent.body.error).toBe('agent_owner_must_be_agent');
|
|
79
|
-
|
|
157
|
+
// 新客户端(带了 agent_owner):节点当负责人严格拒绝
|
|
158
|
+
const agentAsOwner = await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ owner: node, agent_owner: null }) });
|
|
80
159
|
expect(agentAsOwner.status).toBe(400);
|
|
81
160
|
expect(agentAsOwner.body.error).toBe('owner_must_be_human');
|
|
82
161
|
const changed = await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ agent_owner: node }) });
|
|
@@ -85,9 +164,7 @@ describe("requirements stay on the hub", () => {
|
|
|
85
164
|
expect(changed.body.requirement.agent_owner).toEqual(node);
|
|
86
165
|
expect(changed.body.requirement.participants).toEqual([user, node]);
|
|
87
166
|
expect(changed.body.requirement.column).toBe('pool');
|
|
88
|
-
|
|
89
|
-
expect((await api(token, path, { method: 'PATCH', body: JSON.stringify({ owner: null, participants: [] }) })).status).toBe(403);
|
|
90
|
-
}
|
|
167
|
+
expect((await api(viewerToken, path, { method: 'PATCH', body: JSON.stringify({ owner: null, participants: [] }) })).status).toBe(403);
|
|
91
168
|
expect((await api(otherToken, path, { method: 'PATCH', body: JSON.stringify({ owner: null }) })).status).toBe(404);
|
|
92
169
|
for (const invalid of [{ kind: 'node', id: 'foreign-person-node' }, { kind: 'user', id: 'unknown' }, { kind: 'admin', id: ownerId }]) {
|
|
93
170
|
expect((await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ participants: [invalid] }) })).status).toBe(400);
|
|
@@ -115,7 +192,7 @@ describe("requirements stay on the hub", () => {
|
|
|
115
192
|
expect(both.body.requirement.agent_owner).toEqual(agent);
|
|
116
193
|
const plain = await api(ownerToken, '/api/requirements', { method: 'POST', body: JSON.stringify({ name: '都不填' }) });
|
|
117
194
|
expect(plain.body.requirement.agent_owner).toBeNull();
|
|
118
|
-
const wrongOwner = await api(ownerToken, '/api/requirements', { method: 'POST', body: JSON.stringify({ name: '节点当负责人', owner: agent }) });
|
|
195
|
+
const wrongOwner = await api(ownerToken, '/api/requirements', { method: 'POST', body: JSON.stringify({ name: '节点当负责人', owner: agent, agent_owner: null }) });
|
|
119
196
|
expect(wrongOwner.status).toBe(400);
|
|
120
197
|
expect(wrongOwner.body.error).toBe('owner_must_be_human');
|
|
121
198
|
const wrongAgent = await api(ownerToken, '/api/requirements', { method: 'POST', body: JSON.stringify({ name: '人当负责 Agent', agent_owner: human }) });
|
|
@@ -164,7 +241,10 @@ describe("requirements stay on the hub", () => {
|
|
|
164
241
|
expect((await api(ownerToken, `${path}/checklist/a`, { method: "PATCH", body: JSON.stringify({ done: "yes" }) })).status).toBe(400);
|
|
165
242
|
expect((await api(ownerToken, `${path}/checklist/a`, { method: "POST", body: JSON.stringify({ done: true }) })).status).toBe(404);
|
|
166
243
|
expect((await api(viewerToken, `${path}/checklist/a`, { method: "PATCH", body: JSON.stringify({ done: false }) })).status).toBe(403);
|
|
167
|
-
|
|
244
|
+
// Agent(节点令牌)能勾自己网络里的子任务(PR B 放开)
|
|
245
|
+
const agentTick = await api(nodeToken, `${path}/checklist/a`, { method: "PATCH", body: JSON.stringify({ done: true }) });
|
|
246
|
+
expect(agentTick.status).toBe(200);
|
|
247
|
+
expect(agentTick.body.requirement.updated_by.kind).toBe("node");
|
|
168
248
|
expect((await api(otherToken, `${path}/checklist/a`, { method: "PATCH", body: JSON.stringify({ done: false }) })).status).toBe(404);
|
|
169
249
|
|
|
170
250
|
// 整个替换:排序 / 增删;只改描述时清单不动;只改清单是有效 PATCH
|
|
@@ -193,7 +273,7 @@ describe("requirements stay on the hub", () => {
|
|
|
193
273
|
expect(row.checklist.length).toBe(100);
|
|
194
274
|
expect(row.description.length).toBe(20_000);
|
|
195
275
|
expect((await api(viewerToken, path, { method: "PATCH", body: JSON.stringify({ description: "越权" }) })).status).toBe(403);
|
|
196
|
-
expect((await api(nodeToken, path, { method: "PATCH", body: JSON.stringify({ description: "Agent
|
|
276
|
+
expect((await api(nodeToken, path, { method: "PATCH", body: JSON.stringify({ description: "Agent 写的描述" }) })).body.requirement.description).toBe("Agent 写的描述");
|
|
197
277
|
});
|
|
198
278
|
|
|
199
279
|
test("projects: CRUD per network, project_id on cards, delete nulls references, archive keeps them", async () => {
|
|
@@ -217,7 +297,9 @@ describe("requirements stay on the hub", () => {
|
|
|
217
297
|
// 权限:viewer 能读不能写;节点令牌一律拒绝;别的网络看不见、改不了
|
|
218
298
|
expect((await api(viewerToken, `/api/requirements/projects${q}`)).body.projects.length).toBe(2);
|
|
219
299
|
expect((await api(viewerToken, `/api/requirements/projects${q}`, { method: "POST", body: JSON.stringify({ name: "越权" }) })).status).toBe(403);
|
|
220
|
-
|
|
300
|
+
// Agent 能读项目(挂卡片要用),不能建 / 改 / 删项目
|
|
301
|
+
expect((await api(nodeToken, `/api/requirements/projects${q}`)).body.projects.length).toBe(2);
|
|
302
|
+
expect((await api(nodeToken, `/api/requirements/projects${q}`, { method: "POST", body: JSON.stringify({ name: "Agent 建的" }) })).body.error).toBe("user_token_required");
|
|
221
303
|
expect((await api(otherToken, "/api/requirements/projects")).body.projects).toEqual([]);
|
|
222
304
|
expect((await api(otherToken, `/api/requirements/projects/${tmai.body.project.id}`, { method: "PATCH", body: JSON.stringify({ name: "抢" }) })).status).toBe(404);
|
|
223
305
|
const foreign = await api(otherToken, "/api/requirements/projects", { method: "POST", body: JSON.stringify({ name: "别人的项目" }) });
|
|
@@ -290,7 +372,90 @@ describe("requirements stay on the hub", () => {
|
|
|
290
372
|
expect((await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ due: "" }) })).body.requirement.due).toBe("");
|
|
291
373
|
const listed = await api(ownerToken, "/api/requirements");
|
|
292
374
|
expect(listed.body.capabilities).toContain("due_datetime");
|
|
293
|
-
expect(listed.body.capabilities).toEqual(["agent_owner", "description", "checklist", "projects", "due_datetime"]);
|
|
375
|
+
expect(listed.body.capabilities).toEqual(["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags"]);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("sub-requirements: parent_id in the same network, no cycles, ≤ 5 levels, child counts, filters, delete detaches children", async () => {
|
|
379
|
+
const mk = async (name: string, extra: Record<string, unknown> = {}) => (await api(ownerToken, "/api/requirements", { method: "POST", body: JSON.stringify({ name, network_id: ownerNetwork, ...extra }) })).body.requirement;
|
|
380
|
+
const root = await mk("父需求");
|
|
381
|
+
const a = await mk("子 A", { parent_id: root.id });
|
|
382
|
+
const b = await mk("子 B", { parent_id: root.id, column: "done" });
|
|
383
|
+
expect(a.parent_id).toBe(root.id);
|
|
384
|
+
const parent = (await api(ownerToken, `/api/requirements/${root.id}`)).body.requirement;
|
|
385
|
+
expect(parent.children).toEqual({ total: 2, done: 1 });
|
|
386
|
+
expect(parent.parent_id).toBeNull();
|
|
387
|
+
// 过滤
|
|
388
|
+
const kids = await api(ownerToken, `/api/requirements?network_id=${ownerNetwork}&parent_id=${root.id}`);
|
|
389
|
+
expect(kids.body.requirements.map((r: any) => r.id).sort()).toEqual([a.id, b.id].sort());
|
|
390
|
+
const top = await api(ownerToken, `/api/requirements?network_id=${ownerNetwork}&top_level=1`);
|
|
391
|
+
expect(top.body.requirements.some((r: any) => r.id === root.id)).toBe(true);
|
|
392
|
+
expect(top.body.requirements.some((r: any) => r.id === a.id)).toBe(false);
|
|
393
|
+
// 成环:把父卡挂到自己的子卡下 / 挂到自己
|
|
394
|
+
expect((await api(ownerToken, `/api/requirements/${root.id}`, { method: "PATCH", body: JSON.stringify({ parent_id: a.id }) })).body.error).toBe("parent_cycle");
|
|
395
|
+
expect((await api(ownerToken, `/api/requirements/${a.id}`, { method: "PATCH", body: JSON.stringify({ parent_id: a.id }) })).body.error).toBe("parent_cycle");
|
|
396
|
+
// 深度:第 5 层可以,第 6 层不行;把一棵 2 层的子树挂到第 4 层下面也不行
|
|
397
|
+
let chain = root;
|
|
398
|
+
const levels = [root];
|
|
399
|
+
for (let i = 2; i <= 5; i++) { chain = await mk(`第 ${i} 层`, { parent_id: chain.id }); levels.push(chain); }
|
|
400
|
+
expect(chain.parent_id).toBe(levels[3].id);
|
|
401
|
+
expect((await api(ownerToken, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "第 6 层", network_id: ownerNetwork, parent_id: chain.id }) })).body.error).toBe("parent_too_deep");
|
|
402
|
+
const subtree = await mk("子树根");
|
|
403
|
+
await mk("子树叶", { parent_id: subtree.id });
|
|
404
|
+
expect((await api(ownerToken, `/api/requirements/${subtree.id}`, { method: "PATCH", body: JSON.stringify({ parent_id: levels[3].id }) })).body.error).toBe("parent_too_deep");
|
|
405
|
+
expect((await api(ownerToken, `/api/requirements/${subtree.id}`, { method: "PATCH", body: JSON.stringify({ parent_id: levels[2].id }) })).status).toBe(200);
|
|
406
|
+
// 别的网络的卡当父卡:拒绝;坏 id:拒绝
|
|
407
|
+
const foreign = (await api(otherToken, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "别的网络的" }) })).body.requirement;
|
|
408
|
+
expect((await api(ownerToken, `/api/requirements/${a.id}`, { method: "PATCH", body: JSON.stringify({ parent_id: foreign.id }) })).body.error).toBe("parent_not_found");
|
|
409
|
+
expect((await api(otherToken, `/api/requirements/${foreign.id}`, { method: "PATCH", body: JSON.stringify({ parent_id: root.id }) })).body.error).toBe("parent_not_found");
|
|
410
|
+
expect((await api(ownerToken, `/api/requirements/${a.id}`, { method: "PATCH", body: JSON.stringify({ parent_id: "bad id!" }) })).body.error).toBe("invalid_parent_id");
|
|
411
|
+
// 解挂
|
|
412
|
+
expect((await api(ownerToken, `/api/requirements/${b.id}`, { method: "PATCH", body: JSON.stringify({ parent_id: null }) })).body.requirement.parent_id).toBeNull();
|
|
413
|
+
// 删父卡:子卡保留、变顶层
|
|
414
|
+
const before = (await api(ownerToken, `/api/requirements?network_id=${ownerNetwork}`)).body.requirements.length;
|
|
415
|
+
expect((await api(ownerToken, `/api/requirements/${root.id}`, { method: "DELETE" })).status).toBe(200);
|
|
416
|
+
const after = (await api(ownerToken, `/api/requirements?network_id=${ownerNetwork}`)).body.requirements;
|
|
417
|
+
expect(after.length).toBe(before - 1);
|
|
418
|
+
expect(after.find((r: any) => r.id === a.id).parent_id).toBeNull();
|
|
419
|
+
expect(after.find((r: any) => r.id === levels[1].id).parent_id).toBeNull();
|
|
420
|
+
expect(after.find((r: any) => r.id === levels[2].id).parent_id).toBe(levels[1].id);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("old-app compat: owner {kind:'node'} without agent_owner becomes agent_owner (200 + flag); new clients stay strict", async () => {
|
|
424
|
+
const { db } = await import('./db.js');
|
|
425
|
+
const ownerId = db.get<{ owner_id: string }>('SELECT owner_id FROM networks WHERE network_id=?1', ownerNetwork)!.owner_id;
|
|
426
|
+
db.run('INSERT OR IGNORE INTO nodes(node_id,node_name,alias,network_id) VALUES (?1,?2,?2,?3)', ['compat-node', 'compat-node', ownerNetwork]);
|
|
427
|
+
const node = { kind: 'node', id: 'compat-node' };
|
|
428
|
+
const human = { kind: 'user', id: ownerId };
|
|
429
|
+
const card = (await api(ownerToken, '/api/requirements', { method: 'POST', body: JSON.stringify({ name: '旧 App 的卡', owner: human }) })).body.requirement;
|
|
430
|
+
const path = `/api/requirements/${card.id}`;
|
|
431
|
+
// 0.2.142 的详情保存:{name?, owner:{node}} —— 不带 agent_owner
|
|
432
|
+
const old = await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ owner: node }) });
|
|
433
|
+
expect(old.status).toBe(200);
|
|
434
|
+
expect(old.body.owner_coerced_to_agent_owner).toBe(true);
|
|
435
|
+
// 回显给旧客户端的 owner 是它设的那个节点(它按这个核对「保存生效」);agent_owner 也在
|
|
436
|
+
expect(old.body.requirement.owner).toEqual(node);
|
|
437
|
+
expect(old.body.requirement.agent_owner).toEqual(node);
|
|
438
|
+
// 实际存储:负责人清空,负责 Agent = 节点;GET 看到的是真实值
|
|
439
|
+
const stored = (await api(ownerToken, path)).body.requirement;
|
|
440
|
+
expect(stored.owner).toBeNull();
|
|
441
|
+
expect(stored.agent_owner).toEqual(node);
|
|
442
|
+
const row = db.get<{ owner_json: string | null; agent_owner_json: string | null }>('SELECT owner_json, agent_owner_json FROM requirements WHERE requirement_id=?1', card.id)!;
|
|
443
|
+
expect(row.owner_json).toBeNull();
|
|
444
|
+
expect(JSON.parse(row.agent_owner_json!)).toEqual(node);
|
|
445
|
+
// 旧 App 新建时选了节点当负责人:同样改写
|
|
446
|
+
const created = await api(ownerToken, '/api/requirements', { method: 'POST', body: JSON.stringify({ name: '旧 App 新建', owner: node, assignee: '' }) });
|
|
447
|
+
expect(created.status).toBe(201);
|
|
448
|
+
expect(created.body.owner_coerced_to_agent_owner).toBe(true);
|
|
449
|
+
expect((await api(ownerToken, `/api/requirements/${created.body.requirement.id}`)).body.requirement.agent_owner).toEqual(node);
|
|
450
|
+
// 旧 App 设人类负责人:照常,不带标志
|
|
451
|
+
const normal = await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ owner: human }) });
|
|
452
|
+
expect(normal.body.owner_coerced_to_agent_owner).toBeUndefined();
|
|
453
|
+
expect(normal.body.requirement.owner).toEqual(human);
|
|
454
|
+
expect(normal.body.requirement.agent_owner).toEqual(node);
|
|
455
|
+
// 新客户端带 agent_owner:严格
|
|
456
|
+
expect((await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ owner: node, agent_owner: node }) })).body.error).toBe('owner_must_be_human');
|
|
457
|
+
// 节点不在网络里:仍然 400(不因为兼容放宽成员校验)
|
|
458
|
+
expect((await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ owner: { kind: 'node', id: 'foreign-person-node' } }) })).status).toBe(400);
|
|
294
459
|
});
|
|
295
460
|
|
|
296
461
|
test("owner creates a card in the pool", async () => {
|
|
@@ -340,9 +505,110 @@ describe("requirements stay on the hub", () => {
|
|
|
340
505
|
expect(move.status).toBe(403);
|
|
341
506
|
});
|
|
342
507
|
|
|
343
|
-
test("a node token cannot
|
|
344
|
-
const
|
|
345
|
-
|
|
508
|
+
test("agent API: a node token reads / creates / patches in its own network only; cannot delete; who did what is recorded", async () => {
|
|
509
|
+
const { db } = await import("./db.js");
|
|
510
|
+
const { createNetworkTokenForNode, register } = await import("./auth.js");
|
|
511
|
+
const ownerId = db.get<{ owner_id: string }>("SELECT owner_id FROM networks WHERE network_id=?1", ownerNetwork)!.owner_id;
|
|
512
|
+
const bound = createNetworkTokenForNode(ownerId, ownerNetwork, "sync-bot", "node_sync_bot");
|
|
513
|
+
expect(bound.ok).toBe(true);
|
|
514
|
+
const bot = bound.token!;
|
|
515
|
+
// 读
|
|
516
|
+
const listed = await api(bot, "/api/requirements");
|
|
517
|
+
expect(listed.status).toBe(200);
|
|
518
|
+
expect(listed.body.requirements.length).toBeGreaterThan(0);
|
|
519
|
+
// 建:记下是哪个节点建的
|
|
520
|
+
const made = await api(bot, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "Agent 建的任务", column: "doing", checklist: [{ id: "x", text: "第一步" }] }) });
|
|
521
|
+
expect(made.status).toBe(201);
|
|
522
|
+
expect(made.body.requirement.created_by).toEqual({ kind: "node", id: "node_sync_bot" });
|
|
523
|
+
expect(made.body.requirement.updated_by).toEqual({ kind: "node", id: "node_sync_bot" });
|
|
524
|
+
const path = `/api/requirements/${made.body.requirement.id}`;
|
|
525
|
+
// 按 id 读、改、勾、归档
|
|
526
|
+
expect((await api(bot, path)).body.requirement.name).toBe("Agent 建的任务");
|
|
527
|
+
expect((await api(bot, path, { method: "PATCH", body: JSON.stringify({ column: "done" }) })).body.requirement.column).toBe("done");
|
|
528
|
+
expect((await api(bot, `${path}/checklist/x`, { method: "PATCH", body: JSON.stringify({ done: true }) })).body.requirement.checklist[0].done).toBe(true);
|
|
529
|
+
// 人改了之后 updated_by 变成人
|
|
530
|
+
const human = await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ priority: "high" }) });
|
|
531
|
+
expect(human.body.requirement.updated_by).toEqual({ kind: "user", id: ownerId });
|
|
532
|
+
expect(human.body.requirement.created_by).toEqual({ kind: "node", id: "node_sync_bot" });
|
|
533
|
+
// 不能删,只能归档;归档的默认不在列表里
|
|
534
|
+
const del = await api(bot, path, { method: "DELETE" });
|
|
535
|
+
expect(del.status).toBe(403);
|
|
536
|
+
expect(del.body.error).toBe("user_token_required");
|
|
537
|
+
expect((await api(bot, path, { method: "PATCH", body: JSON.stringify({ archived: true }) })).body.requirement.archived).toBe(true);
|
|
538
|
+
expect((await api(ownerToken, "/api/requirements")).body.requirements.some((r: any) => r.id === made.body.requirement.id)).toBe(false);
|
|
539
|
+
expect((await api(ownerToken, "/api/requirements?include_archived=1")).body.requirements.some((r: any) => r.id === made.body.requirement.id)).toBe(true);
|
|
540
|
+
expect((await api(bot, path, { method: "PATCH", body: JSON.stringify({ archived: "yes" }) })).status).toBe(400);
|
|
541
|
+
// 别的网络的节点令牌:看不到、改不了、也不能往别的网络里建
|
|
542
|
+
const outsider = register(`req_outsider_${Date.now()}`, "RequirementsOutsider123!", undefined, "seed");
|
|
543
|
+
const foreignTok = createNetworkTokenForNode(outsider.user!.user_id, outsider.network_id!, "foreign-bot", "node_foreign_bot");
|
|
544
|
+
expect(foreignTok.ok).toBe(true);
|
|
545
|
+
const foreign = foreignTok.token!;
|
|
546
|
+
expect((await api(foreign, `/api/requirements?include_archived=1`)).body.requirements.some((r: any) => r.id === made.body.requirement.id)).toBe(false);
|
|
547
|
+
expect((await api(foreign, path)).status).toBe(404);
|
|
548
|
+
expect((await api(foreign, path, { method: "PATCH", body: JSON.stringify({ name: "越界" }) })).status).toBe(404);
|
|
549
|
+
expect((await api(foreign, `${path}/checklist/x`, { method: "PATCH", body: JSON.stringify({ done: false }) })).status).toBe(404);
|
|
550
|
+
const planted = await api(foreign, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "塞进别的网络", network_id: ownerNetwork }) });
|
|
551
|
+
expect(planted.status).toBe(201);
|
|
552
|
+
// 写进的是它自己的网络(令牌绑定的网络优先于 body 里的 network_id)。owner 在测试库里是管理员,所以按网络查。
|
|
553
|
+
expect((await api(ownerToken, `/api/requirements?network_id=${ownerNetwork}`)).body.requirements.some((r: any) => r.id === planted.body.requirement.id)).toBe(false);
|
|
554
|
+
expect((await api(foreign, `/api/requirements/${planted.body.requirement.id}`)).status).toBe(200);
|
|
555
|
+
// 人照常能删
|
|
556
|
+
const humanDel = await api(ownerToken, path, { method: "DELETE" });
|
|
557
|
+
expect(humanDel.status).toBe(200);
|
|
558
|
+
expect((await api(ownerToken, path)).status).toBe(404);
|
|
559
|
+
expect((await api(viewerToken, `/api/requirements/${planted.body.requirement.id}`, { method: "DELETE" })).status).toBe(404);
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
test("external_ref: unique per network, 409 with the existing id, upsert is idempotent, list filters", async () => {
|
|
563
|
+
const { db } = await import("./db.js");
|
|
564
|
+
const { createNetworkTokenForNode } = await import("./auth.js");
|
|
565
|
+
const ownerId = db.get<{ owner_id: string }>("SELECT owner_id FROM networks WHERE network_id=?1", ownerNetwork)!.owner_id;
|
|
566
|
+
const bot = createNetworkTokenForNode(ownerId, ownerNetwork, "gh-sync", "node_gh_sync").token!;
|
|
567
|
+
const ref = "github:acme/widgets#123";
|
|
568
|
+
const first = await api(bot, "/api/requirements/upsert", { method: "POST", body: JSON.stringify({ external_ref: ref, external_url: "https://github.com/acme/widgets/issues/123", name: "Issue 标题", description: "issue 正文" }) });
|
|
569
|
+
expect(first.status).toBe(201);
|
|
570
|
+
expect(first.body.created).toBe(true);
|
|
571
|
+
expect(first.body.requirement.external_ref).toBe(ref);
|
|
572
|
+
expect(first.body.requirement.external_url).toBe("https://github.com/acme/widgets/issues/123");
|
|
573
|
+
// 同一个 issue 再同步:改,不重复建;省略的字段(状态)保留
|
|
574
|
+
await api(ownerToken, `/api/requirements/${first.body.requirement.id}`, { method: "PATCH", body: JSON.stringify({ column: "doing" }) });
|
|
575
|
+
const again = await api(bot, "/api/requirements/upsert", { method: "POST", body: JSON.stringify({ external_ref: ref, name: "Issue 改过的标题" }) });
|
|
576
|
+
expect(again.status).toBe(200);
|
|
577
|
+
expect(again.body.created).toBe(false);
|
|
578
|
+
expect(again.body.requirement.id).toBe(first.body.requirement.id);
|
|
579
|
+
expect(again.body.requirement.name).toBe("Issue 改过的标题");
|
|
580
|
+
expect(again.body.requirement.column).toBe("doing");
|
|
581
|
+
const same = await api(bot, "/api/requirements/upsert", { method: "POST", body: JSON.stringify({ external_ref: ref }) });
|
|
582
|
+
expect(same.body.created).toBe(false);
|
|
583
|
+
expect((await api(ownerToken, `/api/requirements?external_ref=${encodeURIComponent(ref)}`)).body.requirements.length).toBe(1);
|
|
584
|
+
// 普通 POST 撞同一个 external_ref:409 + 已有 id
|
|
585
|
+
const dup = await api(ownerToken, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "重复", external_ref: ref }) });
|
|
586
|
+
expect(dup.status).toBe(409);
|
|
587
|
+
expect(dup.body.error).toBe("external_ref_exists");
|
|
588
|
+
expect(dup.body.existing_id).toBe(first.body.requirement.id);
|
|
589
|
+
// PATCH 改成别人已有的 external_ref 也 409
|
|
590
|
+
const other = await api(ownerToken, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "另一个", external_ref: "github:acme/widgets#124" }) });
|
|
591
|
+
expect((await api(ownerToken, `/api/requirements/${other.body.requirement.id}`, { method: "PATCH", body: JSON.stringify({ external_ref: ref }) })).status).toBe(409);
|
|
592
|
+
// 另一个网络可以有同样的 external_ref(唯一只在网络内)
|
|
593
|
+
const elsewhere = await api(otherToken, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "别的网络同一个 issue", external_ref: ref }) });
|
|
594
|
+
expect(elsewhere.status).toBe(201);
|
|
595
|
+
// 坏值
|
|
596
|
+
for (const bad of [{ external_ref: "has space" }, { external_ref: "" }, { external_url: "javascript:alert(1)" }]) {
|
|
597
|
+
expect((await api(ownerToken, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "坏", ...bad }) })).status).toBe(400);
|
|
598
|
+
}
|
|
599
|
+
expect((await api(bot, "/api/requirements/upsert", { method: "POST", body: JSON.stringify({ name: "没有 ref" }) })).body.error).toBe("external_ref_required");
|
|
600
|
+
// 列表筛选
|
|
601
|
+
const byStatus = await api(ownerToken, "/api/requirements?status=doing");
|
|
602
|
+
expect(byStatus.body.requirements.every((r: any) => r.column === "doing")).toBe(true);
|
|
603
|
+
expect((await api(ownerToken, "/api/requirements?status=nope")).status).toBe(400);
|
|
604
|
+
const since = new Date(Date.now() - 60_000).toISOString();
|
|
605
|
+
expect((await api(ownerToken, `/api/requirements?updated_since=${encodeURIComponent(since)}`)).body.requirements.some((r: any) => r.id === first.body.requirement.id)).toBe(true);
|
|
606
|
+
expect((await api(ownerToken, `/api/requirements?updated_since=${encodeURIComponent(new Date(Date.now() + 60_000).toISOString())}`)).body.requirements.length).toBe(0);
|
|
607
|
+
expect((await api(ownerToken, "/api/requirements?updated_since=nope")).status).toBe(400);
|
|
608
|
+
const human = { kind: "user", id: ownerId };
|
|
609
|
+
await api(ownerToken, `/api/requirements/${other.body.requirement.id}`, { method: "PATCH", body: JSON.stringify({ owner: human }) });
|
|
610
|
+
expect((await api(ownerToken, `/api/requirements?owner=user:${ownerId}`)).body.requirements.map((r: any) => r.id)).toContain(other.body.requirement.id);
|
|
611
|
+
expect((await api(ownerToken, "/api/requirements?owner=bad")).status).toBe(400);
|
|
346
612
|
});
|
|
347
613
|
|
|
348
614
|
test("the same client_id does not create a second card", async () => {
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// 需求池 MCP 工具:走真 MCP in-process transport。Agent(节点令牌)在自己网络里读 / 建 / 改 / 勾 / upsert、
|
|
2
|
+
// 别的网络的节点碰不到、删除不存在于工具里(只有人能在 REST 删)。同步场景:同一个 GitHub issue 同步两次不重复。
|
|
3
|
+
// 跑法:cd server && COMMHUB_DB=/tmp/req-mcp.db bun test src/requirements-mcp.test.ts
|
|
4
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
5
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
const dir = mkdtempSync(join(tmpdir(), "anet-req-mcp-"));
|
|
10
|
+
process.env.COMMHUB_DB = join(dir, "hub.db");
|
|
11
|
+
|
|
12
|
+
const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
|
|
13
|
+
const { InMemoryTransport } = await import("@modelcontextprotocol/sdk/inMemory.js");
|
|
14
|
+
const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
|
|
15
|
+
const { db } = await import("./db.js");
|
|
16
|
+
const { register, createNetworkTokenForNode } = await import("./auth.js");
|
|
17
|
+
const { registerTools } = await import("./tools.js");
|
|
18
|
+
|
|
19
|
+
type Identity = { net: string; user: string; alias: string; isNetworkToken: boolean; tokenId: string };
|
|
20
|
+
let owner: Identity, bot: Identity, foreignBot: Identity;
|
|
21
|
+
let ownerNet = "";
|
|
22
|
+
|
|
23
|
+
async function connect(id: Identity) {
|
|
24
|
+
const server = new McpServer({ name: "req-mcp-test", version: "1" });
|
|
25
|
+
registerTools(server, undefined, id.isNetworkToken ? id.net : null, id.user, id.alias, id.isNetworkToken, id.tokenId);
|
|
26
|
+
const client = new Client({ name: "req-mcp-client", version: "1" });
|
|
27
|
+
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
28
|
+
await server.connect(st);
|
|
29
|
+
await client.connect(ct);
|
|
30
|
+
const call = async (name: string, args: Record<string, unknown>) => {
|
|
31
|
+
const r: any = await client.callTool({ name, arguments: args });
|
|
32
|
+
const first = r.content?.[0];
|
|
33
|
+
if (r.isError) return { mcpError: true, text: first?.text };
|
|
34
|
+
return JSON.parse(first.text);
|
|
35
|
+
};
|
|
36
|
+
return { server, call, close: async () => { await client.close(); await server.close(); } };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
beforeAll(() => {
|
|
40
|
+
const o = register(`reqmcp_owner_${Date.now()}`, "ReqMcpOwner123!", undefined, "seed");
|
|
41
|
+
const x = register(`reqmcp_other_${Date.now()}`, "ReqMcpOther123!", undefined, "seed");
|
|
42
|
+
ownerNet = o.network_id!;
|
|
43
|
+
const t1 = createNetworkTokenForNode(o.user!.user_id, ownerNet, "gh-sync", "node_gh_sync");
|
|
44
|
+
const t2 = createNetworkTokenForNode(x.user!.user_id, x.network_id!, "foreign", "node_foreign");
|
|
45
|
+
owner = { net: ownerNet, user: o.user!.user_id, alias: o.user!.username, isNetworkToken: false, tokenId: "tok_user" };
|
|
46
|
+
bot = { net: ownerNet, user: o.user!.user_id, alias: "gh-sync", isNetworkToken: true, tokenId: t1.token_id! };
|
|
47
|
+
foreignBot = { net: x.network_id!, user: x.user!.user_id, alias: "foreign", isNetworkToken: true, tokenId: t2.token_id! };
|
|
48
|
+
});
|
|
49
|
+
afterAll(() => { try { rmSync(dir, { recursive: true, force: true }); } catch {} });
|
|
50
|
+
|
|
51
|
+
describe("requirements MCP tools", () => {
|
|
52
|
+
test("the seven tools are registered with validated schemas; there is no delete tool", async () => {
|
|
53
|
+
const s = await connect(owner);
|
|
54
|
+
try {
|
|
55
|
+
const reg: Record<string, any> = (s.server as any)._registeredTools;
|
|
56
|
+
for (const n of ["requirements_list", "requirements_get", "requirements_create", "requirements_update", "requirements_checklist_toggle", "requirements_upsert_by_external_ref", "projects_list"]) expect(reg[n], n).toBeDefined();
|
|
57
|
+
expect(Object.keys(reg).some(n => /^requirements_delete/.test(n))).toBe(false);
|
|
58
|
+
// 校验:坏的 status / 负责人种类 / 子任务 id 在协议边界被拒
|
|
59
|
+
expect((await s.call("requirements_list", { status: "nope" })).mcpError).toBe(true);
|
|
60
|
+
expect((await s.call("requirements_create", { name: "x", owner: { kind: "robot", id: "a" } })).mcpError).toBe(true);
|
|
61
|
+
expect((await s.call("requirements_checklist_toggle", { id: "r", item_id: "has space", done: true })).mcpError).toBe(true);
|
|
62
|
+
expect((await s.call("requirements_create", { name: "" })).mcpError).toBe(true);
|
|
63
|
+
} finally { await s.close(); }
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("an agent syncs a GitHub issue idempotently, edits it, ticks a checklist item and archives it", async () => {
|
|
67
|
+
const s = await connect(bot);
|
|
68
|
+
try {
|
|
69
|
+
const ref = "github:acme/widgets#42";
|
|
70
|
+
const first = await s.call("requirements_upsert_by_external_ref", { external_ref: ref, external_url: "https://github.com/acme/widgets/issues/42", name: "修复登录", description: "issue 正文", checklist: [{ id: "c1", text: "复现" }, { id: "c2", text: "修" }] });
|
|
71
|
+
expect(first.ok).toBe(true);
|
|
72
|
+
expect(first.created).toBe(true);
|
|
73
|
+
const tagged = await s.call("requirements_update", { id: first.requirement.id, tags: ["release"] });
|
|
74
|
+
expect(tagged.requirement.tags).toEqual(["release"]);
|
|
75
|
+
expect(first.requirement.created_by).toEqual({ kind: "node", id: "node_gh_sync" });
|
|
76
|
+
const again = await s.call("requirements_upsert_by_external_ref", { external_ref: ref, name: "修复登录(改了标题)" });
|
|
77
|
+
expect(again.created).toBe(false);
|
|
78
|
+
expect(again.requirement.tags).toEqual(["release"]);
|
|
79
|
+
expect(again.requirement.id).toBe(first.requirement.id);
|
|
80
|
+
const listed = await s.call("requirements_list", { external_ref: ref });
|
|
81
|
+
expect(listed.requirements.length).toBe(1);
|
|
82
|
+
expect(listed.requirements[0].name).toBe("修复登录(改了标题)");
|
|
83
|
+
const dup = await s.call("requirements_create", { name: "重复", external_ref: ref });
|
|
84
|
+
expect(dup.error).toBe("external_ref_exists");
|
|
85
|
+
expect(dup.existing_id).toBe(first.requirement.id);
|
|
86
|
+
expect(dup.status).toBe(409);
|
|
87
|
+
const id = first.requirement.id;
|
|
88
|
+
expect((await s.call("requirements_update", { id, column: "doing", agent_owner: { kind: "node", id: "node_gh_sync" } })).requirement.column).toBe("doing");
|
|
89
|
+
const ticked = await s.call("requirements_checklist_toggle", { id, item_id: "c1", done: true });
|
|
90
|
+
expect(ticked.requirement.checklist.map((i: any) => i.done)).toEqual([true, false]);
|
|
91
|
+
expect((await s.call("requirements_get", { id })).requirement.agent_owner).toEqual({ kind: "node", id: "node_gh_sync" });
|
|
92
|
+
expect((await s.call("requirements_list", { status: "doing", agent_owner: "node:node_gh_sync" })).requirements.map((r: any) => r.id)).toContain(id);
|
|
93
|
+
expect((await s.call("requirements_update", { id, owner: { kind: "node", id: "node_gh_sync" }, agent_owner: null })).error).toBe("owner_must_be_human");
|
|
94
|
+
expect((await s.call("requirements_update", { id, archived: true })).requirement.archived).toBe(true);
|
|
95
|
+
expect((await s.call("requirements_list", {})).requirements.some((r: any) => r.id === id)).toBe(false);
|
|
96
|
+
expect((await s.call("requirements_list", { include_archived: true })).requirements.some((r: any) => r.id === id)).toBe(true);
|
|
97
|
+
expect(Array.isArray((await s.call("projects_list", {})).projects)).toBe(true);
|
|
98
|
+
// 子需求:建子卡、父卡上的进度、按父卡 / 顶层过滤
|
|
99
|
+
const child = await s.call("requirements_create", { name: "子需求", parent_id: id });
|
|
100
|
+
expect(child.requirement.parent_id).toBe(id);
|
|
101
|
+
expect((await s.call("requirements_get", { id })).requirement.children.total).toBe(1);
|
|
102
|
+
expect((await s.call("requirements_list", { parent_id: id })).requirements.map((r: any) => r.id)).toEqual([child.requirement.id]);
|
|
103
|
+
expect((await s.call("requirements_list", { top_level: true, include_archived: true })).requirements.some((r: any) => r.id === child.requirement.id)).toBe(false);
|
|
104
|
+
expect((await s.call("requirements_update", { id, parent_id: child.requirement.id })).error).toBe("parent_cycle");
|
|
105
|
+
} finally { await s.close(); }
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("a node token from another network cannot see or touch these tasks, nor write into this network", async () => {
|
|
109
|
+
const mine = await connect(bot);
|
|
110
|
+
const theirs = await connect(foreignBot);
|
|
111
|
+
try {
|
|
112
|
+
const made = await mine.call("requirements_create", { name: "只属于本网络" });
|
|
113
|
+
const id = made.requirement.id;
|
|
114
|
+
expect((await theirs.call("requirements_get", { id })).error).toBe("requirement_not_found");
|
|
115
|
+
expect((await theirs.call("requirements_update", { id, name: "越界" })).error).toBe("requirement_not_found");
|
|
116
|
+
expect((await theirs.call("requirements_checklist_toggle", { id, item_id: "x", done: true })).error).toBe("requirement_not_found");
|
|
117
|
+
expect((await theirs.call("requirements_list", { network_id: ownerNet })).requirements.some((r: any) => r.id === id)).toBe(false);
|
|
118
|
+
const planted = await theirs.call("requirements_create", { name: "塞进去", network_id: ownerNet });
|
|
119
|
+
expect(planted.ok).toBe(true);
|
|
120
|
+
const row = db.get<{ network_id: string }>("SELECT network_id FROM requirements WHERE requirement_id = ?1", planted.requirement.id)!;
|
|
121
|
+
expect(row.network_id).toBe(foreignBot.net);
|
|
122
|
+
expect((await mine.call("requirements_get", { id })).requirement.name).toBe("只属于本网络");
|
|
123
|
+
} finally { await mine.close(); await theirs.close(); }
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("a user token keeps working through the same tools", async () => {
|
|
127
|
+
const s = await connect(owner);
|
|
128
|
+
try {
|
|
129
|
+
const made = await s.call("requirements_create", { name: "人建的", network_id: ownerNet, owner: { kind: "user", id: owner.user } });
|
|
130
|
+
expect(made.ok).toBe(true);
|
|
131
|
+
expect(made.requirement.created_by).toEqual({ kind: "user", id: owner.user });
|
|
132
|
+
expect((await s.call("requirements_list", { network_id: ownerNet, owner: `user:${owner.user}` })).requirements.map((r: any) => r.id)).toContain(made.requirement.id);
|
|
133
|
+
} finally { await s.close(); }
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -42,3 +42,10 @@ export function ensureRequirementProjects(database: DbAdapter): void {
|
|
|
42
42
|
CREATE INDEX IF NOT EXISTS idx_requirement_projects_network ON requirement_projects(network_id, sort);
|
|
43
43
|
`);
|
|
44
44
|
}
|
|
45
|
+
|
|
46
|
+
/** external_ref 在同一网络里唯一(只管有值的行)。IF NOT EXISTS:重复执行无副作用。 */
|
|
47
|
+
export function ensureRequirementIndexes(database: DbAdapter): void {
|
|
48
|
+
database.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_requirements_external_ref ON requirements(network_id, external_ref) WHERE external_ref IS NOT NULL");
|
|
49
|
+
database.exec("CREATE INDEX IF NOT EXISTS idx_requirements_network_updated ON requirements(network_id, updated_at)");
|
|
50
|
+
database.exec("CREATE INDEX IF NOT EXISTS idx_requirements_parent ON requirements(parent_id) WHERE parent_id IS NOT NULL");
|
|
51
|
+
}
|
|
@@ -88,7 +88,7 @@ test("an upgraded hub moves node owners to agent_owner and then enforces human o
|
|
|
88
88
|
expect(both.status).toBe(200);
|
|
89
89
|
expect(both.body.requirement.owner).toEqual(human);
|
|
90
90
|
expect(both.body.requirement.agent_owner).toEqual(agent);
|
|
91
|
-
expect((await api(token, path, { method: "PATCH", body: JSON.stringify({ owner: agent }) })).body.error).toBe("owner_must_be_human");
|
|
91
|
+
expect((await api(token, path, { method: "PATCH", body: JSON.stringify({ owner: agent, agent_owner: agent }) })).body.error).toBe("owner_must_be_human");
|
|
92
92
|
expect((await api(token, path, { method: "PATCH", body: JSON.stringify({ agent_owner: human }) })).body.error).toBe("agent_owner_must_be_agent");
|
|
93
93
|
|
|
94
94
|
// 预计完成:旧卡的全天值(升级前写的)原样读回;改成带时刻的,存 UTC
|
|
@@ -64,6 +64,15 @@ test("startup migration moves node owners to agent_owner, keeps every row, and i
|
|
|
64
64
|
expect(extra.every(r => r.description === null && r.checklist_json === null)).toBe(true);
|
|
65
65
|
// 项目表:建了、是空的(不预置),重复执行不报错;卡片的 project_id 列只加不改
|
|
66
66
|
expect(cols.includes("project_id")).toBe(true);
|
|
67
|
+
// PR B:外部引用 / 归档 / 谁做的 —— 只加不改;旧行 archived = 0、其余 NULL;external_ref 的部分唯一索引建了
|
|
68
|
+
for (const c of ["external_ref", "external_url", "archived", "created_by_json", "updated_by_json", "parent_id"]) expect(cols.includes(c)).toBe(true);
|
|
69
|
+
expect((db.all("SELECT parent_id FROM requirements WHERE parent_id IS NOT NULL") as any[]).length).toBe(0);
|
|
70
|
+
expect((db.all("PRAGMA index_list(requirements)") as { name: string }[]).some(i => i.name === "idx_requirements_parent")).toBe(true);
|
|
71
|
+
expect((db.all("SELECT archived, external_ref FROM requirements") as any[]).every(r => r.archived === 0 && r.external_ref === null)).toBe(true);
|
|
72
|
+
const idx = (db.all("PRAGMA index_list(requirements)") as { name: string; unique: number }[]).find(i => i.name === "idx_requirements_external_ref");
|
|
73
|
+
expect(idx?.unique).toBe(1);
|
|
74
|
+
const { ensureRequirementIndexes } = await import("./requirements-migrate.js");
|
|
75
|
+
ensureRequirementIndexes(db);
|
|
67
76
|
expect((db.all("SELECT project_id FROM requirements WHERE project_id IS NOT NULL") as any[]).length).toBe(0);
|
|
68
77
|
expect((db.get("SELECT COUNT(*) AS n FROM requirement_projects") as any).n).toBe(0);
|
|
69
78
|
const { ensureRequirementProjects } = await import("./requirements-migrate.js");
|