@sleep2agi/commhub-server 0.9.0-preview.13 → 0.9.0-preview.130
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/README.md +130 -10
- package/bin/commhub.ts +99 -11
- package/package.json +7 -7
- package/src/ack-create-request-transport.test.ts +182 -0
- package/src/ack-create-request.test.ts +50 -7
- package/src/admin-delete-network-http.test.ts +228 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/adoption-candidates-http.test.ts +137 -0
- package/src/adoption-candidates.ts +158 -0
- package/src/agent-access-batch.test.ts +210 -0
- package/src/agent-access.ts +487 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +659 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +223 -0
- package/src/agent-notice.ts +48 -0
- 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/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +42 -0
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/api-host-supervisors-fallback.test.ts +271 -0
- package/src/api-host-supervisors-runtime-readiness.test.ts +195 -0
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/auth-password-change-cli-tokens-http.test.ts +294 -0
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth-weak-password-login.test.ts +176 -0
- package/src/auth.ts +598 -49
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/binding-generation-http.test.ts +121 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/clone-node.test.ts +248 -0
- package/src/clone-node.ts +289 -0
- package/src/codex-config-flags.ts +77 -0
- package/src/codex-fork-contract.ts +23 -0
- package/src/codex-fork-migration.test.ts +38 -0
- package/src/commhub-bin-args.test.ts +85 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +134 -1
- package/src/config-apply-validate.ts +111 -5
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-network-id.test.ts +79 -0
- package/src/create-node-tool-schema.test.ts +48 -0
- package/src/create-node-unicode-name.test.ts +120 -0
- package/src/create-node-validate.test.ts +193 -7
- package/src/create-node-validate.ts +227 -15
- package/src/create-node-workdir.test.ts +289 -0
- package/src/create-node.ts +77 -23
- package/src/daemon-provider-snapshots.test.ts +421 -0
- package/src/daemon-provider-snapshots.ts +327 -0
- package/src/daemon-token-owner-http.test.ts +236 -0
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +148 -0
- package/src/db-adapter-translate.test.ts +128 -0
- package/src/db-adapter.ts +323 -87
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +930 -35
- package/src/delivered-without-push.test.ts +140 -0
- package/src/department-groups-http.test.ts +209 -0
- package/src/department-groups-sync-http.test.ts +328 -0
- package/src/department-groups.ts +434 -0
- package/src/department-heads-http.test.ts +330 -0
- package/src/department-heads.ts +226 -0
- package/src/departments-http.test.ts +185 -0
- package/src/departments.ts +260 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -0
- package/src/dm-file-scope-http.test.ts +178 -0
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +321 -0
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +327 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +404 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +732 -0
- package/src/group-membership-events-http.test.ts +264 -0
- package/src/group-messages-http.test.ts +482 -0
- package/src/group-messages.ts +297 -0
- package/src/health-redaction.test.ts +642 -0
- package/src/http-gzip.test.ts +94 -0
- package/src/http-gzip.ts +112 -0
- package/src/hub-provider-config.test.ts +139 -0
- package/src/hub-provider-config.ts +105 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +215 -0
- package/src/inbox-caller.ts +49 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2362
- package/src/lifecycle-guard.ts +1 -1
- package/src/mcp-context-cost-http.test.ts +123 -0
- package/src/mcp-projects-events-http.test.ts +162 -0
- package/src/mcp-route-label-http.test.ts +120 -0
- package/src/mcp-write-network-resolution.test.ts +432 -0
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/member-presence-http.test.ts +210 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/migrate-sqlite-to-pg.test.ts +47 -0
- package/src/migrate-sqlite-to-pg.ts +263 -0
- package/src/model-auth-notify-http.test.ts +181 -0
- package/src/model-auth-notify.ts +116 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +276 -0
- package/src/network-token-membership-http.test.ts +139 -0
- package/src/node-alias-control.ts +12 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-caller-identity-http.test.ts +781 -0
- package/src/node-create-request-status-http.test.ts +105 -0
- package/src/node-daemon-bindings.test.ts +132 -0
- package/src/node-daemon-bindings.ts +130 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-health-codex-login.test.ts +120 -0
- package/src/node-health-dispatch-http.test.ts +279 -0
- package/src/node-health-guard.ts +105 -0
- package/src/node-health-passthrough.test.ts +115 -0
- package/src/node-health-store.ts +101 -0
- package/src/node-id-alias.test.ts +190 -0
- package/src/node-identity-conflict-http.test.ts +269 -0
- package/src/node-identity-conflict.ts +142 -0
- package/src/node-identity-mismatch.test.ts +74 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +53 -0
- package/src/node-lifecycle-read-http.test.ts +245 -0
- package/src/node-lifecycle-read.ts +121 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-permissions-http.test.ts +276 -0
- package/src/node-permissions.ts +285 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +120 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-ownership.ts +51 -0
- package/src/node-token-privilege-http.test.ts +158 -0
- package/src/observer-avatar-http.test.ts +431 -0
- package/src/observer-push.test.ts +252 -0
- package/src/opencode-v2-catalog.test.ts +268 -0
- package/src/opencode-v2-catalog.ts +352 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/pg-worker.ts +87 -0
- package/src/project-dir-warn-clear-http.test.ts +132 -0
- package/src/project-viewer-can-http.test.ts +114 -0
- package/src/push.ts +429 -10
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/rename.ts +22 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-handover-capabilities-http.test.ts +124 -0
- package/src/report-status-host-schema.test.ts +235 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/requirement-due-reminders-http.test.ts +620 -0
- package/src/requirement-due-reminders.ts +416 -0
- package/src/requirement-events-http.test.ts +268 -0
- package/src/requirement-events.test.ts +45 -0
- package/src/requirement-events.ts +144 -0
- package/src/requirement-notify.ts +165 -0
- package/src/requirement-participant-notify-http.test.ts +309 -0
- package/src/requirement-tag-ops-http.test.ts +266 -0
- package/src/requirement-tags.ts +89 -0
- package/src/requirements-abandoned-http.test.ts +236 -0
- package/src/requirements-comment-http.test.ts +167 -0
- package/src/requirements-completed-at-migration.test.ts +70 -0
- package/src/requirements-errors-mcp-http.test.ts +164 -0
- package/src/requirements-errors.ts +143 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +709 -0
- package/src/requirements-last-event-http.test.ts +349 -0
- package/src/requirements-list-mcp-slim-http.test.ts +155 -0
- package/src/requirements-list-slim-http.test.ts +319 -0
- package/src/requirements-mcp.test.ts +163 -0
- package/src/requirements-migrate.ts +259 -0
- package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
- package/src/requirements-people-http.test.ts +114 -0
- package/src/requirements-people-mcp-http.test.ts +225 -0
- package/src/requirements-people.ts +127 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-search-http.test.ts +182 -0
- package/src/requirements-search.test.ts +32 -0
- package/src/requirements-search.ts +145 -0
- package/src/requirements-seq-http.test.ts +193 -0
- package/src/requirements-seq-migration.test.ts +63 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements-stats-http.test.ts +288 -0
- package/src/requirements-stats.ts +167 -0
- package/src/requirements-status-participants-http.test.ts +222 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +98 -0
- package/src/requirements.ts +1806 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +222 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +125 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +76 -0
- package/src/retention.ts +15 -3
- package/src/route-callers-http.test.ts +120 -0
- package/src/route-timing.test.ts +211 -0
- package/src/route-timing.ts +258 -0
- package/src/rules-file-duplicate-alias.test.ts +234 -0
- package/src/rules-file-session-target.test.ts +207 -0
- package/src/rules-file-transport.test.ts +237 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/runtime-readiness.ts +90 -0
- package/src/schedule-agent-mcp-http.test.ts +381 -0
- package/src/schedule-agent.ts +192 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
- package/src/scheduled-failures-http.test.ts +233 -0
- package/src/scheduled-failures.ts +179 -0
- package/src/scheduled-offline.test.ts +131 -0
- package/src/scheduled-offline.ts +66 -0
- package/src/scheduled-reply-unread-http.test.ts +212 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-stuck-http.test.ts +238 -0
- package/src/scheduled-stuck.ts +153 -0
- package/src/scheduled-tasks-http.test.ts +638 -0
- package/src/scheduled-tasks.ts +766 -0
- package/src/scoped-secrets.ts +124 -0
- package/src/send-dedup-request-id.test.ts +229 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +458 -0
- package/src/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +5257 -0
- package/src/session-task-on-dispatch.ts +14 -0
- package/src/session-token-ownership-http.test.ts +122 -0
- package/src/shared/codex-inventory.ts +41 -0
- package/src/shared/daemon-provider-login.ts +31 -0
- package/src/shared/daemon-provider-snapshot.ts +52 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/shared/node-name.test.ts +62 -0
- package/src/shared/node-name.ts +161 -0
- package/src/shared/provider-application.ts +29 -0
- package/src/shared/provider-collection.ts +14 -0
- package/src/shared/provider-initialization.ts +8 -0
- package/src/shared/reserved-env-drift.test.ts +10 -0
- package/src/shared/scoped-secret-contract.ts +5 -0
- package/src/shared/task-content-limit-drift.test.ts +28 -0
- package/src/shared/task-content-limit.ts +25 -0
- package/src/side-thread-command-transport.test.ts +312 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +233 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2499 -0
- package/src/skillhub-http.test.ts +108 -0
- package/src/sqlite-close-checkpoint.test.ts +78 -0
- package/src/start-node.test.ts +264 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/status-alias-resolver-http.test.ts +163 -0
- package/src/status-alias-resolver.ts +19 -0
- package/src/status-node-id-filter-http.test.ts +161 -0
- package/src/status-read-cache-http.test.ts +229 -0
- package/src/status-read-cache.ts +122 -0
- package/src/stop-delete-node.test.ts +509 -1
- package/src/task-access-http.test.ts +402 -0
- package/src/task-access.ts +420 -0
- package/src/task-auth-origin-http.test.ts +91 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +482 -0
- package/src/task-diagnostic-http.test.ts +149 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-expiry-notice-http.test.ts +287 -0
- package/src/task-expiry-notice.ts +315 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-late-replies.ts +54 -0
- package/src/task-late-reply-http.test.ts +152 -0
- package/src/task-lifecycle-watcher.test.ts +204 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +141 -0
- package/src/task-orphan-http.test.ts +287 -0
- package/src/task-orphan.ts +119 -0
- package/src/task-queue-ahead-http.test.ts +309 -0
- package/src/task-queue-ahead-scale.test.ts +236 -0
- package/src/task-queue-ahead.ts +186 -0
- package/src/task-rejection-log-http.test.ts +160 -0
- package/src/task-rejection-log.ts +49 -0
- package/src/task-stale-open-http.test.ts +290 -0
- package/src/task-stale-open.ts +103 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tool-audience-http.test.ts +220 -0
- package/src/tool-audience.ts +118 -0
- package/src/tools-list-json-schema.test.ts +67 -0
- package/src/tools.ts +3927 -323
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.ts +103 -6
- package/src/user-avatar-http.test.ts +74 -0
- package/src/user-avatar-migration.test.ts +25 -0
- package/src/user-inbox-by-agent-http.test.ts +161 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +223 -0
- package/src/user-inbox-schema.test.ts +54 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
// RFC-042(看板 #457)部门群,第二个 PR:成员同步 + 手动成员 + 改群名。
|
|
2
|
+
// HTTP 集成测试(真实 Bun.serve,私有端口,临时库;PostgreSQL 由 test2123 梯子用 COMMHUB_TEST_PG_URL 跑同一个文件)。
|
|
3
|
+
// 钉住 §4 的每个触发点:调进 / 调出 / 子树内调动、建带负责人的子部门、改上级、换负责人、删部门、移出网络、读时兜底对账;
|
|
4
|
+
// manual 行永远不被同步动(手动拉的人进部门后仍是 manual,离开部门也留在群里);手动移部门来源的人 → 409;
|
|
5
|
+
// 谁能管群(owner / admin / 该部门(含上级)负责人),看得见管不了 → 403,看不见 → 404,Agent → 403 humans_only。
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
8
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
import { tmpdir } from "os";
|
|
11
|
+
import { register, createNetworkTokenForNode } from "./auth.js";
|
|
12
|
+
import { db } from "./db.js";
|
|
13
|
+
|
|
14
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-dept-groups-sync-"));
|
|
15
|
+
let BASE = "";
|
|
16
|
+
let hub: any = null;
|
|
17
|
+
const PW = "DeptGroupsSyncPassw0rd!x";
|
|
18
|
+
let NET = "";
|
|
19
|
+
const U: Record<string, { token: string; id: string }> = {};
|
|
20
|
+
const DEPT: Record<string, string> = {};
|
|
21
|
+
const GRP: Record<string, string> = {};
|
|
22
|
+
|
|
23
|
+
async function send(token: string, method: string, path: string, body?: unknown) {
|
|
24
|
+
const res = await fetch(`${BASE}${path}`, { method, headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
|
|
25
|
+
const text = await res.text();
|
|
26
|
+
let json: any = null;
|
|
27
|
+
try { json = JSON.parse(text); } catch {}
|
|
28
|
+
return { status: res.status, body: json };
|
|
29
|
+
}
|
|
30
|
+
const D = (suffix = "") => `/api/networks/${NET}/departments${suffix}`;
|
|
31
|
+
const CG = (suffix = "") => `/api/networks/${NET}/chat-groups${suffix}`;
|
|
32
|
+
const place = async (who: string, dept: string | null) =>
|
|
33
|
+
expect((await send(U.owner.token, "PUT", `/api/networks/${NET}/members/${U[who].id}/department`, { department_id: dept })).status).toBe(200);
|
|
34
|
+
const patchDept = async (key: string, body: Record<string, unknown>) =>
|
|
35
|
+
expect((await send(U.owner.token, "PATCH", D(`/${DEPT[key]}`), body)).status).toBe(200);
|
|
36
|
+
/** 直接读库(不经过 GET,GET 会顺手对账,会把要测的「写操作时同步」掩盖掉)。user_id → source。 */
|
|
37
|
+
function rows(groupKey: string): Record<string, string> {
|
|
38
|
+
const out: Record<string, string> = {};
|
|
39
|
+
for (const r of db.all<{ user_id: string; source: string }>("SELECT user_id, source FROM chat_group_members WHERE group_id = ?1", GRP[groupKey])) out[r.user_id] = r.source;
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
const who = (groupKey: string) => Object.keys(rows(groupKey)).map((id) => Object.keys(U).find((k) => U[k].id === id) ?? id).sort();
|
|
43
|
+
|
|
44
|
+
beforeAll(async () => {
|
|
45
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
46
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
47
|
+
process.env.HOST = "127.0.0.1";
|
|
48
|
+
const a = register(`dgs_owner_${Date.now()}`, PW, undefined, "Owner");
|
|
49
|
+
expect(a.ok).toBe(true);
|
|
50
|
+
U.owner = { token: a.token!, id: a.user!.user_id };
|
|
51
|
+
NET = a.network_id!;
|
|
52
|
+
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [U.owner.id]);
|
|
53
|
+
const mod: any = await import("./server.js");
|
|
54
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
55
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
56
|
+
const add = async (username: string, role: string) => {
|
|
57
|
+
expect((await send(U.owner.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role })).status).toBe(200);
|
|
58
|
+
const login = await send("", "POST", "/api/auth/login", { username, password: PW });
|
|
59
|
+
expect(login.status).toBe(200);
|
|
60
|
+
return { token: login.body.token as string, id: login.body.user.user_id as string };
|
|
61
|
+
};
|
|
62
|
+
const stamp = Date.now();
|
|
63
|
+
U.admin = await add(`dgs_admin_${stamp}`, "admin");
|
|
64
|
+
U.head = await add(`dgs_head_${stamp}`, "member"); // 研发部负责人,研发部成员
|
|
65
|
+
U.alice = await add(`dgs_alice_${stamp}`, "member"); // 研发部成员
|
|
66
|
+
U.bob = await add(`dgs_bob_${stamp}`, "member"); // 后端组成员 + 后端组负责人
|
|
67
|
+
U.carol = await add(`dgs_carol_${stamp}`, "member"); // 未分配,拿来调来调去
|
|
68
|
+
U.dave = await add(`dgs_dave_${stamp}`, "member"); // 市场部成员
|
|
69
|
+
U.erin = await add(`dgs_erin_${stamp}`, "member"); // 未分配,用来测「移出网络」
|
|
70
|
+
U.viewer = await add(`dgs_viewer_${stamp}`, "viewer"); // 研发部成员,只读
|
|
71
|
+
U.node = { token: createNetworkTokenForNode(U.owner.id, NET, "dgs-node", "node_dgs").token!, id: "node_dgs" };
|
|
72
|
+
const o = register(`dgs_outsider_${stamp}`, PW);
|
|
73
|
+
U.outsider = { token: o.token!, id: o.user!.user_id };
|
|
74
|
+
|
|
75
|
+
const mk = async (name: string, extra: Record<string, unknown> = {}) => {
|
|
76
|
+
const r = await send(U.owner.token, "POST", D(), { name, ...extra });
|
|
77
|
+
expect(r.status).toBe(201);
|
|
78
|
+
return r.body.department.id as string;
|
|
79
|
+
};
|
|
80
|
+
DEPT.rd = await mk("研发部", { leader_user_id: U.head.id });
|
|
81
|
+
DEPT.backend = await mk("后端组", { parent_id: DEPT.rd, leader_user_id: U.bob.id });
|
|
82
|
+
DEPT.market = await mk("市场部");
|
|
83
|
+
await place("head", DEPT.rd);
|
|
84
|
+
await place("alice", DEPT.rd);
|
|
85
|
+
await place("viewer", DEPT.rd);
|
|
86
|
+
await place("bob", DEPT.backend);
|
|
87
|
+
await place("dave", DEPT.market);
|
|
88
|
+
for (const key of ["rd", "backend", "market"]) {
|
|
89
|
+
const r = await send(U.owner.token, "POST", D(`/${DEPT[key]}/group`), {});
|
|
90
|
+
expect(r.status).toBe(201);
|
|
91
|
+
GRP[key] = r.body.group.id;
|
|
92
|
+
}
|
|
93
|
+
}, 30_000);
|
|
94
|
+
|
|
95
|
+
afterAll(() => {
|
|
96
|
+
try { hub?.stop?.(true); } catch {}
|
|
97
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("seed", () => {
|
|
101
|
+
test("groups start from the roster", () => {
|
|
102
|
+
expect(who("rd")).toEqual(["alice", "bob", "head", "viewer"]);
|
|
103
|
+
expect(who("backend")).toEqual(["bob"]);
|
|
104
|
+
expect(who("market")).toEqual(["dave"]);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("sync on moving a member (PUT …/members/:uid/department)", () => {
|
|
109
|
+
test("move in: joins the department group and every ancestor department group, source='department'", async () => {
|
|
110
|
+
await place("carol", DEPT.backend);
|
|
111
|
+
expect(rows("backend")[U.carol.id]).toBe("department");
|
|
112
|
+
expect(rows("rd")[U.carol.id]).toBe("department");
|
|
113
|
+
expect(rows("market")[U.carol.id]).toBeUndefined();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("move within the subtree: leaves the sub-department group, stays in the parent group", async () => {
|
|
117
|
+
await place("carol", DEPT.rd);
|
|
118
|
+
expect(rows("backend")[U.carol.id]).toBeUndefined();
|
|
119
|
+
expect(rows("rd")[U.carol.id]).toBe("department");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("move across: leaves 研发部, joins 市场部", async () => {
|
|
123
|
+
await place("carol", DEPT.market);
|
|
124
|
+
expect(rows("rd")[U.carol.id]).toBeUndefined();
|
|
125
|
+
expect(rows("market")[U.carol.id]).toBe("department");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("move out (unassigned): removed from the group", async () => {
|
|
129
|
+
await place("carol", null);
|
|
130
|
+
expect(rows("market")[U.carol.id]).toBeUndefined();
|
|
131
|
+
expect(who("market")).toEqual(["dave"]);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("manual members are never touched by sync", () => {
|
|
136
|
+
test("a manual member who later joins the department keeps one row, still 'manual'; leaving the department keeps them in", async () => {
|
|
137
|
+
const add = await send(U.owner.token, "POST", CG(`/${GRP.market}/members`), { user_id: U.carol.id });
|
|
138
|
+
expect(add.status).toBe(201);
|
|
139
|
+
expect(add.body.member).toMatchObject({ user_id: U.carol.id, source: "manual" });
|
|
140
|
+
await place("carol", DEPT.market);
|
|
141
|
+
expect(rows("market")[U.carol.id]).toBe("manual");
|
|
142
|
+
expect(Number(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM chat_group_members WHERE group_id = ?1 AND user_id = ?2", GRP.market, U.carol.id)?.n)).toBe(1);
|
|
143
|
+
await place("carol", null);
|
|
144
|
+
expect(rows("market")[U.carol.id]).toBe("manual");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("manual remove works for a manual row; a department-sourced member → 409 department_member and stays", async () => {
|
|
148
|
+
const dep = await send(U.owner.token, "DELETE", CG(`/${GRP.market}/members/${U.dave.id}`));
|
|
149
|
+
expect(dep.status).toBe(409);
|
|
150
|
+
expect(dep.body.error).toBe("department_member");
|
|
151
|
+
expect(rows("market")[U.dave.id]).toBe("department");
|
|
152
|
+
const man = await send(U.owner.token, "DELETE", CG(`/${GRP.market}/members/${U.carol.id}`));
|
|
153
|
+
expect(man.status).toBe(200);
|
|
154
|
+
expect(rows("market")[U.carol.id]).toBeUndefined();
|
|
155
|
+
expect((await send(U.owner.token, "DELETE", CG(`/${GRP.market}/members/${U.carol.id}`))).status).toBe(404);
|
|
156
|
+
const audit = db.get<{ detail: string }>("SELECT detail FROM audit_log WHERE network_id = ?1 AND action = 'chat_group_member_removed' ORDER BY id DESC LIMIT 1", NET);
|
|
157
|
+
expect(JSON.parse(audit!.detail)).toMatchObject({ group_id: GRP.market, user_id: U.carol.id });
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("sync on department changes", () => {
|
|
162
|
+
test("re-parent (PATCH parent_id): the moved subtree's people leave the old ancestor group and join the new one", async () => {
|
|
163
|
+
await patchDept("backend", { parent_id: DEPT.market });
|
|
164
|
+
expect(rows("rd")[U.bob.id]).toBeUndefined();
|
|
165
|
+
expect(rows("market")[U.bob.id]).toBe("department");
|
|
166
|
+
expect(who("backend")).toEqual(["bob"]);
|
|
167
|
+
await patchDept("backend", { parent_id: DEPT.rd });
|
|
168
|
+
expect(rows("rd")[U.bob.id]).toBe("department");
|
|
169
|
+
expect(rows("market")[U.bob.id]).toBeUndefined();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("set / change / clear the head: the head joins and leaves with the leadership", async () => {
|
|
173
|
+
await patchDept("market", { leader_user_id: U.carol.id }); // carol 未分配,只当负责人
|
|
174
|
+
expect(rows("market")[U.carol.id]).toBe("department");
|
|
175
|
+
await patchDept("market", { leader_user_id: U.erin.id });
|
|
176
|
+
expect(rows("market")[U.carol.id]).toBeUndefined();
|
|
177
|
+
expect(rows("market")[U.erin.id]).toBe("department");
|
|
178
|
+
await patchDept("market", { leader_user_id: null });
|
|
179
|
+
expect(rows("market")[U.erin.id]).toBeUndefined();
|
|
180
|
+
// 换的负责人本来就是部门成员 → 还在群里(按成员身份)。
|
|
181
|
+
await patchDept("rd", { leader_user_id: U.alice.id });
|
|
182
|
+
expect(rows("rd")[U.head.id]).toBe("department");
|
|
183
|
+
expect(rows("rd")[U.alice.id]).toBe("department");
|
|
184
|
+
await patchDept("rd", { leader_user_id: U.head.id });
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("renaming / re-sorting a department does not touch membership", async () => {
|
|
188
|
+
const before = rows("rd");
|
|
189
|
+
await patchDept("rd", { name: "研发中心", sort: 3 });
|
|
190
|
+
expect(rows("rd")).toEqual(before);
|
|
191
|
+
await patchDept("rd", { name: "研发部" });
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("creating a sub-department with a head adds that head to the ancestor groups; deleting it removes them", async () => {
|
|
195
|
+
const r = await send(U.owner.token, "POST", D(), { name: "前端组", parent_id: DEPT.rd, leader_user_id: U.carol.id });
|
|
196
|
+
expect(r.status).toBe(201);
|
|
197
|
+
DEPT.fe = r.body.department.id;
|
|
198
|
+
expect(rows("rd")[U.carol.id]).toBe("department");
|
|
199
|
+
expect(rows("backend")[U.carol.id]).toBeUndefined();
|
|
200
|
+
expect((await send(U.owner.token, "DELETE", D(`/${DEPT.fe}`))).status).toBe(200);
|
|
201
|
+
expect(rows("rd")[U.carol.id]).toBeUndefined();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("an unlinked group (department deleted) is no longer synced", async () => {
|
|
205
|
+
const tmp = (await send(U.owner.token, "POST", D(), { name: "临时组", leader_user_id: U.erin.id })).body.department.id as string;
|
|
206
|
+
const g = await send(U.owner.token, "POST", D(`/${tmp}/group`), {});
|
|
207
|
+
expect(g.status).toBe(201);
|
|
208
|
+
GRP.tmp = g.body.group.id;
|
|
209
|
+
expect((await send(U.owner.token, "DELETE", D(`/${tmp}`))).status).toBe(200);
|
|
210
|
+
expect(who("tmp")).toEqual(["erin"]);
|
|
211
|
+
// 读也不对账(部门已不存在,roster 为空,对账会把 erin 踢掉)。
|
|
212
|
+
expect((await send(U.erin.token, "GET", CG(`/${GRP.tmp}`))).status).toBe(200);
|
|
213
|
+
expect(who("tmp")).toEqual(["erin"]);
|
|
214
|
+
// 解除关联的群里,原部门来源的行可以手动移掉(不会再被加回来)。
|
|
215
|
+
expect((await send(U.owner.token, "DELETE", CG(`/${GRP.tmp}/members/${U.erin.id}`))).status).toBe(200);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe("removing someone from the network", () => {
|
|
220
|
+
test("drops every group row they had in this network, whatever the source", async () => {
|
|
221
|
+
await place("erin", DEPT.backend);
|
|
222
|
+
expect((await send(U.owner.token, "POST", CG(`/${GRP.market}/members`), { user_id: U.erin.id })).status).toBe(201);
|
|
223
|
+
await patchDept("market", { leader_user_id: U.erin.id });
|
|
224
|
+
expect(rows("backend")[U.erin.id]).toBe("department");
|
|
225
|
+
expect(rows("rd")[U.erin.id]).toBe("department");
|
|
226
|
+
expect(rows("market")[U.erin.id]).toBe("manual");
|
|
227
|
+
expect((await send(U.owner.token, "DELETE", `/api/networks/${NET}/members/${U.erin.id}`)).status).toBe(200);
|
|
228
|
+
expect(Number(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM chat_group_members WHERE network_id = ?1 AND user_id = ?2", NET, U.erin.id)?.n)).toBe(0);
|
|
229
|
+
// 她仍挂着市场部负责人(leader_user_id 没清),但已不是网络成员:读时对账也不会把她加回来。
|
|
230
|
+
expect((await send(U.owner.token, "GET", CG(`/${GRP.market}`))).status).toBe(200);
|
|
231
|
+
expect(rows("market")[U.erin.id]).toBeUndefined();
|
|
232
|
+
await patchDept("market", { leader_user_id: null });
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe("read-time reconcile (safety net for drift)", () => {
|
|
237
|
+
test("GET …/chat-groups/:gid and GET …/departments/:id/group put missing roster rows back and drop stale department rows", async () => {
|
|
238
|
+
db.run("DELETE FROM chat_group_members WHERE group_id = ?1 AND user_id = ?2", [GRP.rd, U.alice.id]);
|
|
239
|
+
db.run("INSERT INTO chat_group_members (group_id, user_id, network_id, source) VALUES (?1, ?2, ?3, 'department')", [GRP.rd, U.dave.id, NET]);
|
|
240
|
+
const r = await send(U.owner.token, "GET", CG(`/${GRP.rd}`));
|
|
241
|
+
expect(r.status).toBe(200);
|
|
242
|
+
expect(rows("rd")[U.alice.id]).toBe("department");
|
|
243
|
+
expect(rows("rd")[U.dave.id]).toBeUndefined();
|
|
244
|
+
// 漂移掉的人自己读:先对账再判成员,所以 alice 读得到。
|
|
245
|
+
db.run("DELETE FROM chat_group_members WHERE group_id = ?1 AND user_id = ?2", [GRP.rd, U.alice.id]);
|
|
246
|
+
expect((await send(U.alice.token, "GET", D(`/${DEPT.rd}/group`))).status).toBe(200);
|
|
247
|
+
expect(rows("rd")[U.alice.id]).toBe("department");
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe("rename + manual members: permissions", () => {
|
|
252
|
+
test("owner, admin and the department's head (incl. an ancestor head) can rename; bad names → 400", async () => {
|
|
253
|
+
expect((await send(U.owner.token, "PATCH", CG(`/${GRP.rd}`), { name: " 研发群 " })).body.group.name).toBe("研发群");
|
|
254
|
+
expect((await send(U.admin.token, "PATCH", CG(`/${GRP.rd}`), { name: "研发部" })).status).toBe(200);
|
|
255
|
+
const h = await send(U.head.token, "PATCH", CG(`/${GRP.backend}`), { name: "后端群" });
|
|
256
|
+
expect(h.status).toBe(200);
|
|
257
|
+
expect(h.body.group.name).toBe("后端群");
|
|
258
|
+
const audit = db.get<{ detail: string }>("SELECT detail FROM audit_log WHERE network_id = ?1 AND action = 'chat_group_renamed' ORDER BY id DESC LIMIT 1", NET);
|
|
259
|
+
expect(JSON.parse(audit!.detail)).toMatchObject({ group_id: GRP.backend, via: "leader" });
|
|
260
|
+
for (const name of [42, "", " ", "x".repeat(41), undefined]) {
|
|
261
|
+
const r = await send(U.owner.token, "PATCH", CG(`/${GRP.rd}`), name === undefined ? {} : { name });
|
|
262
|
+
expect(r.status).toBe(400);
|
|
263
|
+
expect(r.body.error).toBe("invalid_group_name");
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("a member who can see but not manage → 403 group_manage_denied; a non-member → 404; nodes → 403 humans_only", async () => {
|
|
268
|
+
// bob 管后端组(下级),管不了研发部的群,但他是研发部群的成员。
|
|
269
|
+
for (const [method, path, body] of [
|
|
270
|
+
["PATCH", CG(`/${GRP.rd}`), { name: "x" }],
|
|
271
|
+
["POST", CG(`/${GRP.rd}/members`), { user_id: U.carol.id }],
|
|
272
|
+
["DELETE", CG(`/${GRP.rd}/members/${U.alice.id}`), undefined],
|
|
273
|
+
] as const) {
|
|
274
|
+
for (const k of ["bob", "alice", "viewer"]) {
|
|
275
|
+
const r = await send(U[k].token, method, path, body);
|
|
276
|
+
expect(r.status).toBe(403);
|
|
277
|
+
expect(r.body.error).toBe("group_manage_denied");
|
|
278
|
+
}
|
|
279
|
+
const carol = await send(U.carol.token, method, path, body);
|
|
280
|
+
expect(carol.status).toBe(404);
|
|
281
|
+
expect(carol.body.error).toBe("group_not_found");
|
|
282
|
+
const node = await send(U.node.token, method, path, body);
|
|
283
|
+
expect(node.status).toBe(403);
|
|
284
|
+
expect(node.body.error).toBe("humans_only");
|
|
285
|
+
expect((await send(U.outsider.token, method, path, body)).status).toBe(403);
|
|
286
|
+
expect((await send("", method, path, body)).status).toBe(401);
|
|
287
|
+
}
|
|
288
|
+
// 不存在的群,管理员也是 404。
|
|
289
|
+
expect((await send(U.owner.token, "PATCH", CG("/grp_doesnotexist"), { name: "x" })).status).toBe(404);
|
|
290
|
+
expect(who("rd")).toEqual(["alice", "bob", "head", "viewer"]);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("a head reads groups in their subtree, and can add / remove manual members there", async () => {
|
|
294
|
+
// head 不是后端组群的成员,但后端组在他的子树里:读得到。
|
|
295
|
+
const read = await send(U.head.token, "GET", CG(`/${GRP.backend}`));
|
|
296
|
+
expect(read.status).toBe(200);
|
|
297
|
+
expect(read.body.is_member).toBe(false);
|
|
298
|
+
const add = await send(U.head.token, "POST", CG(`/${GRP.backend}/members`), { user_id: U.dave.id });
|
|
299
|
+
expect(add.status).toBe(201);
|
|
300
|
+
expect(rows("backend")[U.dave.id]).toBe("manual");
|
|
301
|
+
const audit = db.get<{ detail: string }>("SELECT detail FROM audit_log WHERE network_id = ?1 AND action = 'chat_group_member_added' ORDER BY id DESC LIMIT 1", NET);
|
|
302
|
+
expect(JSON.parse(audit!.detail)).toMatchObject({ group_id: GRP.backend, user_id: U.dave.id, via: "leader" });
|
|
303
|
+
expect((await send(U.bob.token, "DELETE", CG(`/${GRP.backend}/members/${U.dave.id}`))).status).toBe(200);
|
|
304
|
+
// 越界:head 管不了市场部的群(不是成员 → 404)。
|
|
305
|
+
expect((await send(U.head.token, "POST", CG(`/${GRP.market}/members`), { user_id: U.alice.id })).status).toBe(404);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("add: bad input, non-network users, and existing members are refused", async () => {
|
|
309
|
+
expect((await send(U.owner.token, "POST", CG(`/${GRP.rd}/members`), {})).body.error).toBe("user_id_required");
|
|
310
|
+
expect((await send(U.owner.token, "POST", CG(`/${GRP.rd}/members`), { user_id: 7 })).status).toBe(400);
|
|
311
|
+
const outsider = await send(U.owner.token, "POST", CG(`/${GRP.rd}/members`), { user_id: U.outsider.id });
|
|
312
|
+
expect(outsider.status).toBe(400);
|
|
313
|
+
expect(outsider.body.error).toBe("not_network_member");
|
|
314
|
+
expect((await send(U.owner.token, "POST", CG(`/${GRP.rd}/members`), { user_id: U.node.id })).body.error).toBe("not_network_member");
|
|
315
|
+
const dup = await send(U.owner.token, "POST", CG(`/${GRP.rd}/members`), { user_id: U.alice.id });
|
|
316
|
+
expect(dup.status).toBe(409);
|
|
317
|
+
expect(dup.body.error).toBe("already_group_member");
|
|
318
|
+
expect(rows("rd")[U.alice.id]).toBe("department");
|
|
319
|
+
expect((await send(U.owner.token, "POST", CG(`/${GRP.rd}/members`), "not json")).status).toBe(400);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("unsupported methods → 405", async () => {
|
|
323
|
+
expect((await send(U.owner.token, "PUT", CG(`/${GRP.rd}`), { name: "x" })).status).toBe(405);
|
|
324
|
+
expect((await send(U.owner.token, "DELETE", CG(`/${GRP.rd}`))).status).toBe(405);
|
|
325
|
+
expect((await send(U.owner.token, "GET", CG(`/${GRP.rd}/members`))).status).toBe(405);
|
|
326
|
+
expect((await send(U.owner.token, "POST", CG(), {})).status).toBe(405);
|
|
327
|
+
});
|
|
328
|
+
});
|