@oj-module/cli 0.2.48 → 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 (45) hide show
  1. package/devkit/README.md +9 -7
  2. package/devkit/SKILL.md +53 -14
  3. package/devkit/manual.md +203 -3
  4. package/devkit/wecom-daikaifa.md +100 -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/__tests__/build-backend.test.ts +80 -0
  11. package/src/build.ts +50 -0
  12. package/src/init.ts +5 -4
  13. package/templates/config.yaml +28 -6
  14. package/templates/openapi.json +174 -2
  15. package/templates/src/auth/api/_shared/wecom/admin.ts +87 -7
  16. package/templates/src/auth/api/_shared/wecom/client.ts +42 -6
  17. package/templates/src/auth/api/_shared/wecom/config.ts +256 -46
  18. package/templates/src/auth/api/_shared/wecom/contacts.ts +7 -1
  19. package/templates/src/auth/api/_shared/wecom/events.ts +25 -6
  20. package/templates/src/auth/api/_shared/wecom/login.ts +44 -4
  21. package/templates/src/auth/api/_shared/wecom/replay.ts +44 -3
  22. package/templates/src/auth/api/_shared/wecom/store.ts +61 -12
  23. package/templates/src/auth/api/_shared/wecom/sync.ts +25 -1
  24. package/templates/src/auth/api/migrations/0003__credential_kind.mysql.sql +3 -0
  25. package/templates/src/auth/api/migrations/0003__credential_kind.pg.sql +3 -0
  26. package/templates/src/auth/api/migrations/0003__credential_kind.sql +9 -0
  27. package/templates/src/auth/api/schema.yaml +2 -0
  28. package/templates/src/auth/api/wecom/admin/api.ts +112 -11
  29. package/templates/src/auth/api/wecom/api.ts +151 -59
  30. package/templates/src/auth/api/wecom/sync/api.ts +21 -5
  31. package/templates/src/auth/web/__client/api.schemas.ts +22 -1
  32. package/templates/src/auth/web/__client/api.ts +47 -1
  33. package/templates/src/login/web/locales/en-US.json +2 -0
  34. package/templates/src/login/web/locales/zh-CN.json +2 -0
  35. package/templates/src/login/web/pages/login.tsx +39 -3
  36. package/templates/src/system/web/locales/en-US.json +24 -1
  37. package/templates/src/system/web/locales/zh-CN.json +24 -1
  38. package/templates/src/system/web/pages/departments/sync-bar.tsx +32 -7
  39. package/templates/src/system/web/pages/wecom/index.tsx +164 -46
  40. package/templates/tests/wecom-admin.spec.ts +7 -7
  41. package/templates/tests/wecom-connector.spec.ts +38 -8
  42. package/templates/tests/wecom-corp.spec.ts +523 -0
  43. package/templates/tests/wecom-endpoints.spec.ts +73 -7
  44. package/templates/tests/wecom-org-sync.spec.ts +7 -1
  45. package/vendor/host-versions.json +3 -3
@@ -21,7 +21,7 @@ import {
21
21
  eventUrlOf,
22
22
  loginRedirectOf,
23
23
  readCallbackConfig,
24
- readWecomConfig,
24
+ readSuiteConfig,
25
25
  siteRootOf,
26
26
  } from "../src/auth/api/_shared/wecom/config";
27
27
  import { envOfState, handleSuiteEvent } from "../src/auth/api/_shared/wecom/events";
@@ -105,6 +105,7 @@ function profile(suiteCorpAgent: { corp?: string, agent?: string, env?: string,
105
105
  agentId: suiteCorpAgent.agent ?? "100001",
106
106
  env: suiteCorpAgent.env ?? "prod",
107
107
  corpName: "测试企业",
108
+ credentialKind: "suite" as const,
108
109
  permanentCode: suiteCorpAgent.code ?? "perm_a",
109
110
  };
110
111
  }
@@ -426,17 +427,46 @@ describe("P1-F:凭证链(client:缓存 + 失效重试 + 错误两态)",
426
427
  await expect(client.userIdByCode("c", "a", "p", "CODE")).rejects.toMatchObject({ status: 502 });
427
428
  });
428
429
 
