@oj-module/cli 0.2.49 → 0.2.50

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 (38) hide show
  1. package/devkit/README.md +8 -6
  2. package/devkit/SKILL.md +32 -12
  3. package/devkit/manual.md +6 -3
  4. package/devkit/wecom-daikaifa.md +95 -1
  5. package/devkit/wecom-internal-app.md +643 -0
  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/init.ts +5 -4
  11. package/templates/config.yaml +28 -6
  12. package/templates/openapi.json +174 -2
  13. package/templates/src/auth/api/_shared/wecom/admin.ts +87 -7
  14. package/templates/src/auth/api/_shared/wecom/config.ts +256 -46
  15. package/templates/src/auth/api/_shared/wecom/contacts.ts +7 -1
  16. package/templates/src/auth/api/_shared/wecom/events.ts +25 -6
  17. package/templates/src/auth/api/_shared/wecom/login.ts +21 -13
  18. package/templates/src/auth/api/_shared/wecom/store.ts +61 -12
  19. package/templates/src/auth/api/_shared/wecom/sync.ts +11 -1
  20. package/templates/src/auth/api/migrations/0003__credential_kind.mysql.sql +3 -0
  21. package/templates/src/auth/api/migrations/0003__credential_kind.pg.sql +3 -0
  22. package/templates/src/auth/api/migrations/0003__credential_kind.sql +9 -0
  23. package/templates/src/auth/api/schema.yaml +2 -0
  24. package/templates/src/auth/api/wecom/admin/api.ts +112 -11
  25. package/templates/src/auth/api/wecom/api.ts +102 -48
  26. package/templates/src/auth/api/wecom/sync/api.ts +21 -5
  27. package/templates/src/auth/web/__client/api.schemas.ts +22 -1
  28. package/templates/src/auth/web/__client/api.ts +47 -1
  29. package/templates/src/system/web/locales/en-US.json +24 -1
  30. package/templates/src/system/web/locales/zh-CN.json +24 -1
  31. package/templates/src/system/web/pages/departments/sync-bar.tsx +32 -7
  32. package/templates/src/system/web/pages/wecom/index.tsx +164 -46
  33. package/templates/tests/wecom-admin.spec.ts +7 -7
  34. package/templates/tests/wecom-connector.spec.ts +7 -6
  35. package/templates/tests/wecom-corp.spec.ts +523 -0
  36. package/templates/tests/wecom-endpoints.spec.ts +10 -4
  37. package/templates/tests/wecom-org-sync.spec.ts +7 -1
  38. package/vendor/host-versions.json +3 -3
