@oj-module/cli 0.2.47 → 0.2.48

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.
Files changed (94) hide show
  1. package/devkit/SKILL.md +1 -1
  2. package/devkit/framework-dev.md +2 -2
  3. package/devkit/manual.md +24 -14
  4. package/devkit/module-dev-and-cdn-playground.md +2 -2
  5. package/devkit/wecom-daikaifa.md +237 -14
  6. package/package.json +2 -2
  7. package/shell-dist/assets/runtime.js +2 -2
  8. package/shell-dist/index.html +2 -2
  9. package/shell-dist/versions.json +3 -3
  10. package/src/__tests__/templates-tenant-scope.test.ts +7 -2
  11. package/src/init.ts +1 -1
  12. package/src/market/__tests__/bootstrap.test.ts +1 -1
  13. package/src/market/__tests__/setup.test.ts +7 -7
  14. package/src/market/bootstrap.ts +1 -1
  15. package/templates/README.md +1 -1
  16. package/templates/init.include.yaml +12 -0
  17. package/templates/openapi.json +2863 -549
  18. package/templates/src/_platform/api/_shared/service/domain/contacts.ts +7 -0
  19. package/templates/src/_platform/api/_shared/service/domain/org-read.ts +451 -0
  20. package/templates/src/_platform/api/_shared/service/domain/org.ts +566 -0
  21. package/templates/src/_platform/api/_shared/service/domain/users.ts +140 -2
  22. package/templates/src/_platform/api/_shared/service/infra/query.ts +2 -1
  23. package/templates/src/_platform/api/fixtures/l1-test-user.sql +1 -1
  24. package/templates/src/_platform/api/manifest.yaml +3 -0
  25. package/templates/src/_platform/api/migrations/0016__org.mysql.sql +41 -0
  26. package/templates/src/_platform/api/migrations/0016__org.pg.sql +40 -0
  27. package/templates/src/_platform/api/migrations/0016__org.sql +62 -0
  28. package/templates/src/_platform/api/org/departments/api.ts +51 -0
  29. package/templates/src/_platform/api/org/is-member/api.ts +33 -0
  30. package/templates/src/_platform/api/org/members/api.ts +55 -0
  31. package/templates/src/_platform/api/org/picker/api.ts +59 -0
  32. package/templates/src/_platform/api/org/resolve/api.ts +38 -0
  33. package/templates/src/_platform/api/org/tree/api.ts +37 -0
  34. package/templates/src/_platform/api/org/user/api.ts +46 -0
  35. package/templates/src/_platform/api/schema.yaml +48 -0
  36. package/templates/src/_platform/api/seed.mysql.sql +10 -5
  37. package/templates/src/_platform/api/seed.pg.sql +10 -5
  38. package/templates/src/_platform/api/seed.sql +13 -5
  39. package/templates/src/_platform/web/__client/api.schemas.ts +126 -0
  40. package/templates/src/_platform/web/__client/api.ts +170 -1
  41. package/templates/src/auth/api/_shared/ldap-login.ts +2 -0
  42. package/templates/src/auth/api/_shared/session.ts +5 -1
  43. package/templates/src/auth/api/_shared/wecom/admin.ts +3 -1
  44. package/templates/src/auth/api/_shared/wecom/client.ts +243 -31
  45. package/templates/src/auth/api/_shared/wecom/config.ts +12 -1
  46. package/templates/src/auth/api/_shared/wecom/contacts.ts +263 -0
  47. package/templates/src/auth/api/_shared/wecom/crypt.ts +17 -7
  48. package/templates/src/auth/api/_shared/wecom/events.ts +111 -26
  49. package/templates/src/auth/api/_shared/wecom/login.ts +152 -1
  50. package/templates/src/auth/api/_shared/wecom/payload.ts +116 -0
  51. package/templates/src/auth/api/_shared/wecom/store.ts +65 -0
  52. package/templates/src/auth/api/_shared/wecom/sync.ts +487 -0
  53. package/templates/src/auth/api/manifest.yaml +3 -0
  54. package/templates/src/auth/api/migrations/0002__org_sync.mysql.sql +28 -0
  55. package/templates/src/auth/api/migrations/0002__org_sync.pg.sql +27 -0
  56. package/templates/src/auth/api/migrations/0002__org_sync.sql +41 -0
  57. package/templates/src/auth/api/schema.yaml +31 -0
  58. package/templates/src/auth/api/wecom/admin/api.ts +3 -1
  59. package/templates/src/auth/api/wecom/api.ts +100 -18
  60. package/templates/src/auth/api/wecom/sync/api.ts +199 -0
  61. package/templates/src/auth/web/__client/api.schemas.ts +97 -0
  62. package/templates/src/auth/web/__client/api.ts +73 -1
  63. package/templates/src/notification/api/seed.sql +1 -1
  64. package/templates/src/system/api/departments/api.ts +142 -0
  65. package/templates/src/system/api/departments/detach/api.ts +34 -0
  66. package/templates/src/system/api/departments/members/api.ts +142 -0
  67. package/templates/src/system/api/departments/move/api.ts +39 -0
  68. package/templates/src/system/api/users/api.ts +45 -5
  69. package/templates/src/system/api/users/profile/api.ts +44 -0
  70. package/templates/src/system/web/__client/api.schemas.ts +143 -0
  71. package/templates/src/system/web/__client/api.ts +293 -1
  72. package/templates/src/system/web/entry.ts +6 -0
  73. package/templates/src/system/web/locales/en-US.json +112 -6
  74. package/templates/src/system/web/locales/zh-CN.json +112 -6
  75. package/templates/src/system/web/pages/departments/index.tsx +346 -0
  76. package/templates/src/system/web/pages/departments/sync-bar.tsx +257 -0
  77. package/templates/src/system/web/pages/departments/tree.ts +66 -0
  78. package/templates/src/system/web/pages/errors.ts +1 -1
  79. package/templates/src/system/web/pages/users/index.tsx +49 -7
  80. package/templates/src/system/web/pages/wecom/index.tsx +14 -0
  81. package/templates/tests/l1-builder.test.ts +1 -1
  82. package/templates/tests/org-departments.spec.ts +311 -0
  83. package/templates/tests/org-read.spec.ts +469 -0
  84. package/templates/tests/rbac-contacts.spec.ts +13 -3
  85. package/templates/tests/rbac-seed.spec.ts +11 -0
  86. package/templates/tests/seed-role-accounts.spec.ts +6 -2
  87. package/templates/tests/wecom-admin.spec.ts +1 -0
  88. package/templates/tests/wecom-connector.spec.ts +115 -8
  89. package/templates/tests/wecom-crypt.spec.ts +12 -3
  90. package/templates/tests/wecom-endpoints.spec.ts +182 -14
  91. package/templates/tests/wecom-org-sync.spec.ts +598 -0
  92. package/templates/tests/wecom-sync.spec.ts +286 -0
  93. package/vendor/host-versions.json +3 -3
  94. package/templates/src/auth/api/_shared/wecom/xml.ts +0 -36
