@sleep2agi/commhub-server 0.9.0-preview.73 → 0.9.0-preview.74
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-acl-http.test.ts +2 -0
- package/src/auth.ts +16 -7
- package/src/dm-file-scope-http.test.ts +178 -0
- package/src/human-dm.ts +35 -8
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/requirement-tag-ops-http.test.ts +266 -0
- package/src/requirement-tags.ts +73 -0
- package/src/requirements-completed-at-migration.test.ts +70 -0
- package/src/requirements-http.test.ts +3 -2
- package/src/requirements-migrate.ts +60 -0
- package/src/requirements-seq-http.test.ts +2 -1
- package/src/requirements-stats-http.test.ts +288 -0
- package/src/requirements-stats.ts +164 -0
- package/src/requirements-upgrade-migration.test.ts +13 -0
- package/src/requirements.ts +193 -19
- package/src/rest-explicit-columns-http.test.ts +1 -1
- package/src/scheduled-reply-unread-http.test.ts +212 -0
- package/src/server.ts +81 -10
- package/src/task-access-http.test.ts +375 -0
- package/src/task-access.ts +284 -0
- package/src/tools.ts +47 -7
- package/src/uploads.ts +8 -0
package/src/server.ts
CHANGED
|
@@ -15,7 +15,8 @@ import { assertNodeActive } from "./lifecycle-guard.js";
|
|
|
15
15
|
import { pendingInboxCount } from "./inbox-count.js";
|
|
16
16
|
import { addAgentNetworkScope, addHumanNetworkScope, addNetworkScope, addOwnTrafficScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, resolveRestWriteNetworkId, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
17
17
|
import { restrictedMemberSeesFile, restrictedMemberAttachmentsDenied } from "./restricted-files.js";
|
|
18
|
-
import { listDmThread, listDmThreads, sendHumanDm } from "./human-dm.js";
|
|
18
|
+
import { dmParticipantSeesFile, listDmThread, listDmThreads, sendHumanDm } from "./human-dm.js";
|
|
19
|
+
import { getTaskAccessMode, isTaskScoped, listProjectGrants, replaceTaskGrants, NEW_MEMBER_TASK_ACCESS } from "./task-access.js";
|
|
19
20
|
import { canMessageAgent, isAgentRestricted, listAgentGrants, listGroupGrants, getAgentAccessMode, replaceAgentGrants, restrictedNetworkIds, usernameIsAgentAlias, listAgentGroups, createAgentGroup, updateAgentGroup, replaceAgentGroupMembers, deleteAgentGroup, usersGrantedGroup } from "./agent-access.js";
|
|
20
21
|
import { validateAvatarUrl } from "./avatar-validate.js";
|
|
21
22
|
import { narrowTags, parseStoredTags, validateScalarAttr } from "./node-attrs-validate.js";
|
|
@@ -41,6 +42,7 @@ import {
|
|
|
41
42
|
stripHostLocalPathsForCrossHostSafe,
|
|
42
43
|
validateAttachments,
|
|
43
44
|
validateIndexEntry,
|
|
45
|
+
DM_FILE_SCOPE,
|
|
44
46
|
} from "./uploads.js";
|
|
45
47
|
import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, statSync, unlinkSync, renameSync } from "fs";
|
|
46
48
|
import { dirname as pathDirname, join as pathJoin } from "path";
|
|
@@ -385,15 +387,16 @@ export function resolvePrincipal(req: Request, options: RequestTokenOptions = {}
|
|
|
385
387
|
// these two fields lives here and nowhere else, so the authz rules can be
|
|
386
388
|
// read as rules rather than as string-checks.
|
|
387
389
|
export function normalizeEntry(
|
|
388
|
-
entry: { owner_id?: unknown; network_id?: unknown },
|
|
389
|
-
): { ownerId: string | null; networkId: string | null } {
|
|
390
|
+
entry: { owner_id?: unknown; network_id?: unknown; scope?: unknown },
|
|
391
|
+
): { ownerId: string | null; networkId: string | null; dmScoped?: true } {
|
|
390
392
|
const ownerId = typeof entry.owner_id === "string" && entry.owner_id.length > 0
|
|
391
393
|
? entry.owner_id
|
|
392
394
|
: null;
|
|
393
395
|
const networkId = typeof entry.network_id === "string" && entry.network_id.length > 0
|
|
394
396
|
? entry.network_id
|
|
395
397
|
: null;
|
|
396
|
-
|
|
398
|
+
// dmScoped 只在是私信文件时出现:其余条目的形状与之前逐字相同。
|
|
399
|
+
return { ownerId, networkId, ...(entry.scope === DM_FILE_SCOPE ? { dmScoped: true as const } : {}) };
|
|
397
400
|
}
|
|
398
401
|
|
|
399
402
|
// #495/#503 — shared authorization gate for /api/files/:file_id downloads.
|
|
@@ -414,6 +417,13 @@ export function normalizeEntry(
|
|
|
414
417
|
// a task but not open its attachment is a broken product).
|
|
415
418
|
// - entry.network_id absent (legacy / unattributed) → the pre-#503
|
|
416
419
|
// rules stand unchanged: owner match, or null owner_id + DEV_OPEN.
|
|
420
|
+
// - entry.dmScoped (uploaded with ?purpose=dm — its first use is a
|
|
421
|
+
// human DM) → only the uploader and the users on either side of a DM
|
|
422
|
+
// that carries it (human-dm.ts dmParticipantSeesFile). Other members of
|
|
423
|
+
// the network and node tokens (agents) are denied. admin-utok and the
|
|
424
|
+
// legacy master token still pass above, as for every other file.
|
|
425
|
+
// Files uploaded any other way never become DM-scoped, including when
|
|
426
|
+
// they are later forwarded into a DM.
|
|
417
427
|
// - Everything else → deny (404). New uploads in production always
|
|
418
428
|
// carry a truthy owner_id (requireAuth blocks the null-owner-
|
|
419
429
|
// producing path when DEV_OPEN=off and no legacy master token is
|
|
@@ -428,7 +438,7 @@ export function normalizeEntry(
|
|
|
428
438
|
// 所有文件在同一 network → 无实际影响, 但语义变更须明写, 不能悄悄发生.
|
|
429
439
|
export function authorizeFileDownload(
|
|
430
440
|
principal: Principal,
|
|
431
|
-
entry: { ownerId: string | null; networkId: string | null },
|
|
441
|
+
entry: { ownerId: string | null; networkId: string | null; dmScoped?: boolean },
|
|
432
442
|
fileId?: string,
|
|
433
443
|
): boolean {
|
|
434
444
|
if (principal.kind === "legacy-master") return true;
|
|
@@ -436,8 +446,13 @@ export function authorizeFileDownload(
|
|
|
436
446
|
|
|
437
447
|
if (entry.networkId !== null) {
|
|
438
448
|
if (principal.kind === "anonymous" || principal.kind === "dev-open-anon") return false;
|
|
439
|
-
if (principal.kind === "ntok") return principal.boundNetworkId === entry.networkId;
|
|
449
|
+
if (principal.kind === "ntok") return principal.boundNetworkId === entry.networkId && !entry.dmScoped;
|
|
440
450
|
if (!getUserNetworkRole(principal.userId, entry.networkId)) return false;
|
|
451
|
+
// 私信文件:只看是不是这段私信里的人,与受限 / 不受限无关。
|
|
452
|
+
if (entry.dmScoped) {
|
|
453
|
+
if (entry.ownerId !== null && entry.ownerId === principal.userId) return true;
|
|
454
|
+
return !!fileId && dmParticipantSeesFile(principal.userId, entry.networkId, fileId);
|
|
455
|
+
}
|
|
441
456
|
// 多用户 Agent 权限:受限成员只能下载自己传的,或者出现在自己与授权 Agent 往来 / 自己收件箱里的文件。
|
|
442
457
|
if (!isAgentRestricted(principal.userId, entry.networkId)) return true;
|
|
443
458
|
if (entry.ownerId !== null && entry.ownerId === principal.userId) return true;
|
|
@@ -1548,6 +1563,51 @@ return Bun.serve({
|
|
|
1548
1563
|
// ── 多用户 Agent 权限:成员可访问的 Agent(owner/admin 管理) ──
|
|
1549
1564
|
// GET → { agent_access: 'all'|'granted', grants: [{node_id, alias, can_message}] }
|
|
1550
1565
|
// PUT { grants?: [{node_id | alias, can_message?}] | string[], agent_access?: 'all'|'granted' } → 整体替换
|
|
1566
|
+
// ── 任务(需求卡)的人员权限(RFC-038 §9):成员能看 / 能改哪些卡。owner/admin/Hub 管理员管理 ──
|
|
1567
|
+
// GET → { task_access: 'all'|'scoped', restricted, project_grants: [{project_id, can_edit}] }
|
|
1568
|
+
// PUT { task_access?, project_grants?: [{project_id, can_edit?}] | string[] } → 不传的字段保持原样;传了整体替换
|
|
1569
|
+
const taskGrantsMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/members\/([^/]+)\/task-grants$/);
|
|
1570
|
+
if (taskGrantsMatch && (req.method === "GET" || req.method === "PUT")) {
|
|
1571
|
+
const token = requestToken(req, { allowQueryToken: false });
|
|
1572
|
+
const resolved = token ? resolveToken(token) : null;
|
|
1573
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1574
|
+
if (resolved.networkId) return withCors(req, Response.json({ ok: false, error: "user_token_required" }, { status: 403 }));
|
|
1575
|
+
const netId = decodeURIComponent(taskGrantsMatch[1]);
|
|
1576
|
+
const targetUid = decodeURIComponent(taskGrantsMatch[2]);
|
|
1577
|
+
const hubAdmin = resolved.user.role === "admin";
|
|
1578
|
+
const callerRole = getUserNetworkRole(resolved.user.user_id, netId);
|
|
1579
|
+
if (!hubAdmin && callerRole !== "owner" && callerRole !== "admin") {
|
|
1580
|
+
return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
|
|
1581
|
+
}
|
|
1582
|
+
const targetRole = getUserNetworkRole(targetUid, netId);
|
|
1583
|
+
if (!targetRole) return withCors(req, Response.json({ ok: false, error: "member_not_found" }, { status: 404 }));
|
|
1584
|
+
const view = () => ({
|
|
1585
|
+
ok: true, network_id: netId, user_id: targetUid, role: targetRole,
|
|
1586
|
+
task_access: getTaskAccessMode(netId, targetUid), restricted: isTaskScoped(targetUid, netId),
|
|
1587
|
+
project_grants: listProjectGrants(netId, targetUid),
|
|
1588
|
+
});
|
|
1589
|
+
if (req.method === "GET") return withCors(req, Response.json(view()));
|
|
1590
|
+
// 网络 admin 不能改 owner / 其他 admin(对他们本来也不生效)。
|
|
1591
|
+
if (!hubAdmin && callerRole !== "owner" && (targetRole === "owner" || targetRole === "admin")) {
|
|
1592
|
+
return withCors(req, Response.json({ ok: false, error: "owner required" }, { status: 403 }));
|
|
1593
|
+
}
|
|
1594
|
+
let body: any;
|
|
1595
|
+
try { body = await req.json(); } catch { return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 })); }
|
|
1596
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 }));
|
|
1597
|
+
if (body.task_access === undefined && body.project_grants === undefined) {
|
|
1598
|
+
return withCors(req, Response.json({ ok: false, error: "task_access or project_grants required" }, { status: 400 }));
|
|
1599
|
+
}
|
|
1600
|
+
const before = { task_access: getTaskAccessMode(netId, targetUid), project_grants: listProjectGrants(netId, targetUid) };
|
|
1601
|
+
const result = replaceTaskGrants({ networkId: netId, userId: targetUid, taskAccess: body.task_access, projectGrants: body.project_grants, actorUserId: resolved.user.user_id });
|
|
1602
|
+
if (!result.ok) {
|
|
1603
|
+
return withCors(req, Response.json({ ok: false, error: result.error, ...(result.detail ? { detail: result.detail } : {}) }, { status: result.status }));
|
|
1604
|
+
}
|
|
1605
|
+
logAudit(resolved.user.user_id, resolved.user.username, "member_task_grants_changed", "network", netId,
|
|
1606
|
+
JSON.stringify({ user_id: targetUid, before, after: { task_access: result.task_access, project_grants: result.project_grants } }).slice(0, 4000),
|
|
1607
|
+
undefined, netId);
|
|
1608
|
+
return withCors(req, Response.json(view()));
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1551
1611
|
const grantsMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/members\/([^/]+)\/agent-grants$/);
|
|
1552
1612
|
if (grantsMatch && (req.method === "GET" || req.method === "PUT")) {
|
|
1553
1613
|
const token = requestToken(req, { allowQueryToken: false });
|
|
@@ -1648,8 +1708,12 @@ return Bun.serve({
|
|
|
1648
1708
|
if (body.agent_access !== undefined && body.agent_access !== "all" && body.agent_access !== "granted") {
|
|
1649
1709
|
return withCors(req, Response.json({ ok: false, error: "invalid_agent_access" }, { status: 400 }));
|
|
1650
1710
|
}
|
|
1651
|
-
|
|
1652
|
-
if (
|
|
1711
|
+
// task_access 缺省 NEW_MEMBER_TASK_ACCESS(RFC-038 §9.4);显式传 'all' / 'scoped' 覆盖。
|
|
1712
|
+
if (body.task_access !== undefined && body.task_access !== "all" && body.task_access !== "scoped") {
|
|
1713
|
+
return withCors(req, Response.json({ ok: false, error: "invalid_task_access" }, { status: 400 }));
|
|
1714
|
+
}
|
|
1715
|
+
const result = addNetworkMember(netId, body.user_id, body.role || "member", resolved.user.user_id, { agentAccess: body.agent_access, taskAccess: body.task_access });
|
|
1716
|
+
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "member_added", "network", netId, `${body.user_id} as ${body.role || "member"} agent_access=${body.agent_access === "all" ? "all" : "granted"} task_access=${body.task_access ?? NEW_MEMBER_TASK_ACCESS}`, undefined, netId);
|
|
1653
1717
|
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
1654
1718
|
}
|
|
1655
1719
|
if (req.method === "PUT" && targetUid) {
|
|
@@ -1657,7 +1721,7 @@ return Bun.serve({
|
|
|
1657
1721
|
const body = await req.json() as any;
|
|
1658
1722
|
const result = updateMemberRole(netId, targetUid, body.role);
|
|
1659
1723
|
if (result.ok) {
|
|
1660
|
-
logAudit(resolved.user.user_id, resolved.user.username, "member_role_changed", "network", netId, `${targetUid} → ${body.role}
|
|
1724
|
+
logAudit(resolved.user.user_id, resolved.user.username, "member_role_changed", "network", netId, `${targetUid} → ${body.role}`, undefined, netId);
|
|
1661
1725
|
closeUserStreamsInNetwork(netId, targetUid);
|
|
1662
1726
|
}
|
|
1663
1727
|
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
@@ -1666,7 +1730,7 @@ return Bun.serve({
|
|
|
1666
1730
|
if (!["owner", "admin"].includes(callerRole)) return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
|
|
1667
1731
|
const result = removeNetworkMember(netId, targetUid);
|
|
1668
1732
|
if (result.ok) {
|
|
1669
|
-
logAudit(resolved.user.user_id, resolved.user.username, "member_removed", "network", netId, targetUid);
|
|
1733
|
+
logAudit(resolved.user.user_id, resolved.user.username, "member_removed", "network", netId, targetUid, undefined, netId);
|
|
1670
1734
|
closeUserStreamsInNetwork(netId, targetUid);
|
|
1671
1735
|
}
|
|
1672
1736
|
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
@@ -2454,6 +2518,12 @@ return Bun.serve({
|
|
|
2454
2518
|
// uploaded and never gets their multipart envelope parsed.
|
|
2455
2519
|
const principal = resolvePrincipal(req);
|
|
2456
2520
|
const requestedNetId = url.searchParams.get("network_id");
|
|
2521
|
+
// ?purpose=dm:这个文件是为一条人与人私信传的(见 UploadIndexEntry.scope)。缺省 = 旧行为。
|
|
2522
|
+
// 拼错的值拒掉,不静默当成缺省 —— 否则私信图会不声不响地变成全网可见。
|
|
2523
|
+
const purpose = url.searchParams.get("purpose");
|
|
2524
|
+
if (purpose !== null && purpose !== DM_FILE_SCOPE) {
|
|
2525
|
+
return earlyReject(Response.json({ ok: false, error: "bad_purpose", message: `purpose must be "${DM_FILE_SCOPE}" or absent` }, { status: 400 }));
|
|
2526
|
+
}
|
|
2457
2527
|
let uploadNetId: string | null = null;
|
|
2458
2528
|
|
|
2459
2529
|
switch (principal.kind) {
|
|
@@ -2630,6 +2700,7 @@ return Bun.serve({
|
|
|
2630
2700
|
owner_id: authCtx?.userId ?? null,
|
|
2631
2701
|
// Key omitted (not null) when unattributed — see UploadIndexEntry.
|
|
2632
2702
|
...(uploadNetId ? { network_id: uploadNetId } : {}),
|
|
2703
|
+
...(purpose === DM_FILE_SCOPE ? { scope: DM_FILE_SCOPE } : {}),
|
|
2633
2704
|
uploaded_at: new Date().toISOString(),
|
|
2634
2705
|
}, null, 2));
|
|
2635
2706
|
} else {
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
// 任务(需求卡)的人员权限(RFC-038 §9)—— HTTP 集成测试(真实 Bun.serve,私有端口,临时库)。
|
|
2
|
+
//
|
|
3
|
+
// 场景:Hub 管理员 admin 的网络 NET。成员:
|
|
4
|
+
// alice —— 新建成员,经授权接口显式设成 scoped,授权 P1(只看)、P2(可改);
|
|
5
|
+
// (新成员默认值 NEW_MEMBER_TASK_ACCESS 暂时是 'all',见「默认值与授权接口」)
|
|
6
|
+
// carol —— 升级前的老成员(task_access='all'):行为必须与今天逐字相同;
|
|
7
|
+
// vic —— 经授权接口显式设成 scoped 的 viewer,授权 P3(只看)。
|
|
8
|
+
// admin 建的卡:c_own(负责人 alice)、c_part(参与人 alice)、c_p1 / c_p2 / c_p3(在项目里)、c_other(与 alice 无关)。
|
|
9
|
+
// 正向:相关卡与授权项目看得见;负责 / 自建 / can_edit 能改;老成员与 owner 照旧;MCP 与 REST 一致。
|
|
10
|
+
// 反向:看不见的卡在 GET / PATCH / DELETE / 勾子任务 / #N 上与不存在的卡逐字节相同;只读卡 403;
|
|
11
|
+
// 往没授权的项目建卡、挂到看不见的父卡、撞别人的 client_id、external_ref / upsert、项目管理都被挡。
|
|
12
|
+
|
|
13
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
14
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
15
|
+
import { join } from "path";
|
|
16
|
+
import { tmpdir } from "os";
|
|
17
|
+
import { issueUserToken, register } from "./auth.js";
|
|
18
|
+
import { db } from "./db.js";
|
|
19
|
+
import { NEW_MEMBER_TASK_ACCESS } from "./task-access.js";
|
|
20
|
+
|
|
21
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-task-access-"));
|
|
22
|
+
let BASE = "";
|
|
23
|
+
let hub: any = null;
|
|
24
|
+
const PW = "TaskAccessPassw0rd!x";
|
|
25
|
+
let NET = "";
|
|
26
|
+
let admin = { token: "", id: "" };
|
|
27
|
+
let alice = { token: "", id: "" };
|
|
28
|
+
let carol = { token: "", id: "" };
|
|
29
|
+
let vic = { token: "", id: "" };
|
|
30
|
+
const P: Record<string, string> = {};
|
|
31
|
+
const C: Record<string, { id: string; seq: number }> = {};
|
|
32
|
+
|
|
33
|
+
type R = { status: number; body: any; text: string };
|
|
34
|
+
async function send(token: string, method: string, path: string, payload?: unknown): Promise<R> {
|
|
35
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
36
|
+
method,
|
|
37
|
+
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), "Content-Type": "application/json" },
|
|
38
|
+
body: payload === undefined ? undefined : JSON.stringify(payload),
|
|
39
|
+
});
|
|
40
|
+
const text = await res.text();
|
|
41
|
+
let body: any = null;
|
|
42
|
+
try { body = JSON.parse(text); } catch {}
|
|
43
|
+
return { status: res.status, body, text };
|
|
44
|
+
}
|
|
45
|
+
const get = (t: string, p: string) => send(t, "GET", p);
|
|
46
|
+
const listIds = async (t: string, qs = "") => {
|
|
47
|
+
const r = await get(t, `/api/requirements?network_id=${NET}${qs}`);
|
|
48
|
+
expect(r.status).toBe(200);
|
|
49
|
+
return (r.body.requirements as any[]).map(x => x.name).filter((n: string) => n.startsWith("ta-")).sort();
|
|
50
|
+
};
|
|
51
|
+
async function mcp(token: string, name: string, args: Record<string, unknown>): Promise<any> {
|
|
52
|
+
const res = await fetch(`${BASE}/mcp`, {
|
|
53
|
+
method: "POST",
|
|
54
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
|
|
55
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
|
|
56
|
+
});
|
|
57
|
+
const raw = await res.text();
|
|
58
|
+
const lines = raw.split("\n").filter(x => x.startsWith("data:"));
|
|
59
|
+
const payload = lines.length ? JSON.parse(lines.at(-1)!.slice(5).trim()) : JSON.parse(raw);
|
|
60
|
+
return JSON.parse(payload.result.content[0].text);
|
|
61
|
+
}
|
|
62
|
+
const userRef = (id: string) => ({ kind: "user", id });
|
|
63
|
+
|
|
64
|
+
beforeAll(async () => {
|
|
65
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
66
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
67
|
+
process.env.HOST = "127.0.0.1";
|
|
68
|
+
const a = register(`ta_admin_${Date.now()}`, PW, undefined, "Admin");
|
|
69
|
+
expect(a.ok).toBe(true);
|
|
70
|
+
admin = { token: a.token!, id: a.user!.user_id };
|
|
71
|
+
NET = a.network_id!;
|
|
72
|
+
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [admin.id]);
|
|
73
|
+
const mod: any = await import("./server.js");
|
|
74
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
75
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
76
|
+
|
|
77
|
+
const mk = async (username: string, role: string) => {
|
|
78
|
+
const r = await send(admin.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role });
|
|
79
|
+
expect(r.status).toBe(200);
|
|
80
|
+
const login = await send("", "POST", "/api/auth/login", { username, password: PW });
|
|
81
|
+
expect(login.status).toBe(200);
|
|
82
|
+
return { token: login.body.token as string, id: login.body.user.user_id as string };
|
|
83
|
+
};
|
|
84
|
+
const stamp = Date.now();
|
|
85
|
+
alice = await mk(`ta_alice_${stamp}`, "member");
|
|
86
|
+
carol = await mk(`ta_carol_${stamp}`, "member");
|
|
87
|
+
vic = await mk(`ta_vic_${stamp}`, "viewer");
|
|
88
|
+
// carol = 升级前就在网络里的成员(ALTER 默认 'all')。
|
|
89
|
+
db.run("UPDATE network_members SET task_access = 'all' WHERE network_id = ?1 AND user_id = ?2", [NET, carol.id]);
|
|
90
|
+
|
|
91
|
+
for (const name of ["ta-P1", "ta-P2", "ta-P3", "ta-P4"]) {
|
|
92
|
+
const r = await send(admin.token, "POST", "/api/requirements/projects", { network_id: NET, name });
|
|
93
|
+
expect(r.status).toBe(201);
|
|
94
|
+
P[name] = r.body.project.id;
|
|
95
|
+
}
|
|
96
|
+
const card = async (name: string, extra: Record<string, unknown> = {}) => {
|
|
97
|
+
const r = await send(admin.token, "POST", "/api/requirements", { network_id: NET, name, ...extra });
|
|
98
|
+
expect(r.status).toBe(201);
|
|
99
|
+
C[name] = { id: r.body.requirement.id, seq: r.body.requirement.seq };
|
|
100
|
+
};
|
|
101
|
+
await card("ta-own", { owner: userRef(alice.id), checklist: [{ id: "i1", text: "x", done: false }] });
|
|
102
|
+
await card("ta-part", { participants: [userRef(alice.id)], checklist: [{ id: "i1", text: "x", done: false }] });
|
|
103
|
+
await card("ta-p1", { project_id: P["ta-P1"] });
|
|
104
|
+
await card("ta-p2", { project_id: P["ta-P2"] });
|
|
105
|
+
await card("ta-p3", { project_id: P["ta-P3"] });
|
|
106
|
+
await card("ta-other", { tags: ["secret-tag"], client_id: "admin-client-1" });
|
|
107
|
+
|
|
108
|
+
const g = await send(admin.token, "PUT", `/api/networks/${NET}/members/${alice.id}/task-grants`, { task_access: "scoped", project_grants: [{ project_id: P["ta-P1"] }, { project_id: P["ta-P2"], can_edit: true }] });
|
|
109
|
+
expect(g.status).toBe(200);
|
|
110
|
+
const gv = await send(admin.token, "PUT", `/api/networks/${NET}/members/${vic.id}/task-grants`, { task_access: "scoped", project_grants: [P["ta-P3"]] });
|
|
111
|
+
expect(gv.status).toBe(200);
|
|
112
|
+
}, 30_000);
|
|
113
|
+
|
|
114
|
+
afterAll(() => {
|
|
115
|
+
try { hub?.stop?.(true); } catch {}
|
|
116
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("默认值与授权接口", () => {
|
|
120
|
+
test("新成员默认 all:常量是 'all';管理员建号 / POST members / 邀请码加入都落在 all 且看得见全部卡", async () => {
|
|
121
|
+
expect(NEW_MEMBER_TASK_ACCESS).toBe("all");
|
|
122
|
+
const all = ["ta-other", "ta-own", "ta-p1", "ta-p2", "ta-p3", "ta-part"];
|
|
123
|
+
const stamp = Date.now();
|
|
124
|
+
// 管理员建号
|
|
125
|
+
const u1 = { username: `ta_new1_${stamp}`, password: PW, network_id: NET, role: "member" };
|
|
126
|
+
expect((await send(admin.token, "POST", "/api/admin/users", u1)).status).toBe(200);
|
|
127
|
+
const l1 = await send("", "POST", "/api/auth/login", { username: u1.username, password: PW });
|
|
128
|
+
// POST /members 不带 task_access
|
|
129
|
+
const u2 = register(`ta_new2_${stamp}`, PW);
|
|
130
|
+
expect((await send(admin.token, "POST", `/api/networks/${NET}/members`, { user_id: u2.user!.user_id, role: "member" })).status).toBe(200);
|
|
131
|
+
// 邀请码加入
|
|
132
|
+
const inv = await send(admin.token, "POST", `/api/networks/${NET}/invite`, { role: "member" });
|
|
133
|
+
const u3 = register(`ta_new3_${stamp}`, PW);
|
|
134
|
+
expect((await send(u3.token!, "POST", "/api/networks/join", { invite_code: inv.body.invite_code })).status).toBe(200);
|
|
135
|
+
for (const [id, token] of [[l1.body.user.user_id, l1.body.token], [u2.user!.user_id, u2.token!], [u3.user!.user_id, u3.token!]] as [string, string][]) {
|
|
136
|
+
const g = await get(admin.token, `/api/networks/${NET}/members/${id}/task-grants`);
|
|
137
|
+
expect(g.body.task_access).toBe("all");
|
|
138
|
+
expect(g.body.restricted).toBe(false);
|
|
139
|
+
expect(await listIds(token)).toEqual(all);
|
|
140
|
+
const me = await get(token, "/api/auth/me");
|
|
141
|
+
expect(me.body.networks.find((n: any) => n.network_id === NET).task_access).toBe("all");
|
|
142
|
+
}
|
|
143
|
+
// POST /members 显式传 scoped 仍然生效
|
|
144
|
+
const u4 = register(`ta_new4_${stamp}`, PW);
|
|
145
|
+
expect((await send(admin.token, "POST", `/api/networks/${NET}/members`, { user_id: u4.user!.user_id, role: "member", task_access: "scoped" })).status).toBe(200);
|
|
146
|
+
expect((await get(admin.token, `/api/networks/${NET}/members/${u4.user!.user_id}/task-grants`)).body.task_access).toBe("scoped");
|
|
147
|
+
expect(await listIds(u4.token!)).toEqual([]);
|
|
148
|
+
for (const id of [l1.body.user.user_id, u2.user!.user_id, u3.user!.user_id, u4.user!.user_id]) {
|
|
149
|
+
expect((await send(admin.token, "DELETE", `/api/networks/${NET}/members/${id}`)).status).toBe(200);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("经授权接口显式设成 scoped 的成员:task_access / restricted / 授权与改默认值之前逐字相同;升级前的行是 'all'", async () => {
|
|
154
|
+
const r = await get(admin.token, `/api/networks/${NET}/members/${alice.id}/task-grants`);
|
|
155
|
+
expect(r.body.task_access).toBe("scoped");
|
|
156
|
+
expect(r.body.restricted).toBe(true);
|
|
157
|
+
expect(r.body.project_grants).toEqual([{ project_id: P["ta-P1"], can_edit: false }, { project_id: P["ta-P2"], can_edit: true }].sort((x, y) => x.project_id.localeCompare(y.project_id)));
|
|
158
|
+
expect((await get(admin.token, `/api/networks/${NET}/members/${carol.id}/task-grants`)).body.task_access).toBe("all");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("/members 与 /auth/me 带上 task_access;owner 恒 all", async () => {
|
|
162
|
+
const m = await get(admin.token, `/api/networks/${NET}/members`);
|
|
163
|
+
const row = (id: string) => m.body.members.find((x: any) => x.user_id === id);
|
|
164
|
+
expect(row(alice.id).task_access).toBe("scoped");
|
|
165
|
+
expect(row(alice.id).task_project_count).toBe(2);
|
|
166
|
+
expect(row(carol.id).task_access).toBe("all");
|
|
167
|
+
expect(row(admin.id).task_access).toBe("all");
|
|
168
|
+
const me = await get(alice.token, "/api/auth/me");
|
|
169
|
+
expect(me.body.networks.find((n: any) => n.network_id === NET).task_access).toBe("scoped");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("授权接口:外网 / 不存在的项目 400 且整批不写;不传的字段保持原样;普通成员与节点令牌 403;审计", async () => {
|
|
173
|
+
const bad = await send(admin.token, "PUT", `/api/networks/${NET}/members/${alice.id}/task-grants`, { project_grants: [P["ta-P4"], "proj_does_not_exist"] });
|
|
174
|
+
expect(bad.status).toBe(400);
|
|
175
|
+
expect(bad.body.error).toBe("project_not_in_network");
|
|
176
|
+
expect((await get(admin.token, `/api/networks/${NET}/members/${alice.id}/task-grants`)).body.project_grants.length).toBe(2);
|
|
177
|
+
const keep = await send(admin.token, "PUT", `/api/networks/${NET}/members/${alice.id}/task-grants`, { task_access: "scoped" });
|
|
178
|
+
expect(keep.body.project_grants.length).toBe(2);
|
|
179
|
+
expect((await send(alice.token, "GET", `/api/networks/${NET}/members/${alice.id}/task-grants`)).status).toBe(403);
|
|
180
|
+
expect((await send(carol.token, "PUT", `/api/networks/${NET}/members/${alice.id}/task-grants`, { task_access: "all" })).status).toBe(403);
|
|
181
|
+
const nt = await send(admin.token, "POST", "/api/auth/node-token", { network_id: NET, node_name: "ta-node", node_id: "node_ta_1" });
|
|
182
|
+
expect((await get(nt.body.token, `/api/networks/${NET}/members/${alice.id}/task-grants`)).status).toBe(403);
|
|
183
|
+
expect(db.get("SELECT 1 FROM audit_log WHERE action = 'member_task_grants_changed' AND network_id = ?1", NET)).toBeTruthy();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("可见性", () => {
|
|
188
|
+
test("alice 只看见:负责 / 参与 / 授权项目的卡;不看见无关的与没授权项目的", async () => {
|
|
189
|
+
expect(await listIds(alice.token)).toEqual(["ta-own", "ta-p1", "ta-p2", "ta-part"]);
|
|
190
|
+
});
|
|
191
|
+
test("viewer_can:scoped 调用者每张卡带上能不能改 / 删;老成员与 owner 不带", async () => {
|
|
192
|
+
const r = await get(alice.token, `/api/requirements?network_id=${NET}`);
|
|
193
|
+
const by = (n: string) => (r.body.requirements as any[]).find(x => x.name === n)?.viewer_can;
|
|
194
|
+
expect(by("ta-own")).toEqual({ edit: true, delete: true });
|
|
195
|
+
expect(by("ta-part")).toEqual({ edit: false, delete: false });
|
|
196
|
+
expect(by("ta-p1")).toEqual({ edit: false, delete: false });
|
|
197
|
+
expect(by("ta-p2")).toEqual({ edit: true, delete: false });
|
|
198
|
+
expect((await get(vic.token, `/api/requirements/${C["ta-p3"].id}?network_id=${NET}`)).body.requirement.viewer_can).toEqual({ edit: false, delete: false });
|
|
199
|
+
const carolList = await get(carol.token, `/api/requirements?network_id=${NET}`);
|
|
200
|
+
expect((carolList.body.requirements as any[]).every(x => x.viewer_can === undefined)).toBe(true);
|
|
201
|
+
expect((await get(admin.token, `/api/requirements/${C["ta-own"].id}`)).body.requirement.viewer_can).toBeUndefined();
|
|
202
|
+
});
|
|
203
|
+
test("viewer vic 只看见授权项目 P3 的卡", async () => {
|
|
204
|
+
expect(await listIds(vic.token)).toEqual(["ta-p3"]);
|
|
205
|
+
});
|
|
206
|
+
test("老成员 carol 与 owner 照旧看全部", async () => {
|
|
207
|
+
const all = ["ta-other", "ta-own", "ta-p1", "ta-p2", "ta-p3", "ta-part"];
|
|
208
|
+
expect(await listIds(carol.token)).toEqual(all);
|
|
209
|
+
expect(await listIds(admin.token)).toEqual(all);
|
|
210
|
+
});
|
|
211
|
+
test("搜索也按可见性过滤", async () => {
|
|
212
|
+
expect(await listIds(alice.token, "&q=ta-other")).toEqual([]);
|
|
213
|
+
expect(await listIds(carol.token, "&q=ta-other")).toEqual(["ta-other"]);
|
|
214
|
+
});
|
|
215
|
+
test("看不见的卡:GET / #N 与不存在的卡逐字节相同", async () => {
|
|
216
|
+
const hidden = await get(alice.token, `/api/requirements/${C["ta-other"].id}?network_id=${NET}`);
|
|
217
|
+
const ghost = await get(alice.token, `/api/requirements/req_does_not_exist?network_id=${NET}`);
|
|
218
|
+
expect(hidden.status).toBe(404);
|
|
219
|
+
expect(hidden.text).toBe(ghost.text);
|
|
220
|
+
const bySeq = await get(alice.token, `/api/requirements/%23${C["ta-other"].seq}?network_id=${NET}`);
|
|
221
|
+
const noSeq = await get(alice.token, `/api/requirements/%2399999?network_id=${NET}`);
|
|
222
|
+
expect(bySeq.status).toBe(404);
|
|
223
|
+
expect(bySeq.text).toBe(noSeq.text);
|
|
224
|
+
expect((await get(carol.token, `/api/requirements/${C["ta-other"].id}?network_id=${NET}`)).status).toBe(200);
|
|
225
|
+
});
|
|
226
|
+
test("标签只从看得见的卡汇总", async () => {
|
|
227
|
+
expect((await get(alice.token, `/api/requirements/tags?network_id=${NET}`)).body.tags).not.toContain("secret-tag");
|
|
228
|
+
expect((await get(carol.token, `/api/requirements/tags?network_id=${NET}`)).body.tags).toContain("secret-tag");
|
|
229
|
+
});
|
|
230
|
+
test("项目列表:alice 只看授权的 P1 / P2", async () => {
|
|
231
|
+
const r = await get(alice.token, `/api/requirements/projects?network_id=${NET}`);
|
|
232
|
+
expect(r.body.projects.map((p: any) => p.name).sort()).toEqual(["ta-P1", "ta-P2"]);
|
|
233
|
+
expect((await get(carol.token, `/api/requirements/projects?network_id=${NET}`)).body.projects.length).toBe(4);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe("写入", () => {
|
|
238
|
+
test("能改:负责的、可改项目里的;只读:参与的、只看项目里的(403 task_read_only);看不见的与不存在的同一个 404", async () => {
|
|
239
|
+
expect((await send(alice.token, "PATCH", `/api/requirements/${C["ta-own"].id}?network_id=${NET}`, { column: "doing" })).status).toBe(200);
|
|
240
|
+
expect((await send(alice.token, "PATCH", `/api/requirements/${C["ta-p2"].id}?network_id=${NET}`, { column: "doing" })).status).toBe(200);
|
|
241
|
+
for (const n of ["ta-part", "ta-p1"]) {
|
|
242
|
+
const r = await send(alice.token, "PATCH", `/api/requirements/${C[n].id}?network_id=${NET}`, { column: "doing" });
|
|
243
|
+
expect(r.status).toBe(403);
|
|
244
|
+
expect(r.body.error).toBe("task_read_only");
|
|
245
|
+
}
|
|
246
|
+
const hidden = await send(alice.token, "PATCH", `/api/requirements/${C["ta-other"].id}?network_id=${NET}`, { column: "doing" });
|
|
247
|
+
const ghost = await send(alice.token, "PATCH", `/api/requirements/req_does_not_exist?network_id=${NET}`, { column: "doing" });
|
|
248
|
+
expect(hidden.status).toBe(404);
|
|
249
|
+
expect(hidden.text).toBe(ghost.text);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("勾子任务:负责的能勾,参与的 403,看不见的 404", async () => {
|
|
253
|
+
expect((await send(alice.token, "PATCH", `/api/requirements/${C["ta-own"].id}/checklist/i1?network_id=${NET}`, { done: true })).status).toBe(200);
|
|
254
|
+
const part = await send(alice.token, "PATCH", `/api/requirements/${C["ta-part"].id}/checklist/i1?network_id=${NET}`, { done: true });
|
|
255
|
+
expect(part.status).toBe(403);
|
|
256
|
+
expect(part.body.error).toBe("task_read_only");
|
|
257
|
+
expect((await send(alice.token, "PATCH", `/api/requirements/${C["ta-other"].id}/checklist/i1?network_id=${NET}`, { done: true })).status).toBe(404);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("挪项目:只能挪进 can_edit 的项目;没授权的与不存在的同一个错误", async () => {
|
|
261
|
+
const r = await send(alice.token, "PATCH", `/api/requirements/${C["ta-own"].id}?network_id=${NET}`, { project_id: P["ta-P1"] });
|
|
262
|
+
const ghost = await send(alice.token, "PATCH", `/api/requirements/${C["ta-own"].id}?network_id=${NET}`, { project_id: "proj_nope" });
|
|
263
|
+
expect(r.status).toBe(400);
|
|
264
|
+
expect(r.text).toBe(ghost.text);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("新建:能建;自建的卡看得见也能改;建进只看项目 / 看不见的父卡 / 别人的 client_id / external_ref 都被挡", async () => {
|
|
268
|
+
const mine = await send(alice.token, "POST", "/api/requirements", { network_id: NET, name: "ta-alice-own" });
|
|
269
|
+
expect(mine.status).toBe(201);
|
|
270
|
+
expect(await listIds(alice.token)).toContain("ta-alice-own");
|
|
271
|
+
expect((await send(alice.token, "PATCH", `/api/requirements/${mine.body.requirement.id}?network_id=${NET}`, { priority: "high" })).status).toBe(200);
|
|
272
|
+
expect((await send(alice.token, "POST", "/api/requirements", { network_id: NET, name: "ta-into-p2", project_id: P["ta-P2"] })).status).toBe(201);
|
|
273
|
+
const intoP1 = await send(alice.token, "POST", "/api/requirements", { network_id: NET, name: "ta-into-p1", project_id: P["ta-P1"] });
|
|
274
|
+
const intoNone = await send(alice.token, "POST", "/api/requirements", { network_id: NET, name: "ta-into-none", project_id: "proj_nope" });
|
|
275
|
+
expect(intoP1.status).toBe(400);
|
|
276
|
+
expect(intoP1.text).toBe(intoNone.text);
|
|
277
|
+
const hiddenParent = await send(alice.token, "POST", "/api/requirements", { network_id: NET, name: "ta-child", parent_id: C["ta-other"].id });
|
|
278
|
+
const ghostParent = await send(alice.token, "POST", "/api/requirements", { network_id: NET, name: "ta-child", parent_id: "req_does_not_exist" });
|
|
279
|
+
expect(hiddenParent.status).toBe(400);
|
|
280
|
+
expect(hiddenParent.text).toBe(ghostParent.text);
|
|
281
|
+
const replay = await send(alice.token, "POST", "/api/requirements", { network_id: NET, name: "ta-replay", client_id: "admin-client-1" });
|
|
282
|
+
expect(replay.status).toBe(409);
|
|
283
|
+
expect(replay.body.error).toBe("client_id_taken");
|
|
284
|
+
expect(replay.text).not.toContain("ta-other");
|
|
285
|
+
const ext = await send(alice.token, "POST", "/api/requirements", { network_id: NET, name: "ta-ext", external_ref: "github:o/r#1" });
|
|
286
|
+
expect(ext.status).toBe(403);
|
|
287
|
+
expect(ext.body.error).toBe("external_ref_not_allowed");
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("upsert 与项目管理:scoped 成员 403(固定错误,不看参数)", async () => {
|
|
291
|
+
const up = await send(alice.token, "POST", "/api/requirements/upsert", { network_id: NET, name: "x", external_ref: "github:o/r#2" });
|
|
292
|
+
expect(up.status).toBe(403);
|
|
293
|
+
expect(up.body.error).toBe("upsert_not_allowed");
|
|
294
|
+
expect((await send(alice.token, "POST", `/api/requirements/projects?network_id=${NET}`, { name: "ta-alice-proj" })).status).toBe(403);
|
|
295
|
+
expect((await send(alice.token, "PATCH", `/api/requirements/projects/${P["ta-P2"]}?network_id=${NET}`, { name: "renamed" })).status).toBe(403);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("删除:只能删负责 / 自建的;可改项目里的也不能删(403 task_delete_denied);删卡写审计", async () => {
|
|
299
|
+
const p2 = await send(alice.token, "DELETE", `/api/requirements/${C["ta-p2"].id}?network_id=${NET}`);
|
|
300
|
+
expect(p2.status).toBe(403);
|
|
301
|
+
expect(p2.body.error).toBe("task_delete_denied");
|
|
302
|
+
const hidden = await send(alice.token, "DELETE", `/api/requirements/${C["ta-other"].id}?network_id=${NET}`);
|
|
303
|
+
const ghost = await send(alice.token, "DELETE", `/api/requirements/req_does_not_exist?network_id=${NET}`);
|
|
304
|
+
expect(hidden.status).toBe(404);
|
|
305
|
+
expect(hidden.text).toBe(ghost.text);
|
|
306
|
+
expect((await send(alice.token, "DELETE", `/api/requirements/${C["ta-own"].id}?network_id=${NET}`)).status).toBe(200);
|
|
307
|
+
const audit = db.get<{ detail: string; network_id: string }>("SELECT detail, network_id FROM audit_log WHERE action = 'requirement_deleted' AND target_id = ?1", C["ta-own"].id);
|
|
308
|
+
expect(audit?.network_id).toBe(NET);
|
|
309
|
+
expect(audit?.detail).toContain("ta-own");
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("被拒的写入审计限频:同一 (用户, 卡) 连续两次只记一条", async () => {
|
|
313
|
+
await send(alice.token, "PATCH", `/api/requirements/${C["ta-p1"].id}?network_id=${NET}`, { name: "a" });
|
|
314
|
+
await send(alice.token, "PATCH", `/api/requirements/${C["ta-p1"].id}?network_id=${NET}`, { name: "b" });
|
|
315
|
+
const n = db.get<{ c: number }>("SELECT COUNT(*) AS c FROM audit_log WHERE action = 'task_access_denied' AND user_id = ?1 AND target_id = ?2", alice.id, C["ta-p1"].id)?.c;
|
|
316
|
+
expect(n).toBe(1);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("viewer 什么都不能写(沿用 permission_denied)", async () => {
|
|
320
|
+
expect((await send(vic.token, "PATCH", `/api/requirements/${C["ta-p3"].id}?network_id=${NET}`, { column: "doing" })).status).toBe(403);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("老成员 carol 照旧:能改、能删别人的卡、能管项目、能 upsert", async () => {
|
|
324
|
+
expect((await send(carol.token, "PATCH", `/api/requirements/${C["ta-other"].id}?network_id=${NET}`, { column: "doing" })).status).toBe(200);
|
|
325
|
+
expect((await send(carol.token, "POST", `/api/requirements/projects?network_id=${NET}`, { name: "ta-carol-proj" })).status).toBe(201);
|
|
326
|
+
expect((await send(carol.token, "POST", "/api/requirements/upsert", { network_id: NET, name: "ta-carol-up", external_ref: "github:o/r#3" })).status).toBe(201);
|
|
327
|
+
expect((await send(carol.token, "DELETE", `/api/requirements/${C["ta-p3"].id}?network_id=${NET}`)).status).toBe(200);
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
describe("MCP 与 REST 一致", () => {
|
|
332
|
+
test("requirements_list / requirements_get 对 alice 同样过滤", async () => {
|
|
333
|
+
const list = await mcp(alice.token, "requirements_list", { network_id: NET });
|
|
334
|
+
const names = (list.requirements as any[]).map(x => x.name).filter((n: string) => n.startsWith("ta-")).sort();
|
|
335
|
+
expect(names).toEqual(await listIds(alice.token));
|
|
336
|
+
const hidden = await mcp(alice.token, "requirements_get", { id: C["ta-other"].id, network_id: NET });
|
|
337
|
+
expect(hidden.ok).toBe(false);
|
|
338
|
+
expect(hidden.error).toBe("requirement_not_found");
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
describe("LIKE 通配符不越权(参与人 / 负责人 id 里有 % 或 _)", () => {
|
|
343
|
+
// 可见性子句用 LIKE … ESCAPE 匹配 `"kind":"user","id":"<uid>"`;id 里的 % / _ 必须按字面匹配。
|
|
344
|
+
// 造两个用户:pct 的 id 带 % 与 _,twin 的 id 在「不转义」时恰好能被 pct 的模式匹配上。
|
|
345
|
+
const PCT = "u_ta%pct_1";
|
|
346
|
+
const TWIN = "u_taXpctA1";
|
|
347
|
+
let pctToken = "";
|
|
348
|
+
beforeAll(async () => {
|
|
349
|
+
for (const [id, name] of [[PCT, "ta_pct"], [TWIN, "ta_twin"]] as const) {
|
|
350
|
+
db.run("INSERT INTO users (user_id, username, password_hash, display_name, role) VALUES (?1, ?2, 'x', ?2, 'user')", [id, `${name}_${Date.now()}`]);
|
|
351
|
+
db.run("INSERT INTO network_members (network_id, user_id, role, agent_access, task_access) VALUES (?1, ?2, 'member', 'granted', 'scoped')", [NET, id]);
|
|
352
|
+
}
|
|
353
|
+
pctToken = issueUserToken(PCT).token;
|
|
354
|
+
for (const [name, ref] of [["ta-twin-owned", TWIN], ["ta-pct-owned", PCT]] as const) {
|
|
355
|
+
const r = await send(admin.token, "POST", "/api/requirements", { network_id: NET, name, owner: userRef(ref), participants: [userRef(ref)] });
|
|
356
|
+
expect(r.status).toBe(201);
|
|
357
|
+
C[name] = { id: r.body.requirement.id, seq: r.body.requirement.seq };
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
test("id 带 % / _ 的成员只看见自己的卡,看不见被通配符「撞上」的那张", async () => {
|
|
361
|
+
const mine = await listIds(pctToken);
|
|
362
|
+
expect(mine).toContain("ta-pct-owned");
|
|
363
|
+
expect(mine).not.toContain("ta-twin-owned");
|
|
364
|
+
expect((await get(pctToken, `/api/requirements/${C["ta-twin-owned"].id}?network_id=${NET}`)).status).toBe(404);
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
describe("清理", () => {
|
|
369
|
+
test("删项目清掉项目授权;移出成员清掉他的全部授权", async () => {
|
|
370
|
+
expect((await send(admin.token, "DELETE", `/api/requirements/projects/${P["ta-P1"]}?network_id=${NET}`)).status).toBe(200);
|
|
371
|
+
expect(db.get("SELECT 1 FROM network_member_project_grants WHERE project_id = ?1", P["ta-P1"])).toBeFalsy();
|
|
372
|
+
expect((await send(admin.token, "DELETE", `/api/networks/${NET}/members/${vic.id}`)).status).toBe(200);
|
|
373
|
+
expect(db.get("SELECT 1 FROM network_member_project_grants WHERE user_id = ?1", vic.id)).toBeFalsy();
|
|
374
|
+
});
|
|
375
|
+
});
|