429
- it("get_permanent_code 缺 corpid / agent 列表为空 → 502(不静默落一行空 profile)", async () => {
430
+ it("get_permanent_code 缺 corpid / agent 列表为空且复核也没有 → 502(不静默落一行空 profile)", async () => {
430
431
  const noAgent = createWecomClient({
432
+ // suite_access_token 由调用方传入,所以这里的应答从 get_permanent_code 开始
431
433
  http: stubHttp([
432
- { suite_access_token: "sat", expires_in: 7200 },
433
434
  { permanent_code: "perm", auth_corp_info: { corpid: "ww_x" }, auth_info: { agent: [] } },
435
+ // 回源复核(get_auth_info)同样没有应用 → 这时才判死
436
+ { auth_info: { agent: [] } },
434
437
  ]),
435
438
  cache: fakeCache(),
436
439
  });
437
440
  await expect(noAgent.permanentCodeByAuthCode("sat", "AUTH")).rejects.toMatchObject({ status: 502 });
438
441
  });
439
442
 
443
+ // 2026-11-01 生产日志:create_auth 拿到永久码,但 auth_info.agent 是空的 → 上一版直接 502,
444
+ // 企业管理员看到「应用安装失败」,而永久码白白作废(auth_code 一次有效)。
445
+ it("get_permanent_code 的 agent 为空 → 拿刚换到的永久码回源 get_auth_info 补齐", async () => {
446
+ const http = stubHttp([
447
+ { permanent_code: "perm", auth_corp_info: { corpid: "ww_x" }, auth_info: { agent: [] } },
448
+ { auth_info: { agent: [{ agentid: 1000012, auth_mode: 1 }] } },
449
+ ]);
450
+ const client = createWecomClient({ http, cache: fakeCache() });
451
+
452
+ const granted = await client.permanentCodeByAuthCode("sat", "AUTH");
453
+
454
+ expect(granted.agentIds).toEqual(["1000012"]);
455
+ expect(granted.authModes["1000012"]).toBe(1);
456
+ expect(http.calls.some(u => u.includes("/service/get_auth_info"))).toBe(true);
457
+ });
458
+
459
+ it("auth_info.agent 给成单个对象(不是数组)也认——少一次「明明有应用却判成没授权」", async () => {
460
+ const client = createWecomClient({
461
+ http: stubHttp([
462
+ { permanent_code: "perm", auth_corp_info: { corpid: "ww_x" }, auth_info: { agent: { agentid: 1000011, auth_mode: 0 } } },
463
+ ]),
464
+ cache: fakeCache(),
465
+ });
466
+
467
+ expect((await client.permanentCodeByAuthCode("sat", "AUTH")).agentIds).toEqual(["1000011"]);
468
+ });
469
+
440
470
  it("userNameById 拿到真名(去首尾空白)", async () => {
441
471
  const http = stubHttp([{ access_token: "t", expires_in: 7200 }, { errcode: 0, name: " 张三 " }]);
442
472
  const client = createWecomClient({ http, cache: fakeCache() });
@@ -728,7 +758,7 @@ describe("P1-J:部署常量读取(缺项一次报全 / 密钥格式 fail-fas
728
758
  installVars({ wecom_suite_id: "", wecom_callback_token: "", wecom_enc_key: "" });
729
759
  const msg = (() => {
730
760
  try {
731
- readWecomConfig();
761
+ readSuiteConfig();
732
762
  return "";
733
763
  }
734
764
  catch (e) {
@@ -760,7 +790,7 @@ describe("P1-J:部署常量读取(缺项一次报全 / 密钥格式 fail-fas
760
790
 
761
791
  it("baseUrl 去尾斜杠后拼出两个企微侧地址(回调 URL 要原样填进后台)", () => {
762
792
  installVars();
763
- const cfg = readWecomConfig();
793
+ const cfg = readSuiteConfig();
764
794
  expect(cfg.baseUrl).toBe("https://site.example.com/api/auth/wecom");
765
795
  // 不带 callback_format=json:官方文档只给「对外收款」写了这个参数,代开发的通用回调配置只有 XML
766
796
  expect(eventUrlOf(cfg)).toBe("https://site.example.com/api/auth/wecom/event");
@@ -770,7 +800,7 @@ describe("P1-J:部署常量读取(缺项一次报全 / 密钥格式 fail-fas
770
800
 
771
801
  it("子路径部署:站点根保留子路径(302 回跳必须落 /oj/login,不是 /login)", () => {
772
802
  installVars({ wecom_base_url: "https://site.example.com/oj/api/auth/wecom" });
773
- const cfg = readWecomConfig();
803
+ const cfg = readSuiteConfig();
774
804
  expect(siteRootOf(cfg)).toBe("https://site.example.com/oj");
775
805
  expect(loginLandingUrl({ origin: siteRootOf(cfg), ticket: "tk", tenant: "ww_corp_a" }))
776
806
  .toBe("https://site.example.com/oj/login?wecom_ticket=tk&tenant=ww_corp_a");
@@ -778,12 +808,12 @@ describe("P1-J:部署常量读取(缺项一次报全 / 密钥格式 fail-fas
778
808
 
779
809
  it("baseUrl 后缀不对 → 400(宁可拒,也不猜站点根拼出错误回跳)", () => {
780
810
  installVars({ wecom_base_url: "https://site.example.com/api/wecom" });
781
- expect(() => siteRootOf(readWecomConfig())).toThrow(/必须以 \/api\/auth\/wecom 结尾/);
811
+ expect(() => siteRootOf(readSuiteConfig())).toThrow(/必须以 \/api\/auth\/wecom 结尾/);
782
812
  });
783
813
 
784
814
  it("baseUrl 非绝对地址 → 400(相对路径会让 redirect_uri 与回调 URL 双双失效)", () => {
785
815
  installVars({ wecom_base_url: "/api/auth/wecom" });
786
- expect(() => readWecomConfig()).toThrow(/绝对地址/);
816
+ expect(() => readSuiteConfig()).toThrow(/绝对地址/);
787
817
  });
788
818
 
789
819
  it("wecom_enc_key 只收 16/32 字节的 hex 或 base64(AES-192 会在加密时才炸,故在读配置处拦住)", () => {
@@ -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
+ });