@@ -0,0 +1,469 @@
1
+ // BDD:组织架构共享读面(A3 / 方案 §5.2 + D5)——给别的模块消费的只读面。
2
+ //
3
+ // 这一层比 CRUD 更容易「静默越权」,所以用例按**门禁**和**可见性**两条线钉:
4
+ // 1. self 档的可见范围是「我所在部门 + 祖先链」,不是「登录即可看全公司」。
5
+ // 祖先可见 ≠ 祖先可展开:对祖先 includeSub 会把整棵子树吐出来,等于绕过自身路径,
6
+ // 必须 400(宁可报错也不静默截断——截断给出的是「看似正确其实少人」的结果)。
7
+ // 2. 读面字段是**白名单**:origin / syncVisible / isLeader / externalStatus 一个都不能漏出来。
8
+ // 反过来也钉住:返回字段与 scope 无关,消费模块不该按档位分支解析。
9
+ //
10
+ // 另外钉住 §9.1 阻断项:通讯录只出 activated=1,同步灌进来的「从未登录」账号不得被检索到。
11
+ import type { OrgDeps } from "../src/_platform/api/_shared/service/domain/org";
12
+
13
+ import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
14
+ import { contactDetail, searchContacts } from "../src/_platform/api/_shared/service/domain/contacts";
15
+ import {
16
+ addMember,
17
+ createDepartment,
18
+ deleteDepartment,
19
+ } from "../src/_platform/api/_shared/service/domain/org";
20
+ import {
21
+ isMemberOf,
22
+ pageDepartments,
23
+ pageMembers,
24
+ parseScope,
25
+ readPicker,
26
+ readTree,
27
+ readUserDepartments,
28
+ resolveReader,
29
+ resolveReadFace,
30
+ resolveUserIds,
31
+ } from "../src/_platform/api/_shared/service/domain/org-read";
32
+ import { createUser } from "../src/_platform/api/_shared/service/domain/users";
33
+ import { invalidateUser } from "../src/_platform/api/_shared/service/infra/access";
34
+ import { ensureWecomUser } from "../src/auth/api/_shared/wecom/login";
35
+ import { ADMIN, installDb } from "./fixture";
36
+
37
+ let dbo: ReturnType<typeof installDb>;
38
+ const g = globalThis as any;
39
+ const deps: OrgDeps = { now: () => 1_700_000_000 };
40
+
41
+ let seq = 0;
42
+ /** 建部门(走基线 CRUD,origin=local) */
43
+ async function mkDept(name: string, parentId: number | null = null): Promise<number> {
44
+ return createDepartment({ name, parentId }, deps);
45
+ }
46
+
47
+ /** 建本地账号(返回 uid);activated 由 createUser 决定 */
48
+ async function mkUser(username: string): Promise<number> {
49
+ seq += 1;
50
+ return createUser({ username: `${username}${seq}`, password: "aA123456" }, { bcryptHash: async () => "x" });
51
+ }
52
+
53
+ /** 极简骨架:公司 → (研发 → (前端组), 销售) */
54
+ async function mkOrg(): Promise<{ root: number, rd: number, fe: number, sales: number }> {
55
+ const root = await mkDept("公司");
56
+ const rd = await mkDept("研发部", root);
57
+ const fe = await mkDept("前端组", rd);
58
+ const sales = await mkDept("销售部", root);
59
+ return { root, rd, fe, sales };
60
+ }
61
+
62
+ beforeAll(() => {
63
+ dbo = installDb();
64
+ });
65
+
66
+ beforeEach(() => {
67
+ g.http = { user: { id: ADMIN } };
68
+ });
69
+
70
+ afterEach(() => {
71
+ dbo.exec("DELETE FROM sys_dept_member");
72
+ dbo.exec("DELETE FROM sys_department");
73
+ dbo.exec("DELETE FROM sys_role_permission");
74
+ dbo.exec("DELETE FROM sys_user_role");
75
+ dbo.exec("DELETE FROM sys_refresh_token");
76
+ dbo.exec("DELETE FROM sys_rate_window");
77
+ dbo.exec("DELETE FROM sys_oplog");
78
+ dbo.exec("DELETE FROM users WHERE id <> 1");
79
+ invalidateUser(ADMIN);
80
+ });
81
+
82
+ describe("scope 解析", () => {
83
+ it("缺省与 'self' 都归一为 self;'all' 原样;其余 400", () => {
84
+ expect(parseScope(undefined)).toBe("self");
85
+ expect(parseScope("")).toBe("self");
86
+ expect(parseScope(" ")).toBe("self");
87
+ expect(parseScope("self")).toBe("self");
88
+ expect(parseScope("all")).toBe("all");
89
+ expect(() => parseScope("full")).toThrowError(/scope must be/);
90
+ });
91
+
92
+ it("self 档可见集 = 我所在部门 + 全部祖先;可展开集只含我所在部门", async () => {
93
+ const { root, rd, fe } = await mkOrg();
94
+ const uid = await mkUser("u");
95
+ await addMember(fe, uid, {}, deps);
96
+
97
+ const reader = await resolveReader(uid, "self");
98
+ expect([...reader.visible!].sort((a, b) => a - b)).toEqual([root, rd, fe].sort((a, b) => a - b));
99
+ // 祖先只是「可见」,不可展开——见头注红线
100
+ expect([...reader.expandable!]).toEqual([fe]);
101
+ });
102
+
103
+ it("self 档无部门 → 可见集是**空集**而不是 null(空集不等于不限制)", async () => {
104
+ const uid = await mkUser("u");
105
+ const reader = await resolveReader(uid, "self");
106
+ expect(reader.visible).toBeInstanceOf(Set);
107
+ expect(reader.visible!.size).toBe(0);
108
+ expect(reader.expandable!.size).toBe(0);
109
+ });
110
+
111
+ it("all 档不设可见集(null = 不限制)", async () => {
112
+ const uid = await mkUser("u");
113
+ const reader = await resolveReader(uid, "all");
114
+ expect(reader.visible).toBeNull();
115
+ expect(reader.expandable).toBeNull();
116
+ });
117
+ });
118
+
119
+ describe("部门树读面", () => {
120
+ it("self 档只出自己部门与祖先,兄弟部门与无关子树不出", async () => {
121
+ const { root, rd, fe, sales } = await mkOrg();
122
+ const uid = await mkUser("u");
123
+ await addMember(fe, uid, {}, deps);
124
+
125
+ const ids = (await readTree(await resolveReader(uid, "self"))).map(d => d.id);
126
+ expect(ids.sort((a, b) => a - b)).toEqual([root, rd, fe].sort((a, b) => a - b));
127
+ expect(ids).not.toContain(sales);
128
+ });
129
+
130
+ it("all 档出全量", async () => {
131
+ const { root, rd, fe, sales } = await mkOrg();
132
+ const ids = (await readTree(await resolveReader(ADMIN, "all"))).map(d => d.id);
133
+ expect(ids.sort((a, b) => a - b)).toEqual([root, rd, fe, sales].sort((a, b) => a - b));
134
+ });
135
+
136
+ it("字段白名单:读面不出 origin / syncVisible / deleted / 负责人 / 在职状态", async () => {
137
+ const { fe } = await mkOrg();
138
+ const tree = await readTree(await resolveReader(ADMIN, "all"));
139
+ const item = tree.find(d => d.id === fe)!;
140
+ expect(Object.keys(item).sort()).toEqual(["id", "memberCount", "name", "nameEn", "orderNo", "parentId"]);
141
+ });
142
+
143
+ it("软删 / 同步不可见的部门不进读面(那是管理端的事)", async () => {
144
+ const { root, rd, fe, sales } = await mkOrg();
145
+ await deleteDepartment(fe);
146
+ dbo.prepare("UPDATE sys_department SET sync_visible = 0 WHERE id = ?").run(rd);
147
+
148
+ const ids = (await readTree(await resolveReader(ADMIN, "all"))).map(d => d.id);
149
+ expect(ids.sort((a, b) => a - b)).toEqual([root, sales].sort((a, b) => a - b));
150
+ });
151
+
152
+ it("memberCount 是真人数(不是子树累加)", async () => {
153
+ const { rd, fe } = await mkOrg();
154
+ const uid = await mkUser("u");
155
+ await addMember(fe, uid, {}, deps);
156
+ const tree = await readTree(await resolveReader(ADMIN, "all"));
157
+ expect(tree.find(d => d.id === fe)!.memberCount).toBe(1);
158
+ expect(tree.find(d => d.id === rd)!.memberCount).toBe(0);
159
+ });
160
+ });
161
+
162
+ describe("部门分页读面", () => {
163
+ it("parent=0 只出根层,parent=<id> 只出直属下级", async () => {
164
+ const { root, rd, fe, sales } = await mkOrg();
165
+ const reader = await resolveReader(ADMIN, "all");
166
+ expect((await pageDepartments(reader, { parent: 0 })).list.map(d => d.id)).toEqual([root]);
167
+ expect((await pageDepartments(reader, { parent: root })).list.map(d => d.id).sort((a, b) => a - b))
168
+ .toEqual([rd, sales].sort((a, b) => a - b));
169
+ expect((await pageDepartments(reader, { parent: rd })).list.map(d => d.id)).toEqual([fe]);
170
+ });
171
+
172
+ it("keyword 单字 400(与通讯录同一条防遍历红线)", async () => {
173
+ const reader = await resolveReader(ADMIN, "all");
174
+ await expect(pageDepartments(reader, { keyword: "研" })).rejects.toMatchObject({ status: 400 });
175
+ });
176
+
177
+ it("keyword 命中中英文名;分页给出 total / truncated", async () => {
178
+ const { root } = await mkOrg();
179
+ for (let i = 0; i < 3; i++)
180
+ await createDepartment({ name: `市场${i}`, nameEn: `mkt-${i}`, parentId: root }, deps);
181
+ const reader = await resolveReader(ADMIN, "all");
182
+
183
+ expect((await pageDepartments(reader, { keyword: "mkt" })).total).toBe(3);
184
+ const p1 = await pageDepartments(reader, { keyword: "市场", page: 1, size: 2 });
185
+ expect(p1.list).toHaveLength(2);
186
+ expect(p1.total).toBe(3);
187
+ expect(p1.truncated).toBe(true);
188
+ const p2 = await pageDepartments(reader, { keyword: "市场", page: 2, size: 2 });
189
+ expect(p2.list).toHaveLength(1);
190
+ expect(p2.truncated).toBe(false);
191
+ });
192
+ });
193
+
194
+ describe("成员读面", () => {
195
+ it("给 dept 出该部门直属成员 + 部门名路径(根在前)", async () => {
196
+ const { fe } = await mkOrg();
197
+ const uid = await mkUser("u");
198
+ await addMember(fe, uid, { isMain: true }, deps);
199
+
200
+ const page = await pageMembers(await resolveReader(ADMIN, "all"), { dept: fe });
201
+ expect(page.total).toBe(1);
202
+ expect(page.list[0].userId).toBe(uid);
203
+ expect(page.list[0].deptPath).toEqual(["公司", "研发部", "前端组"]);
204
+ expect(page.list[0].isMain).toBe(true);
205
+ });
206
+
207
+ it("includeSub 展开子树;不传只出直属", async () => {
208
+ const { root, rd, fe } = await mkOrg();
209
+ const a = await mkUser("a");
210
+ const b = await mkUser("b");
211
+ await addMember(rd, a, {}, deps);
212
+ await addMember(fe, b, {}, deps);
213
+
214
+ const reader = await resolveReader(ADMIN, "all");
215
+ expect((await pageMembers(reader, { dept: root })).list.map(m => m.userId)).toEqual([]);
216
+ expect((await pageMembers(reader, { dept: root, includeSub: true })).list.map(m => m.userId).sort((x, y) => x - y))
217
+ .toEqual([a, b].sort((x, y) => x - y));
218
+ });
219
+
220
+ it("⚡ self 档对**祖先**部门 includeSub → 400(展开祖先等于绕过自身路径)", async () => {
221
+ const { rd, fe } = await mkOrg();
222
+ const uid = await mkUser("u");
223
+ await addMember(fe, uid, {}, deps);
224
+ const reader = await resolveReader(uid, "self");
225
+
226
+ await expect(pageMembers(reader, { dept: rd, includeSub: true })).rejects.toMatchObject({ status: 400 });
227
+ // 祖先仍可看直属成员(可见 ≠ 可展开)
228
+ await expect(pageMembers(reader, { dept: rd })).resolves.toMatchObject({ total: 0 });
229
+ });
230
+
231
+ it("self 档查不可见部门 → 403", async () => {
232
+ const { fe, sales } = await mkOrg();
233
+ const uid = await mkUser("u");
234
+ await addMember(fe, uid, {}, deps);
235
+ const reader = await resolveReader(uid, "self");
236
+ await expect(pageMembers(reader, { dept: sales })).rejects.toMatchObject({ status: 403 });
237
+ });
238
+
239
+ it("一人多部门只出一条(主部门优先),避免 includeSub 重复吐人", async () => {
240
+ const { rd, fe } = await mkOrg();
241
+ const uid = await mkUser("u");
242
+ await addMember(rd, uid, {}, deps);
243
+ await addMember(fe, uid, { isMain: true }, deps);
244
+
245
+ const list = (await pageMembers(await resolveReader(ADMIN, "all"), { dept: rd, includeSub: true })).list;
246
+ expect(list.filter(m => m.userId === uid)).toHaveLength(1);
247
+ expect(list.find(m => m.userId === uid)!.deptId).toBe(fe);
248
+ });
249
+
250
+ it("停用账号不出(与通讯录同口径:不该被当成收件人)", async () => {
251
+ const { fe } = await mkOrg();
252
+ const uid = await mkUser("u");
253
+ await addMember(fe, uid, {}, deps);
254
+ dbo.prepare("UPDATE users SET is_forbidden = 1 WHERE id = ?").run(uid);
255
+
256
+ expect((await pageMembers(await resolveReader(ADMIN, "all"), { dept: fe })).total).toBe(0);
257
+ });
258
+
259
+ it("keyword 命中 username / displayName", async () => {
260
+ const { fe } = await mkOrg();
261
+ const a = await mkUser("alice");
262
+ const b = await mkUser("bob");
263
+ await addMember(fe, a, {}, deps);
264
+ await addMember(fe, b, {}, deps);
265
+ const page = await pageMembers(await resolveReader(ADMIN, "all"), { dept: fe, keyword: "ali" });
266
+ expect(page.list.map(m => m.userId)).toEqual([a]);
267
+ });
268
+ });
269
+
270
+ describe("某人的部门", () => {
271
+ it("出主部门路径、全部部门与职务", async () => {
272
+ const { rd, fe } = await mkOrg();
273
+ const uid = await mkUser("u");
274
+ await addMember(rd, uid, {}, deps);
275
+ await addMember(fe, uid, { isMain: true }, deps);
276
+ dbo.prepare("UPDATE users SET position = '前端工程师' WHERE id = ?").run(uid);
277
+
278
+ const info = await readUserDepartments(await resolveReader(ADMIN, "all"), uid);
279
+ expect(info.mainDeptId).toBe(fe);
280
+ expect(info.mainDeptPath).toEqual(["公司", "研发部", "前端组"]);
281
+ expect(info.position).toBe("前端工程师");
282
+ expect(info.departments.map(d => d.deptId)).toEqual([fe, rd]);
283
+ });
284
+
285
+ it("self 档查别人 → 403(不是 404:不泄露账号存在性)", async () => {
286
+ const uid = await mkUser("u");
287
+ const other = await mkUser("o");
288
+ await expect(readUserDepartments(await resolveReader(uid, "self"), other)).rejects.toMatchObject({ status: 403 });
289
+ });
290
+
291
+ it("self 档查自己可以;用户不存在 → 404", async () => {
292
+ const uid = await mkUser("u");
293
+ await expect(readUserDepartments(await resolveReader(uid, "self"), uid)).resolves.toMatchObject({ userId: uid });
294
+ await expect(readUserDepartments(await resolveReader(ADMIN, "all"), 999_999)).rejects.toMatchObject({ status: 404 });
295
+ });
296
+
297
+ it("已删部门从 departments 里裁掉,主部门路径随之清空", async () => {
298
+ const { fe } = await mkOrg();
299
+ const uid = await mkUser("u");
300
+ await addMember(fe, uid, { isMain: true }, deps);
301
+ await deleteDepartment(fe);
302
+
303
+ const info = await readUserDepartments(await resolveReader(ADMIN, "all"), uid);
304
+ expect(info.departments).toEqual([]);
305
+ expect(info.mainDeptId).toBeNull();
306
+ expect(info.mainDeptPath).toEqual([]);
307
+ });
308
+ });
309
+
310
+ describe("选人组件", () => {
311
+ it("keyword 必填,且 <2 字 400", async () => {
312
+ const reader = await resolveReader(ADMIN, "all");
313
+ await expect(readPicker(reader, {})).rejects.toMatchObject({ status: 400 });
314
+ await expect(readPicker(reader, { keyword: "研" })).rejects.toMatchObject({ status: 400 });
315
+ });
316
+
317
+ it("人与部门一起返回,limit 生效", async () => {
318
+ const { root } = await mkOrg();
319
+ for (let i = 0; i < 3; i++)
320
+ await createDepartment({ name: `市场${i}`, parentId: root }, deps);
321
+ const uid = await mkUser("mkt");
322
+ const res = await readPicker(await resolveReader(ADMIN, "all"), { keyword: "市场", limit: 2 });
323
+ expect(res.departments).toHaveLength(2);
324
+ expect(res.departments.every(d => d.parentId === root)).toBe(true);
325
+ // 名字里没有「市场」的人不该被带出来
326
+ expect(res.users.map(u => u.userId)).not.toContain(uid);
327
+ });
328
+
329
+ it("limit 超上限静默钳 50", async () => {
330
+ const { root } = await mkOrg();
331
+ for (let i = 0; i < 60; i++)
332
+ await createDepartment({ name: `部门${i}`, parentId: root }, deps);
333
+ const res = await readPicker(await resolveReader(ADMIN, "all"), { keyword: "部门", limit: 999 });
334
+ expect(res.departments).toHaveLength(50);
335
+ });
336
+ });
337
+
338
+ describe("resolve(把部门展开成 user_id)", () => {
339
+ it("deptIds + includeSub 展开子树,结果去重升序", async () => {
340
+ const { rd, fe } = await mkOrg();
341
+ const a = await mkUser("a");
342
+ const b = await mkUser("b");
343
+ const c = await mkUser("c");
344
+ await addMember(rd, a, {}, deps);
345
+ await addMember(fe, b, {}, deps);
346
+ await addMember(fe, c, {}, deps);
347
+
348
+ const reader = await resolveReader(ADMIN, "all");
349
+ expect(await resolveUserIds(reader, { deptIds: [rd] })).toEqual([a]);
350
+ expect(await resolveUserIds(reader, { deptIds: [rd], includeSub: true })).toEqual([a, b, c].sort((x, y) => x - y));
351
+ // 父子同时给也不重复
352
+ expect(await resolveUserIds(reader, { deptIds: [rd, fe], includeSub: true })).toEqual([a, b, c].sort((x, y) => x - y));
353
+ });
354
+
355
+ it("显式 userIds 原样并入并去重(不校验部门归属)", async () => {
356
+ const { fe } = await mkOrg();
357
+ const a = await mkUser("a");
358
+ const reader = await resolveReader(ADMIN, "all");
359
+ expect(await resolveUserIds(reader, { userIds: [a, a, 424242] })).toEqual([a, 424242].sort((x, y) => x - y));
360
+ expect(await resolveUserIds(reader, { userIds: [a], deptIds: [fe] })).toEqual([a]);
361
+ });
362
+
363
+ it("⚡ self 档 includeSub 祖先部门 → 400", async () => {
364
+ const { rd, fe } = await mkOrg();
365
+ const uid = await mkUser("u");
366
+ await addMember(fe, uid, {}, deps);
367
+ const reader = await resolveReader(uid, "self");
368
+ await expect(resolveUserIds(reader, { deptIds: [rd], includeSub: true })).rejects.toMatchObject({ status: 400 });
369
+ });
370
+
371
+ it("非法 id 忽略,不抛", async () => {
372
+ await mkOrg();
373
+ const reader = await resolveReader(ADMIN, "all");
374
+ expect(await resolveUserIds(reader, { deptIds: ["x", -1, 0], userIds: [null, "y"] })).toEqual([]);
375
+ });
376
+ });
377
+
378
+ describe("is-member", () => {
379
+ it("含子部门:在孙部门也算属于根部门", async () => {
380
+ const { root, rd, fe, sales } = await mkOrg();
381
+ const uid = await mkUser("u");
382
+ await addMember(fe, uid, {}, deps);
383
+ const reader = await resolveReader(ADMIN, "all");
384
+
385
+ expect(await isMemberOf(reader, uid, fe)).toBe(true);
386
+ expect(await isMemberOf(reader, uid, rd)).toBe(true);
387
+ expect(await isMemberOf(reader, uid, root)).toBe(true);
388
+ expect(await isMemberOf(reader, uid, sales)).toBe(false);
389
+ });
390
+
391
+ it("self 档只能问自己 → 问别人 403;可见部门内正常作答", async () => {
392
+ const { fe } = await mkOrg();
393
+ const uid = await mkUser("u");
394
+ const other = await mkUser("o");
395
+ await addMember(fe, uid, {}, deps);
396
+ const reader = await resolveReader(uid, "self");
397
+ await expect(isMemberOf(reader, other, fe)).rejects.toMatchObject({ status: 403 });
398
+ expect(await isMemberOf(reader, uid, fe)).toBe(true);
399
+ // 不可见部门连问都不许问(403 而不是 false:false 会泄露「这个部门存在且你不属于」)
400
+ await expect(isMemberOf(reader, uid, 999_999)).rejects.toMatchObject({ status: 403 });
401
+ });
402
+ });
403
+
404
+ describe("门禁(resolveReadFace)", () => {
405
+ it("未登录 → 401", async () => {
406
+ g.http = {};
407
+ await expect(resolveReadFace(undefined)).rejects.toMatchObject({ status: 401 });
408
+ });
409
+
410
+ it("伪造 X-TENANT-ID → 403(token 的 sub 在本租户查无此人)", async () => {
411
+ g.http = { user: { id: ADMIN }, tenantId: "other-corp" };
412
+ await expect(resolveReadFace(undefined)).rejects.toMatchObject({ status: 403 });
413
+ });
414
+
415
+ it("scope 非法 → 400", async () => {
416
+ await expect(resolveReadFace("full")).rejects.toMatchObject({ status: 400 });
417
+ });
418
+
419
+ it("scope=all 无权限点 → 403", async () => {
420
+ const uid = await mkUser("u");
421
+ // must_change 先清零:否则 403 来自「首登改密悬挂」,测不到权限点这一层
422
+ dbo.prepare("UPDATE users SET must_change = 0 WHERE id = ?").run(uid);
423
+ g.http = { user: { id: uid } };
424
+ await expect(resolveReadFace("all")).rejects.toMatchObject({ status: 403 });
425
+ // self 档不挂权限点:登录即可
426
+ await expect(resolveReadFace("self")).resolves.toMatchObject({ scope: "self" });
427
+ });
428
+
429
+ it("scope=all 绑定 platform.org.read.all 后通过", async () => {
430
+ const uid = await mkUser("u");
431
+ // createUser 默认 must_change=1(首登改密悬挂),判权出口会先拒;这里只测权限点绑定
432
+ dbo.prepare("UPDATE users SET must_change = 0 WHERE id = ?").run(uid);
433
+ dbo.prepare("INSERT INTO sys_user_role (user_id, role_name) VALUES (?, 'auditor')").run(uid);
434
+ dbo.prepare("INSERT INTO sys_role_permission (role_name, permission_code) VALUES ('auditor', 'platform.org.read.all')").run();
435
+ g.http = { user: { id: uid } };
436
+ await expect(resolveReadFace("all")).resolves.toMatchObject({ scope: "all", visible: null });
437
+ });
438
+
439
+ it("超限 → 429(60 次/分)", async () => {
440
+ const uid = await mkUser("u");
441
+ g.http = { user: { id: uid } };
442
+ dbo.prepare("INSERT INTO sys_rate_window (rate_key, window_start, count) VALUES (?, ?, 60)")
443
+ .run(`org:${uid}`, Date.now() - 1_000);
444
+ await expect(resolveReadFace("self")).rejects.toMatchObject({ status: 429 });
445
+ });
446
+ });
447
+
448
+ describe("通讯录 activated 门禁(方案 §9.1 阻断项)", () => {
449
+ it("管理员建号即 activated=1", async () => {
450
+ const uid = await mkUser("u");
451
+ expect(dbo.prepare("SELECT activated FROM users WHERE id = ?").get(uid)).toMatchObject({ activated: 1 });
452
+ });
453
+
454
+ it("企微登录建档 activated=1(真人刚登录过)", async () => {
455
+ const { uid } = await ensureWecomUser("corp-x", "zhangsan");
456
+ expect(dbo.prepare("SELECT activated FROM users WHERE id = ?").get(uid)).toMatchObject({ activated: 1 });
457
+ });
458
+
459
+ it("同步灌进来的 activated=0 账号检索不到、详情 404", async () => {
460
+ dbo.prepare("INSERT INTO users (username, password_hash, display_name, activated) VALUES ('sync-zhang', '!sync', '张三', 0)").run();
461
+ const id = (dbo.prepare("SELECT id FROM users WHERE username = 'sync-zhang'").get() as { id: number }).id;
462
+
463
+ expect(await searchContacts("张三", {})).toEqual([]);
464
+ await expect(contactDetail(id)).rejects.toMatchObject({ status: 404 });
465
+ // 翻成 1 之后才可见——这是「真人用过」的唯一凭据
466
+ dbo.prepare("UPDATE users SET activated = 1 WHERE id = ?").run(id);
467
+ expect((await searchContacts("张三", {})).map(r => r.id)).toEqual([id]);
468
+ });
469
+ });
@@ -1,6 +1,7 @@
1
1
  // BDD:通讯录——searchContacts 目录(AC1-22/23)+ contactDetail 详情 + 接口级限速。