@@ -0,0 +1,523 @@
1
+ // BDD:企业内部应用(自建)接入 + 「同一部署内两种来源共存」。
2
+ //
3
+ // 这套功能的价值全在**边界**上,所以这里钉的不是企微协议(协议在 wecom-connector.spec.ts),
4
+ // 而是六件容易错得很安静的事:
5
+ // 1. 判定口径是「变量填了没有」,不另设 wecom_mode;两套都填却没 allow_coexist 必须报错,
6
+ // 不能静默择一(迁移期最容易犯的就是「忘了清旧变量」)。
7
+ // 2. 回调验签要能**先于**任何身份凭据存在:引导期只有 callback token/aeskey 也要验得过,
8
+ // 否则「先有 suite 还是先有回调」的死锁又回来了。
9
+ // 3. 两种来源的回调钥匙不同源,同一条 /event 上只能逐个试签;命中哪套就按哪套分发。
10
+ // 4. 同企业双应用时,行要按 credential_kind 隔离:cancel_auth 只停 suite 行,
11
+ // 不能把同一家企业的自建应用一起停掉、更不能把人踢下线。
12
+ // 5. 「验证并接入」四步全过才落库:部门读不到 / 读不到人都必须是 400 且**不留半行**,
13
+ // 否则同步入口会带着一个空可见范围的应用一路 502。
14
+ // 6. 换 AgentId 重接时旧 corp 行要停用(旧 secret 已失效),但同企业 suite 行不受影响。
15
+ //
16
+ // 夹具即真实库(真迁移 + 真 sqlite):0003__credential_kind 的默认值也一起被这层覆盖到。
17
+ /* eslint-disable test/prefer-lowercase-title -- AC 编号是验收锚点,保留大写 */
18
+ import type { WecomStoreDeps } from "../src/auth/api/_shared/wecom/store";
19
+ import { Buffer } from "node:buffer";
20
+ import { createCipheriv, webcrypto } from "node:crypto";
21
+ import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
22
+ import { connectCorp } from "../src/auth/api/_shared/wecom/admin";
23
+ import { createWecomClient, runtimeCache, runtimeHttp } from "../src/auth/api/_shared/wecom/client";
24
+ import { callbackCreds, configuredKinds, corpIdentity, readBase, readCorpConfig, readSuiteConfig, wecomKinds } from "../src/auth/api/_shared/wecom/config";
25
+ import { bytesToB64, utf8Bytes, wecomSignature } from "../src/auth/api/_shared/wecom/crypt";
26
+ import { handleSuiteEvent } from "../src/auth/api/_shared/wecom/events";
27
+ import { findCorpAuth, findProfile, probeProfile, saveProfile } from "../src/auth/api/_shared/wecom/store";
28
+ import { installDb } from "./fixture";
29
+
30
+ const g = globalThis as any;
31
+ let dbo: ReturnType<typeof installDb>;
32
+
33
+ const SUITE = "suite_1";
34
+ const CORP = "ww_self";
35
+ const AGENT = "1000042";
36
+ const SUITE_TOKEN = "cb_token";
37
+ const CORP_TOKEN = "corp_cb_token";
38
+ /** 两套回调钥匙必须不同:同一条 /event 上「试签命中哪一套」才有意义 */
39
+ const SUITE_AES = webcrypto.getRandomValues(new Uint8Array(32));
40
+ const CORP_AES = webcrypto.getRandomValues(new Uint8Array(32));
41
+ const SUITE_AES_VAR = bytesToB64(SUITE_AES).replace(/=$/, "");
42
+ const CORP_AES_VAR = bytesToB64(CORP_AES).replace(/=$/, "");
43
+ const NOW = 1_700_000_000;
44
+
45
+ const deps: WecomStoreDeps = {
46
+ encrypt: (p: string) => `gcm(${p})`,
47
+ decrypt: (b: string) => b.replace(/^gcm\((.*)\)$/, "$1"),
48
+ now: () => NOW,
49
+ };
50
+
51
+ interface Captured { status: number | null, body: any, text: string | null, location: string | null }
52
+
53
+ let out: Captured;
54
+
55
+ function installJson(): void {
56
+ out = { status: null, body: null, text: null, location: null };
57
+ const redirect = (url: string, code = 302) => {
58
+ out.status = code;
59
+ out.location = url;
60
+ };
61
+ g.json = {
62
+ ok: (data?: unknown) => {
63
+ out.status = 200;
64
+ out.body = data;
65
+ },
66
+ fail: (code: number, msg: string) => {
67
+ out.status = code;
68
+ out.body = { code, msg };
69
+ },
70
+ header: () => {},
71
+ raw: (data: unknown) => {
72
+ out.status = 200;
73
+ out.body = data;
74
+ },
75
+ redirect: Object.assign(redirect, { found: (url: string) => redirect(url, 302), seeOther: (url: string) => redirect(url, 303) }),
76
+ stream: (opts?: { contentType?: string }) => {
77
+ out.status = 200;
78
+ return {
79
+ contentType: opts?.contentType ?? null,
80
+ write: (chunk: string) => {
81
+ out.text = (out.text ?? "") + chunk;
82
+ },
83
+ end: () => {},
84
+ };
85
+ },
86
+ };
87
+ }
88
+
89
+ /** 部署常量:**默认只配自建应用**——本文件的主角;需要代开发时由用例显式补上 */
90
+ function installVars(overrides: Record<string, string> = {}): void {
91
+ const base: Record<string, string> = {
92
+ wecom_corp_id: CORP,
93
+ wecom_corp_secret: "corp_secret",
94
+ wecom_corp_agent_id: AGENT,
95
+ wecom_corp_callback_token: CORP_TOKEN,
96
+ wecom_corp_encoding_aes_key: CORP_AES_VAR,
97
+ wecom_enc_key: "0123456789abcdef0123456789abcdef",
98
+ wecom_base_url: "https://site.example.com/api/auth/wecom",
99
+ ...overrides,
100
+ };
101
+ g.vars = { get: (name: string) => base[name] ?? null };
102
+ }
103
+
104
+ /** 代开发那一套(含回调钥匙),用于「两套同时存在」的用例 */
105
+ const SUITE_VARS: Record<string, string> = {
106
+ wecom_suite_id: SUITE,
107
+ wecom_suite_secret: "suite_secret",
108
+ wecom_callback_token: SUITE_TOKEN,
109
+ wecom_encoding_aes_key: SUITE_AES_VAR,
110
+ };
111
+
112
+ function installKv(): void {
113
+ const store = new Map<string, string>();
114
+ const counters = new Map<string, number>();
115
+ g.kv = {
116
+ get: async (k: string) => store.get(k) ?? null,
117
+ set: async (k: string, v: string) => {
118
+ store.set(k, v);
119
+ },
120
+ del: async (k: string) => {
121
+ store.delete(k);
122
+ counters.delete(k);
123
+ },
124
+ expire: async () => 1,
125
+ incr: async (k: string) => {
126
+ const n = (counters.get(k) ?? 0) + 1;
127
+ counters.set(k, n);
128
+ return n;
129
+ },
130
+ };
131
+ }
132
+
133
+ function installCrypto(): void {
134
+ Object.defineProperty(globalThis, "crypto", {
135
+ value: {
136
+ randomHex: (n = 32) => `r${"a".repeat(n * 2)}`,
137
+ sha256Hex: (s: string) => `sha(${s})`,
138
+ aesGcmEncrypt: (plain: string) => `gcm(${plain})`,
139
+ aesGcmDecrypt: (blob: string) => blob.replace(/^gcm\((.*)\)$/, "$1"),
140
+ },
141
+ configurable: true,
142
+ });
143
+ }
144
+
145
+ function request(opts: { query?: Record<string, string>, body?: unknown, headers?: Record<string, string>, user?: { id: number } } = {}): void {
146
+ g.http = { method: "POST", query: opts.query ?? {}, body: opts.body ?? null, headers: opts.headers ?? {}, ...(opts.user ? { user: opts.user } : {}) };
147
+ }
148
+
149
+ /**
150
+ * 企微 webhook 密文(random|len|msg|receiveid → AES-256-CBC,PKCS#7 补到 32 字节)。
151
+ * 与 wecom-endpoints.spec.ts 同一实现:用 node:crypto 关掉自动补位自己按 32 补,不拿自实现给自己背书。
152
+ */
153
+ async function seal(plain: string, key: Uint8Array, receiveId: string): Promise<string> {
154
+ const msg = utf8Bytes(plain);
155
+ const head = webcrypto.getRandomValues(new Uint8Array(16));
156
+ const len = new Uint8Array(4);
157
+ new DataView(len.buffer).setUint32(0, msg.length);
158
+ const body = Buffer.concat([Buffer.from(head), Buffer.from(len), Buffer.from(msg), Buffer.from(utf8Bytes(receiveId))]);
159
+ const pad = 32 - (body.length % 32);
160
+ const cipher = createCipheriv("aes-256-cbc", Buffer.from(key), Buffer.from(key.subarray(0, 16)));
161
+ cipher.setAutoPadding(false);
162
+ return Buffer.concat([cipher.update(Buffer.concat([body, Buffer.alloc(pad, pad)])), cipher.final()]).toString("base64");
163
+ }
164
+
165
+ /** 已落库的一行(suite 行 suite_id 有值、corp 行为空串,主键三段) */
166
+ function seedRow(kind: "suite" | "corp", opts: { corpId?: string, agentId?: string, status?: string } = {}): void {
167
+ const corpId = opts.corpId ?? CORP;
168
+ const agentId = opts.agentId ?? AGENT;
169
+ dbo.prepare("INSERT INTO wecom_app_profile (suite_id, corp_id, agent_id, auth_corp_id, env, corp_name, status, credential_kind, permanent_code_enc, installed_at, update_time) VALUES (?, ?, ?, ?, 'prod', NULL, ?, ?, ?, ?, ?)")
170
+ .run(kind === "suite" ? SUITE : "", corpId, agentId, corpId, opts.status ?? "active", kind, `gcm(secret_${kind}_${agentId})`, NOW, NOW);
171
+ }
172
+
173
+ function rowsOf(corpId = CORP): Array<{ agent_id: string, status: string, credential_kind: string, suite_id: string }> {
174
+ return dbo.prepare("SELECT agent_id, status, credential_kind, suite_id FROM wecom_app_profile WHERE corp_id = ? ORDER BY credential_kind, agent_id").all(corpId) as any[];
175
+ }
176
+
177
+ function mkSuperAdmin(): number {
178
+ dbo.prepare("INSERT INTO users (username, password_hash) VALUES ('u_super', 'x')").run();
179
+ const id = (dbo.prepare("SELECT id FROM users WHERE username = 'u_super'").get() as { id: number }).id;
180
+ dbo.prepare("INSERT INTO sys_user_role (user_id, role_name) VALUES (?, 'superadmin')").run(id);
181
+ return id;
182
+ }
183
+
184
+ /** 上游桩:按路径返回,`calls` 供断言「打了几次 / 打了哪些」 */
185
+ function stubUpstream(opts: { depts?: Array<{ id: string, name?: string }>, membersByDept?: Record<string, Array<{ userid: string }>> } = {}): { calls: string[] } {
186
+ const calls: string[] = [];
187
+ const depts = opts.depts ?? [{ id: "1", name: "总部" }];
188
+ const members = opts.membersByDept ?? { 1: [{ userid: "zhangsan" }] };
189
+ g.fetch = async (url: string) => {
190
+ calls.push(url);
191
+ const json = async () => {
192
+ if (url.includes("/cgi-bin/gettoken"))
193
+ return { errcode: 0, access_token: "tk", expires_in: 7200 };
194
+ if (url.includes("/cgi-bin/department/simplelist"))
195
+ return { errcode: 0, department_id: depts.map(d => ({ id: Number(d.id), name: d.name ?? `部门${d.id}`, parentid: 0, order: 0 })) };
196
+ if (url.includes("/cgi-bin/user/simplelist")) {
197
+ const id = new URL(url).searchParams.get("department_id") ?? "";
198
+ return { errcode: 0, userlist: (members[id] ?? []).map(u => ({ userid: u.userid, department: [Number(id)] })) };
199
+ }
200
+ if (url.includes("/cgi-bin/department/get")) {
201
+ const id = new URL(url).searchParams.get("id") ?? "";
202
+ // 上游详情优先于事件自带字段(contacts.ts::applyDeptEvent 有据):桩里也照这个形状给名字
203
+ return { errcode: 0, department: { id: Number(id), name: depts.find(d => d.id === id)?.name ?? `部门${id}`, parentid: 0, order: 0 } };
204
+ }
205
+ throw new Error(`stub 未预期:${url}`);
206
+ };
207
+ return { ok: true, json };
208
+ };
209
+ return { calls };
210
+ }
211
+
212
+ const wecomApi = async () => (await import("../src/auth/api/wecom/api")).default;
213
+ const adminApi = async () => (await import("../src/auth/api/wecom/admin/api")).default;
214
+
215
+ /** 回调 timestamp 必须新鲜:重放守卫拒 ±5 分钟以外的报文,用固定值会让整段用例假红 */
216
+ const nowSecs = () => String(Math.floor(Date.now() / 1000));
217
+
218
+ beforeAll(() => {
219
+ dbo = installDb();
220
+ g.log = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} };
221
+ g.db.asTenant = (id: string) => {
222
+ if (g.http?.tenantId)
223
+ throw new Error("tenant already set");
224
+ g.http = { ...(g.http ?? {}), tenantId: id };
225
+ };
226
+ });
227
+
228
+ beforeEach(() => {
229
+ installJson();
230
+ installVars();
231
+ installKv();
232
+ installCrypto();
233
+ stubUpstream();
234
+ for (const t of ["wecom_app_profile", "wecom_suite_ticket", "sys_refresh_token", "sys_oplog", "sys_department", "sys_dept_member", "sys_user_role", "users"])
235
+ dbo.exec(`DELETE FROM ${t}`);
236
+ });
237
+
238
+ afterEach(() => {
239
+ g.http = {};
240
+ });
241
+
242
+ describe("C-A:来源判定(变量填了没有,不另设模式开关)", () => {
243
+ it("只配自建三项 → kinds=[corp];suite 流程才报缺 suite 凭据,登录/回调不受牵连", () => {
244
+ expect(wecomKinds()).toEqual(["corp"]);
245
+ expect(configuredKinds()).toEqual(["corp"]);
246
+ expect(readBase()).toMatchObject({ baseUrl: "https://site.example.com/api/auth/wecom" });
247
+ expect(readCorpConfig()).toMatchObject({ corpId: CORP, agentId: AGENT, corpSecret: "corp_secret" });
248
+ // suite 专属流程仍要点名缺什么(readBase 只关登录/回跳,不该被 suite 凭据绑架)
249
+ expect(() => readSuiteConfig()).toThrow(/wecom_suite_id/);
250
+ });
251
+
252
+ it("两套都配齐但没显式 allow_coexist → 400 点名该变量(不静默择一)", () => {
253
+ installVars(SUITE_VARS);
254
+ expect(() => wecomKinds()).toThrow(/wecom_allow_coexist/);
255
+ // 回调侧同样拒绝:静默挑一套验签会让运维以为「另一套没生效」
256
+ expect(() => callbackCreds()).toThrow(/wecom_allow_coexist/);
257
+ });
258
+
259
+ it("两套 + allow_coexist=true → 两种来源都启用;回调凭据代开发优先,receiver 各是自己的身份", () => {
260
+ installVars({ ...SUITE_VARS, wecom_allow_coexist: "true" });
261
+ expect(wecomKinds()).toEqual(["suite", "corp"]);
262
+ expect(callbackCreds().map(c => [c.kind, c.receiver])).toEqual([["suite", SUITE], ["corp", CORP]]);
263
+ });
264
+
265
+ it("引导期(suite_id 未下发、身份凭据一项不齐)→ 回调凭据仍可用,receiver 留空", () => {
266
+ // 只填回调钥匙 + 基址:这是「先建回调、后建模版」的唯一可行顺序(plan §18)
267
+ installVars({ wecom_corp_id: "", wecom_corp_secret: "", wecom_corp_agent_id: "", wecom_corp_callback_token: "", wecom_corp_encoding_aes_key: "", ...SUITE_VARS, wecom_suite_id: "", wecom_suite_secret: "" });
268
+ // 严格口径此时「一种来源都没启用」(没有完整 suite 也没有 corp 三项)
269
+ expect(() => wecomKinds()).toThrow(/二选一/);
270
+ // 但回调验签不该跟着失败,否则企微后台点「保存」当场 400
271
+ expect(callbackCreds()).toEqual([{ kind: "suite", token: SUITE_TOKEN, encodingAesKey: SUITE_AES_VAR, receiver: "" }]);
272
+ });
273
+
274
+ it("半配(填了 corp_id 没填 secret)→ configuredKinds 为空,后台能显示「差什么」而不抛", () => {
275
+ installVars({ wecom_corp_secret: "" });
276
+ expect(configuredKinds()).toEqual([]);
277
+ expect(corpIdentity()).toBeNull();
278
+ });
279
+ });
280
+
281
+ describe("C-B:同企业双应用的档案隔离", () => {
282
+ it("按来源取行:suite 行的 secret 与 corp 行的 secret 不串用", async () => {
283
+ seedRow("suite", { agentId: "100001" });
284
+ seedRow("corp");
285
+ expect((await findProfile(CORP, "100001", deps, "suite")).permanentCode).toBe("secret_suite_100001");
286
+ expect((await findProfile(CORP, AGENT, deps, "corp")).permanentCode).toBe(`secret_corp_${AGENT}`);
287
+ expect(await findCorpAuth(CORP, deps, "corp")).toMatchObject({ credentialKind: "corp", permanentCode: `secret_corp_${AGENT}`, agentIds: [AGENT] });
288
+ expect(await findCorpAuth(CORP, deps, "suite")).toMatchObject({ credentialKind: "suite", agentIds: ["100001"] });
289
+ });
290
+
291
+ it("不带 kind 时行为与单来源部署一致(同一企业两行 → 视为多应用歧义)", async () => {
292
+ seedRow("suite", { agentId: "100001" });
293
+ seedRow("corp");
294
+ await expect(findProfile(CORP, null, deps)).rejects.toMatchObject({ status: 400 });
295
+ expect(await probeProfile(CORP, AGENT)).toMatchObject({ credentialKind: "corp", authMode: 0 });
296
+ });
297
+
298
+ it("cancel_auth:只停 suite 行,自建应用保持启用,且不吊销会话", async () => {
299
+ installVars({ ...SUITE_VARS, wecom_allow_coexist: "true" });
300
+ seedRow("suite", { agentId: "100001" });
301
+ seedRow("corp");
302
+ dbo.prepare("INSERT INTO sys_refresh_token (token_hash, tenant_id, user_id, expires_at, revoked, created_at) VALUES ('h1', ?, 1, ?, 0, ?)").run(CORP, NOW + 100, NOW);
303
+ const { calls } = stubUpstream();
304
+
305
+ // 走事件入口的真路径:cancel_auth → disableCorp → setProfileStatus(kind=suite) → revokeOrKeep
306
+ await handleSuiteEvent("<xml><InfoType>cancel_auth</InfoType><AuthCorpId>ww_self</AuthCorpId></xml>", {
307
+ cfg: readSuiteConfig(),
308
+ client: createWecomClient({ http: runtimeHttp(), cache: runtimeCache() }),
309
+ store: deps,
310
+ });
311
+
312
+ expect(rowsOf()).toEqual([
313
+ { agent_id: AGENT, status: "active", credential_kind: "corp", suite_id: "" },
314
+ { agent_id: "100001", status: "disabled", credential_kind: "suite", suite_id: SUITE },
315
+ ]);
316
+ // 会话还在:这家企业的自建应用仍在线,不该被「取消代开发授权」连带踢下线
317
+ expect(dbo.prepare("SELECT COUNT(*) AS n FROM sys_refresh_token WHERE tenant_id = ?").get(CORP)).toMatchObject({ n: 1 });
318
+ // 停用要连带清 token 缓存(旧 secret 可能被缓存在另一状态),但不该打上游
319
+ expect(calls).toEqual([]);
320
+ });
321
+
322
+ it("该企业一行都不活跃时 cancel_auth 仍吊销该租户会话(安全动作不因来源而失效)", async () => {
323
+ installVars({ ...SUITE_VARS, wecom_allow_coexist: "true" });
324
+ seedRow("suite", { agentId: "100001" });
325
+ dbo.prepare("INSERT INTO sys_refresh_token (token_hash, tenant_id, user_id, expires_at, revoked, created_at) VALUES ('h1', ?, 1, ?, 0, ?)").run(CORP, NOW + 100, NOW);
326
+
327
+ await handleSuiteEvent("<xml><InfoType>cancel_auth</InfoType><AuthCorpId>ww_self</AuthCorpId></xml>", {
328
+ cfg: readSuiteConfig(),
329
+ client: createWecomClient({ http: runtimeHttp(), cache: runtimeCache() }),
330
+ store: deps,
331
+ });
332
+
333
+ expect(dbo.prepare("SELECT COUNT(*) AS n FROM sys_refresh_token WHERE tenant_id = ?").get(CORP)).toMatchObject({ n: 0 });
334
+ });
335
+ });
336
+
337
+ describe("C-C:自建应用「验证并接入」(四步全过才落库)", () => {
338
+ it("全通过 → 落一行 corp 档案(suite_id 空 / credential_kind=corp / auth_mode=0)", async () => {
339
+ const { calls } = stubUpstream({ depts: [{ id: "1" }, { id: "2" }], membersByDept: { 2: [{ userid: "lisi" }] } });
340
+ const cfg = readCorpConfig();
341
+
342
+ const r = await connectCorp(cfg, createWecomClient({ http: runtimeHttp(), cache: runtimeCache() }), deps);
343
+
344
+ expect(r).toEqual({ corpId: CORP, agentId: AGENT, departments: 2, members: 1 });
345
+ expect(rowsOf()).toEqual([{ agent_id: AGENT, status: "active", credential_kind: "corp", suite_id: "" }]);
346
+ const row = dbo.prepare("SELECT permanent_code_enc, auth_mode FROM wecom_app_profile WHERE corp_id = ?").get(CORP) as any;
347
+ // secret 只以密文落库(与代开发的 permanent_code 同一口径)
348
+ expect(row.permanent_code_enc).toBe("gcm(corp_secret)");
349
+ expect(row.auth_mode).toBe(0);
350
+ // 先作废 token 缓存再换:换过 Secret 的部署命中旧缓存会让校验「假通过」
351
+ expect(calls[0]).toContain("gettoken");
352
+ });
353
+
354
+ it("可见范围里没有部门 → 400 且不留半行", async () => {
355
+ stubUpstream({ depts: [] });
356
+ await expect(connectCorp(readCorpConfig(), createWecomClient({ http: runtimeHttp(), cache: runtimeCache() }), deps))
357
+ .rejects
358
+ .toMatchObject({ status: 400 });
359
+ expect(rowsOf()).toEqual([]);
360
+ });
361
+
362
+ it("顶层部门没人不算失败:往后探到有人的那层再下结论", async () => {
363
+ // 企微的顶层部门常常只有子部门、没有直属成员,只看第一个会误判「读不到人」
364
+ const { calls } = stubUpstream({ depts: [{ id: "1" }, { id: "2" }], membersByDept: { 2: [{ userid: "lisi" }] } });
365
+ const r = await connectCorp(readCorpConfig(), createWecomClient({ http: runtimeHttp(), cache: runtimeCache() }), deps);
366
+ expect(r.members).toBe(1);
367
+ expect(calls.filter(u => u.includes("user/simplelist"))).toHaveLength(2);
368
+ });
369
+
370
+ it("探满 10 个部门都没人 → 400,且不再继续打上游", async () => {
371
+ const depts = Array.from({ length: 12 }, (_, i) => ({ id: String(i + 1) }));
372
+ const { calls } = stubUpstream({ depts, membersByDept: {} });
373
+ await expect(connectCorp(readCorpConfig(), createWecomClient({ http: runtimeHttp(), cache: runtimeCache() }), deps))
374
+ .rejects
375
+ .toMatchObject({ status: 400, message: expect.stringContaining("读不到成员") });
376
+ expect(calls.filter(u => u.includes("user/simplelist"))).toHaveLength(10);
377
+ expect(rowsOf()).toEqual([]);
378
+ });
379
+
380
+ it("换 AgentId 重接 → 旧 corp 行停用、同企业 suite 行不动", async () => {
381
+ const { agentId } = readCorpConfig();
382
+ seedRow("corp", { agentId: "1000001" });
383
+ seedRow("suite", { agentId: "100001" });
384
+ const { calls } = stubUpstream();
385
+
386
+ await connectCorp(readCorpConfig(), createWecomClient({ http: runtimeHttp(), cache: runtimeCache() }), deps);
387
+
388
+ // ORDER BY credential_kind, agent_id:corp 两行按 agent 排在前,suite 行在后
389
+ expect(rowsOf()).toEqual([
390
+ { agent_id: "1000001", status: "disabled", credential_kind: "corp", suite_id: "" },
391
+ { agent_id: agentId, status: "active", credential_kind: "corp", suite_id: "" },
392
+ { agent_id: "100001", status: "active", credential_kind: "suite", suite_id: SUITE },
393
+ ]);
394
+ // 只换了新应用的 token(旧应用的作废走缓存 del,不打上游)
395
+ expect(calls.filter(u => u.includes("gettoken"))).toHaveLength(1);
396
+ });
397
+ });
398
+
399
+ describe("C-D:后台端点(overview / connect)", () => {
400
+ it("overview 在配置缺项时仍返回 200 并给出 configError(页面要能显示「差什么」)", async () => {
401
+ const uid = mkSuperAdmin();
402
+ installVars({ wecom_corp_secret: "" });
403
+ request({ user: { id: uid } });
404
+ await (await adminApi()).overview.get();
405
+ expect(out.status).toBe(200);
406
+ expect(out.body.kinds).toEqual([]);
407
+ expect(out.body.configError).toMatch(/wecom_corp_secret/);
408
+ });
409
+
410
+ it("overview 正常态:来源、回调地址(base+/event)、企业身份、回调凭据是否齐", async () => {
411
+ const uid = mkSuperAdmin();
412
+ request({ user: { id: uid } });
413
+ await (await adminApi()).overview.get();
414
+ expect(out.body).toMatchObject({
415
+ kinds: ["corp"],
416
+ callbackUrl: "https://site.example.com/api/auth/wecom/event",
417
+ corpId: CORP,
418
+ agentId: AGENT,
419
+ corpCallbackConfigured: true,
420
+ configError: null,
421
+ });
422
+ });
423
+
424
+ it("未配 corp 三项时 connect → 400,且不落库", async () => {
425
+ const uid = mkSuperAdmin();
426
+ installVars({ wecom_corp_agent_id: "" });
427
+ request({ user: { id: uid } });
428
+ await (await adminApi()).connect.post();
429
+ expect(out.status).toBe(400);
430
+ expect(out.body.msg).toMatch(/wecom_corp_agent_id/);
431
+ expect(rowsOf()).toEqual([]);
432
+ });
433
+ });
434
+
435
+ describe("C-E:/event 按命中凭据分发", () => {
436
+ /** 用自建应用那套钥匙造一条通讯录部门事件(receiveid = corpid,与真实报文一致) */
437
+ async function corpSignedDeptEvent(): Promise<{ query: Record<string, string>, body: string }> {
438
+ const plain = `<xml><ToUserName>${CORP}</ToUserName><MsgType>event</MsgType><Event>change_contact</Event><ChangeType>update_party</ChangeType><Id>2</Id><Name>研发一组</Name></xml>`;
439
+ const encrypted = await seal(plain, CORP_AES, CORP);
440
+ const ts = nowSecs();
441
+ const nonce = "n_corp_1";
442
+ return {
443
+ query: { msg_signature: wecomSignature(CORP_TOKEN, ts, nonce, encrypted), timestamp: ts, nonce },
444
+ body: `<xml><ToUserName>${CORP}</ToUserName><Encrypt><![CDATA[${encrypted}]]></Encrypt></xml>`,
445
+ };
446
+ }
447
+
448
+ it("corp 钥匙签的事件 → 进通讯录分支(落部门),不回 400 签名失败", async () => {
449
+ stubUpstream({ depts: [{ id: "1" }, { id: "2", name: "研发一组" }] });
450
+ seedRow("corp");
451
+ const ev = await corpSignedDeptEvent();
452
+ request({ query: ev.query, body: ev.body });
453
+
454
+ await (await wecomApi()).event.post();
455
+
456
+ expect(out.status).toBe(200);
457
+ expect(out.text).toBe("success");
458
+ // 事件路径落地:部门已按企微 id 建档(证明走的是 contacts,不是 handleSuiteEvent 的 switch)
459
+ expect(dbo.prepare("SELECT origin_id, name FROM sys_department WHERE origin = 'wecom'").get()).toMatchObject({ origin_id: "2", name: "研发一组" });
460
+ // 自建应用没有套件事件:不该写 suite_ticket 表
461
+ expect(dbo.prepare("SELECT COUNT(*) AS n FROM wecom_suite_ticket").get()).toMatchObject({ n: 0 });
462
+ });
463
+
464
+ it("共存部署里两套钥匙都配时,corp 事件仍能被认出来(逐个试签)", async () => {
465
+ installVars({ ...SUITE_VARS, wecom_allow_coexist: "true" });
466
+ seedRow("corp");
467
+ const ev = await corpSignedDeptEvent();
468
+ request({ query: ev.query, body: ev.body });
469
+
470
+ await (await wecomApi()).event.post();
471
+
472
+ expect(out.text).toBe("success");
473
+ expect(dbo.prepare("SELECT origin_id FROM sys_department WHERE origin = 'wecom'").get()).toMatchObject({ origin_id: "2" });
474
+ });
475
+
476
+ it("签名换成另一套 token → 400(两套 token 不同源,不能互相顶替)", async () => {
477
+ const ev = await corpSignedDeptEvent();
478
+ const forged = wecomSignature(SUITE_TOKEN, ev.query.timestamp!, ev.query.nonce!, "whatever");
479
+ request({ query: { ...ev.query, msg_signature: forged }, body: ev.body });
480
+
481
+ await (await wecomApi()).event.post();
482
+ expect(out.status).toBe(400);
483
+ expect(out.body.msg).toMatch(/签名/);
484
+ });
485
+
486
+ it("GET /event 在只有 corp 回调钥匙时也能过 URL 验证", async () => {
487
+ const echo = await seal("echo_corp", CORP_AES, CORP);
488
+ const ts = nowSecs();
489
+ const nonce = "n_verify";
490
+ request({ query: { msg_signature: wecomSignature(CORP_TOKEN, ts, nonce, echo), timestamp: ts, nonce, echostr: echo } });
491
+
492
+ await (await wecomApi()).event.get();
493
+ expect(out.status).toBe(200);
494
+ expect(out.text).toBe("echo_corp");
495
+ });
496
+ });
497
+
498
+ describe("C-F:登录入口认自己那家企业", () => {
499
+ it("共存部署里本站 /login 落到配置的企业,不被别家代开发档案淹掉", async () => {
500
+ // 别家企业通过代开发装进来一行;本站自己是自建应用
501
+ installVars({ ...SUITE_VARS, wecom_allow_coexist: "true" });
502
+ seedRow("suite", { corpId: "ww_other", agentId: "100009" });
503
+ await saveProfile({
504
+ suiteId: "",
505
+ corpId: CORP,
506
+ authCorpId: CORP,
507
+ agentId: AGENT,
508
+ env: "prod",
509
+ corpName: null,
510
+ credentialKind: "corp",
511
+ permanentCode: "corp_secret",
512
+ authMode: 0,
513
+ }, deps);
514
+
515
+ request({ query: {}, headers: { "user-agent": "Mozilla/5.0 Chrome/120" } });
516
+ await (await wecomApi()).login.get();
517
+
518
+ expect(out.status).toBe(302);
519
+ // 扫码链接必须指向本站企业 + 本站应用,否则管理员扫了会装到别家
520
+ expect(out.location).toContain(`appid=${CORP}`);
521
+ expect(out.location).toContain(`agentid=${AGENT}`);
522
+ });
523
+ });
@@ -179,21 +179,27 @@ afterEach(() => {
179
179
 
180
180
  describe("P1-I:GET /auth/wecom/login(发起授权)", () => {
181
181
  it("部署常量缺失 → 400 信封并点出缺哪几个 vars(不是 500)", async () => {
182
- installVars({ wecom_suite_id: "", wecom_enc_key: "" });
182
+ // 登录只依赖「与来源无关」的两项(落库密钥 + 基址):suite/corp 凭据不进必填集合,
183
+ // 否则自建应用的部署会在读 wecom_suite_id 处直接 400(两种来源就没法共存了)。
184
+ installVars({ wecom_enc_key: "", wecom_base_url: "" });
183
185
  request({ query: { corp: "ww_corp_a" } });
184
186
  await (await api()).login.get();
185
187
  expect(out.status).toBe(400);
186
- expect(out.body.msg).toMatch(/wecom_suite_id/);
188
+ expect(out.body.msg).toMatch(/wecom_enc_key/);
189
+ expect(out.body.msg).toMatch(/wecom_base_url/);
187
190
  expect(out.location).toBeNull();
188
191
  });
189
192
 
190
193
  // 代开发模板常只服务一家企业:corp 缺省时从已安装档案自动解析,好让「应用主页」直接填
191
194
  // `https://<域名>/login`(企微后台会因 URL 里的 `${corpid}` 占位符字符拒收)。三种情形分开钉。
192
- it("缺企业代码且一家都没装 → 400,指向「生成开通链接」(不猜默认企业)", async () => {
195
+ it("缺企业代码且一家都没装 → 400,同时给出两种来源的下一步(不猜默认企业)", async () => {
193
196
  request({ query: {} });
194
197
  await (await api()).login.get();
195
198
  expect(out.status).toBe(400);
196
- expect(out.body.msg).toMatch(/尚未完成企业微信授权/);
199
+ expect(out.body.msg).toMatch(/尚未完成企业微信接入/);
200
+ // 文案要能分诊:代开发去扫码开通,自建应用去后台点「验证并接入」
201
+ expect(out.body.msg).toMatch(/代开发/);
202
+ expect(out.body.msg).toMatch(/验证并接入/);
197
203
  });
198
204
 
199
205
  it("缺企业代码但只装了一家 → 302 到该企业的授权地址(省掉手填企业代码)", async () => {
@@ -184,6 +184,7 @@ describe("B1-B:授权模式(auth_mode)", () => {
184
184
  agentId: "100001",
185
185
  env: "prod",
186
186
  corpName: "测试企业",
187
+ credentialKind: "suite",
187
188
  permanentCode: "perm_a",
188
189
  authMode: 1,
189
190
  }, storeDeps());
@@ -199,6 +200,7 @@ describe("B1-B:授权模式(auth_mode)", () => {
199
200
  agentId: "100001",
200
201
  env: "prod",
201
202
  corpName: null,
203
+ credentialKind: "suite",
202
204
  permanentCode: "perm_a",
203
205
  } as never, storeDeps());
204
206
  expect((await findProfile("ww_corp_a", "100001", storeDeps())).authMode).toBe(0);
@@ -212,6 +214,7 @@ describe("B1-B:授权模式(auth_mode)", () => {
212
214
  agentId: "100001",
213
215
  env: "prod",
214
216
  corpName: "测试企业",
217
+ credentialKind: "suite",
215
218
  permanentCode: "perm_a",
216
219
  authMode: 0,
217
220
  }, storeDeps());
@@ -241,6 +244,7 @@ describe("B1-B:授权模式(auth_mode)", () => {
241
244
  agentId: "100001",
242
245
  env: "prod",
243
246
  corpName: "测试企业",
247
+ credentialKind: "suite",
244
248
  permanentCode: "perm_a",
245
249
  authMode: 0,
246
250
  }, storeDeps());
@@ -266,6 +270,7 @@ describe("B1-D:授权探针(同步入口的准入事实)", () => {
266
270
  agentId,
267
271
  env: "prod",
268
272
  corpName: null,
273
+ credentialKind: "suite",
269
274
  permanentCode: `perm_${agentId}`,
270
275
  authMode,
271
276
  }, storeDeps());
@@ -276,7 +281,7 @@ describe("B1-D:授权探针(同步入口的准入事实)", () => {
276
281
 
277
282
  it("装了 → 拿得到 agent + 授权模式;带 agent 可收窄,指错应用 → null", async () => {
278
283
  await profile("100001", 1);
279
- expect(await probeProfile("ww_corp_a", null)).toEqual({ agentId: "100001", authMode: 1, status: "active" });
284
+ expect(await probeProfile("ww_corp_a", null)).toEqual({ agentId: "100001", authMode: 1, status: "active", credentialKind: "suite" });
280
285
  expect(await probeProfile("ww_corp_a", "100001")).toMatchObject({ authMode: 1 });
281
286
  expect(await probeProfile("ww_corp_a", "200002")).toBeNull();
282
287
  });
@@ -447,6 +452,7 @@ describe("C1:通讯录事件增量(change_contact)", () => {
447
452
  agentId: "100001",
448
453
  env: "prod",
449
454
  corpName: "测试企业",
455
+ credentialKind: "suite",
450
456
  permanentCode: "perm_a",
451
457
  authMode: 0,
452
458
  }, storeDeps());
@@ -8,9 +8,9 @@
8
8
  "react-router": "7.18.2",
9
9
  "react-router/dom": "7.18.2",
10
10
  "@tanstack/react-query": "5.102.8",
11
- "@oj-module/runtime": "0.2.49",
12
- "@oj-module/runtime/contract": "0.2.49",
13
- "@oj-module/runtime/contract/errors": "0.2.49",
11
+ "@oj-module/runtime": "0.2.50",
12
+ "@oj-module/runtime/contract": "0.2.50",
13
+ "@oj-module/runtime/contract/errors": "0.2.50",
14
14
  "@plane/kit": "0.1.20",
15
15
  "@plane/editor": "0.1.20",
16
16
  "antd": "6.6.1",