2
2
  // 读模型红线:目录只出 id/username/display_name(无 email/avatar/roles,安全评审 P2);
3
3
  // 禁用用户不出目录(AC1-23);keyword≥2(AC1-22);limit 钳 200(AC1-24)。
4
+ // 另有 activated 门禁(方案 §9.1 阻断项,A3 引入):同步灌进来的「从未登录」账号不进目录。
4
5
  /* eslint-disable test/prefer-lowercase-title -- AC 编号是验收锚点,保留大写 */
5
6
  import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
6
7
 
@@ -10,9 +11,10 @@ import { installDb } from "./fixture";
10
11
  let dbo: ReturnType<typeof installDb>;
11
12
  const g = globalThis as any;
12
13
 
13
- function mkUser(username: string, opts: { displayName?: string, email?: string, forbidden?: boolean } = {}): number {
14
- dbo.prepare("INSERT INTO users (username, password_hash, display_name, email, is_forbidden) VALUES (?, 'x', ?, ?, ?)")
15
- .run(username, opts.displayName ?? null, opts.email ?? null, opts.forbidden ? 1 : 0);
14
+ /** activated 默认 1:夹具造的是「真人用过的号」(同步阶段的占位号见 AC1-23 第二例) */
15
+ function mkUser(username: string, opts: { displayName?: string, email?: string, forbidden?: boolean, activated?: boolean } = {}): number {
16
+ dbo.prepare("INSERT INTO users (username, password_hash, display_name, email, is_forbidden, activated) VALUES (?, 'x', ?, ?, ?, ?)")
17
+ .run(username, opts.displayName ?? null, opts.email ?? null, opts.forbidden ? 1 : 0, opts.activated === false ? 0 : 1);
16
18
  return (dbo.prepare("SELECT id FROM users WHERE username = ?").get(username) as { id: number }).id;
17
19
  }
18
20
 
@@ -50,6 +52,14 @@ describe("AC1-23:禁用用户不出目录", () => {
50
52
  const r = await searchContacts("卡罗");
51
53
  expect(r.map(u => u.username)).toEqual(["c_carol"]);
52
54
  });
55
+
56
+ // §9.1 阻断项:组织架构同步会把全企业名册灌进 users(activated=0)。若目录不设这道闸门,
57
+ // 任何登录账号都能用 keyword 把整份名册拖走——所以「同步占位号」必须查不到、详情也打不开。
58
+ it("activated=0(同步建档、从未登录)不出现在结果中", async () => {
59
+ mkUser("c_frank", { displayName: "卡罗 frank", activated: false });
60
+ const r = await searchContacts("卡罗");
61
+ expect(r.map(u => u.username)).not.toContain("c_frank");
62
+ });
53
63
  });
54
64
 
55
65
  describe("AC1-24:limit 钳制", () => {
@@ -54,3 +54,14 @@ describe("seed 内置角色开发账号(账号名=角色名,2026-10-22 口
54
54
  expect(row).toBeUndefined();
55
55
  });
56
56
  });
57
+
58
+ describe("组织架构共享读面权限点(方案 D5)", () => {
59
+ // 钉这个不是测 seed 有没有写,而是防「端点挂了权限点、库里却没这一行」——
60
+ // 那种状态下 createRole / updateBindings 会因 code 不存在而 400,权限点永远绑不上,
61
+ // 表现是「scope=all 谁都进不去,管理员也束手无策」。
62
+ it("seed 出 platform.org.read.all(未 deprecated),管理员才绑得上", () => {
63
+ const row = dbo.prepare("SELECT * FROM sys_permission WHERE code = 'platform.org.read.all'").get() as Record<string, unknown> | undefined;
64
+ expect(row).toBeDefined();
65
+ expect(Number(row?.deprecated)).toBe(0);
66
+ });
67
+ });
@@ -1,5 +1,5 @@
1
1
  // BDD:脚手架 seed 内置角色开发账号——账号名=角色名一一对应(superadmin/admin/editor/viewer),
2
- // 口令均 123456,must_change=0 免首登改密(纯开发便利,生产部署须改密/删除)。
2
+ // 口令均 aA123456,must_change=0 免首登改密(纯开发便利,生产部署须改密/删除)。
3
3
  // 旧库收敛:历史 seed 把 admin 绑在 superadmin 且 must_change=1,seed 幂等重放即收敛
4
4
  // (DELETE 旧绑定 + UPDATE must_change=0),无需手工迁移。
5
5
  // 直接重放 _platform 迁移 + seed.sql 到内存 sqlite(installDb 不 replay seed,故自给自足)。
@@ -54,9 +54,13 @@ describe("seed 内置角色开发账号", () => {
54
54
  expect(roles.map(r => r.role_name)).toEqual([username]);
55
55
  });
56
56
 
57
- it("四个角色账号口令一致(123456 的 bcrypt,复用 seed 既有哈希)", () => {
57
+ // 钉字面量而不是只钉「四者一致」:一致但整体换过口令时,文档/脚本里的口令说明会静默失效
58
+ // (2026-11-01 由 123456 换 aA123456 时就是这么发生的)。哈希由 `bcrypt.hash('aA123456', 10)`
59
+ // 生成后手工改写为 $2y$ 前缀(与 seed 既有口径同;$2a$/$2b$/$2y$ 三前缀等价)。
60
+ it("四个角色账号口令一致,且等于默认的 aA123456 哈希", () => {
58
61
  const hashes = dbo.prepare("SELECT username, password_hash h FROM users WHERE username IN ('superadmin','admin','editor','viewer')").all() as Array<{ username: string, h: string }>;
59
62
  expect(new Set(hashes.map(r => r.h)).size).toBe(1);
63
+ expect(hashes[0]!.h).toBe("$2y$10$9HEtASi9jG0VIBMMVqxAmOqrE4f6wiezhGmi50iPG1s1MXlncSO0e");
60
64
  });
61
65
 
62
66
  it("旧库收敛:legacy admin(superadmin 绑定 + must_change=1) 经重放收敛为账号名=角色名", () => {
@@ -290,6 +290,7 @@ describe("P3-C:开通入口(createInstall,代开发 get_customized_auth_ur
290
290
  expect(body.templateid_list).toEqual([SUITE]);
291
291
 
292
292
  expect(r.installUrl).toBe("https://open.work.weixin.qq.com/3rdservice/QR_1");
293
+ // 下发给管理员的就是文档里的地址(不带 callback_format=json,依据见 config.ts::eventUrlOf)
293
294
  expect(r.callbackUrl).toBe("https://site.example.com/api/auth/wecom/event");
294
295
  expect(r.expiresIn).toBe(7200);
295
296
  });