@oj-module/cli 0.2.46 → 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 (97) 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 +282 -27
  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/config.yaml +26 -7
  17. package/templates/init.include.yaml +12 -0
  18. package/templates/openapi.json +2863 -549
  19. package/templates/src/_platform/api/_shared/service/domain/contacts.ts +7 -0
  20. package/templates/src/_platform/api/_shared/service/domain/org-read.ts +451 -0
  21. package/templates/src/_platform/api/_shared/service/domain/org.ts +566 -0
  22. package/templates/src/_platform/api/_shared/service/domain/users.ts +140 -2
  23. package/templates/src/_platform/api/_shared/service/infra/query.ts +2 -1
  24. package/templates/src/_platform/api/fixtures/l1-test-user.sql +1 -1
  25. package/templates/src/_platform/api/manifest.yaml +3 -0
  26. package/templates/src/_platform/api/migrations/0016__org.mysql.sql +41 -0
  27. package/templates/src/_platform/api/migrations/0016__org.pg.sql +40 -0
  28. package/templates/src/_platform/api/migrations/0016__org.sql +62 -0
  29. package/templates/src/_platform/api/org/departments/api.ts +51 -0
  30. package/templates/src/_platform/api/org/is-member/api.ts +33 -0
  31. package/templates/src/_platform/api/org/members/api.ts +55 -0
  32. package/templates/src/_platform/api/org/picker/api.ts +59 -0
  33. package/templates/src/_platform/api/org/resolve/api.ts +38 -0
  34. package/templates/src/_platform/api/org/tree/api.ts +37 -0
  35. package/templates/src/_platform/api/org/user/api.ts +46 -0
  36. package/templates/src/_platform/api/schema.yaml +48 -0
  37. package/templates/src/_platform/api/seed.mysql.sql +10 -5
  38. package/templates/src/_platform/api/seed.pg.sql +10 -5
  39. package/templates/src/_platform/api/seed.sql +13 -5
  40. package/templates/src/_platform/web/__client/api.schemas.ts +126 -0
  41. package/templates/src/_platform/web/__client/api.ts +170 -1
  42. package/templates/src/auth/api/_shared/ldap-login.ts +2 -0
  43. package/templates/src/auth/api/_shared/session.ts +5 -1
  44. package/templates/src/auth/api/_shared/wecom/admin.ts +17 -10
  45. package/templates/src/auth/api/_shared/wecom/client.ts +311 -40
  46. package/templates/src/auth/api/_shared/wecom/config.ts +40 -17
  47. package/templates/src/auth/api/_shared/wecom/contacts.ts +263 -0
  48. package/templates/src/auth/api/_shared/wecom/crypt.ts +17 -7
  49. package/templates/src/auth/api/_shared/wecom/events.ts +111 -26
  50. package/templates/src/auth/api/_shared/wecom/login.ts +152 -1
  51. package/templates/src/auth/api/_shared/wecom/payload.ts +116 -0
  52. package/templates/src/auth/api/_shared/wecom/store.ts +65 -0
  53. package/templates/src/auth/api/_shared/wecom/sync.ts +487 -0
  54. package/templates/src/auth/api/manifest.yaml +3 -0
  55. package/templates/src/auth/api/migrations/0002__org_sync.mysql.sql +28 -0
  56. package/templates/src/auth/api/migrations/0002__org_sync.pg.sql +27 -0
  57. package/templates/src/auth/api/migrations/0002__org_sync.sql +41 -0
  58. package/templates/src/auth/api/schema.yaml +31 -0
  59. package/templates/src/auth/api/wecom/admin/api.ts +7 -5
  60. package/templates/src/auth/api/wecom/api.ts +100 -18
  61. package/templates/src/auth/api/wecom/sync/api.ts +199 -0
  62. package/templates/src/auth/web/__client/api.schemas.ts +97 -0
  63. package/templates/src/auth/web/__client/api.ts +73 -1
  64. package/templates/src/login/web/auth-provider.ts +9 -1
  65. package/templates/src/notification/api/seed.sql +1 -1
  66. package/templates/src/system/api/departments/api.ts +142 -0
  67. package/templates/src/system/api/departments/detach/api.ts +34 -0
  68. package/templates/src/system/api/departments/members/api.ts +142 -0
  69. package/templates/src/system/api/departments/move/api.ts +39 -0
  70. package/templates/src/system/api/users/api.ts +45 -5
  71. package/templates/src/system/api/users/profile/api.ts +44 -0
  72. package/templates/src/system/web/__client/api.schemas.ts +143 -0
  73. package/templates/src/system/web/__client/api.ts +293 -1
  74. package/templates/src/system/web/entry.ts +6 -0
  75. package/templates/src/system/web/locales/en-US.json +113 -7
  76. package/templates/src/system/web/locales/zh-CN.json +113 -7
  77. package/templates/src/system/web/pages/departments/index.tsx +346 -0
  78. package/templates/src/system/web/pages/departments/sync-bar.tsx +257 -0
  79. package/templates/src/system/web/pages/departments/tree.ts +66 -0
  80. package/templates/src/system/web/pages/errors.ts +1 -1
  81. package/templates/src/system/web/pages/users/index.tsx +49 -7
  82. package/templates/src/system/web/pages/wecom/index.tsx +16 -1
  83. package/templates/tests/l1-builder.test.ts +1 -1
  84. package/templates/tests/org-departments.spec.ts +311 -0
  85. package/templates/tests/org-read.spec.ts +469 -0
  86. package/templates/tests/rbac-contacts.spec.ts +13 -3
  87. package/templates/tests/rbac-seed.spec.ts +11 -0
  88. package/templates/tests/seed-role-accounts.spec.ts +6 -2
  89. package/templates/tests/wecom-admin.spec.ts +64 -42
  90. package/templates/tests/wecom-client-timeout.spec.ts +99 -0
  91. package/templates/tests/wecom-connector.spec.ts +115 -11
  92. package/templates/tests/wecom-crypt.spec.ts +12 -3
  93. package/templates/tests/wecom-endpoints.spec.ts +182 -14
  94. package/templates/tests/wecom-org-sync.spec.ts +598 -0
  95. package/templates/tests/wecom-sync.spec.ts +286 -0
  96. package/vendor/host-versions.json +3 -3
  97. package/templates/src/auth/api/_shared/wecom/xml.ts +0 -36
@@ -15,7 +15,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
15
15
  import { assertSuperAdmin, invalidateAllUsers } from "../src/_platform/api/_shared/service/infra/access";
16
16
  import { listApps, setAppStatus } from "../src/auth/api/_shared/wecom/admin";
17
17
  import { createWecomClient, runtimeCache, runtimeHttp } from "../src/auth/api/_shared/wecom/client";
18
- import { installUrlOf, readWecomConfig, siteRootOf } from "../src/auth/api/_shared/wecom/config";
18
+ import { readWecomConfig } from "../src/auth/api/_shared/wecom/config";
19
19
  import { installDb } from "./fixture";
20
20
 
21
21
  const g = globalThis as any;
@@ -51,6 +51,8 @@ function installVars(overrides: Record<string, string> = {}): void {
51
51
  const base: Record<string, string> = {
52
52
  wecom_suite_id: SUITE,
53
53
  wecom_suite_secret: "suite_secret",
54
+ wecom_provider_id: "provider_1",
55
+ wecom_provider_secret: "provider_secret",
54
56
  wecom_callback_token: "cb_token",
55
57
  wecom_encoding_aes_key: AES_KEY_B64.replace(/=$/, ""),
56
58
  wecom_enc_key: "0123456789abcdef0123456789abcdef",
@@ -97,10 +99,6 @@ function seedCorp(corpId = "ww_corp_a", agentId = "100001", env = "prod", status
97
99
  .run(SUITE, corpId, agentId, authCorpId, env, `企业 ${corpId}`, status, `gcm(perm_${corpId}_${agentId})`, NOW, NOW);
98
100
  }
99
101
 
100
- function seedTicket(ticket = "tk_9", expiresAt = NOW + 1800): void {
101
- dbo.prepare("INSERT INTO wecom_suite_ticket (suite_id, suite_ticket, expires_at, update_time) VALUES (?, ?, ?, ?)").run(SUITE, ticket, expiresAt, NOW);
102
- }
103
-
104
102
  function mkUser(username: string, roles: string[] = []): number {
105
103
  dbo.prepare("INSERT INTO users (username, password_hash) VALUES (?, 'x')").run(username);
106
104
  const id = (dbo.prepare("SELECT id FROM users WHERE username = ?").get(username) as { id: number }).id;
@@ -124,10 +122,10 @@ function fakeClient(): { client: any, invalidated: string[] } {
124
122
 
125
123
  const deps = { store: { encrypt: (p: string) => `gcm(${p})`, decrypt: (b: string) => b, now: () => NOW } };
126
124
 
127
- /** createInstall 的薄包装:store.now 固定,避免用例依赖真实时钟 */
125
+ /** createInstall 的薄包装:省掉动态 import 的样板 */
128
126
  async function createInstallForTest(cfg: ReturnType<typeof readWecomConfig>, client: ReturnType<typeof createWecomClient>, state: string) {
129
127
  const { createInstall } = await import("../src/auth/api/_shared/wecom/admin");
130
- return createInstall(cfg, client, deps.store, state);
128
+ return createInstall(cfg, client, state);
131
129
  }
132
130
 
133
131
  const api = async () => (await import("../src/auth/api/wecom/admin/api")).default;
@@ -251,70 +249,94 @@ describe("P3-B:启停(setAppStatus)", () => {
251
249
  });
252
250
  });
253
251
 
254
- describe("P3-C:开通入口(createInstall)", () => {
255
- function stubUpstream(preAuth = "PRE_1", expiresIn = 600): string[] {
252
+ describe("P3-C:开通入口(createInstall,代开发 get_customized_auth_url)", () => {
253
+ // 代开发**不支持** get_pre_auth_code(48002):本段钉死调用链走 provider_access_token +
254
+ // get_customized_auth_url,绝不回退到第三方应用的预授权码接口。
255
+ function stubUpstream(qrcodeUrl = "https://open.work.weixin.qq.com/3rdservice/QR_1", expiresIn = 7200): { calls: string[], bodies: any[] } {
256
256
  const calls: string[] = [];
257
- g.fetch = async (url: string) => {
257
+ const bodies: any[] = [];
258
+ g.fetch = async (url: string, init?: any) => {
258
259
  calls.push(url);
260
+ bodies.push(init?.body ? JSON.parse(init.body) : null);
259
261
  return {
260
262
  ok: true,
261
263
  json: async () => {
262
- if (url.includes("get_suite_token"))
263
- return { suite_access_token: "suite_tk", expires_in: 7200 };
264
- if (url.includes("get_pre_auth_code"))
265
- return { pre_auth_code: preAuth, expires_in: expiresIn };
264
+ if (url.includes("get_provider_token"))
265
+ return { provider_access_token: "prov_tk", expires_in: 7200 };
266
+ if (url.includes("get_customized_auth_url"))
267
+ return { qrcode_url: qrcodeUrl, expires_in: expiresIn };
266
268
  throw new Error(`stub fetch 未预期:${url}`);
267
269
  },
268
270
  };
269
271
  };
270
- return calls;
272
+ return { calls, bodies };
271
273
  }
272
274
 
273
- it("suite_ticket → suite_token → 预授权码 → install URL(redirect_uri 落站点根登录页)", async () => {
274
- seedTicket();
275
- const calls = stubUpstream();
275
+ it("provider_token → get_customized_auth_url → qrcode_url(即开通链接),且绝不调 get_pre_auth_code", async () => {
276
+ const { calls, bodies } = stubUpstream();
276
277
  const cfg = readWecomConfig();
277
278
  const client = createWecomClient({ http: runtimeHttp(), cache: runtimeCache() });
278
279
 
279
280
  const r = await createInstallForTest(cfg, client, "st_1");
280
- expect(calls.some(u => u.includes("get_suite_token"))).toBe(true);
281
- expect(calls.some(u => u.includes("get_pre_auth_code"))).toBe(true);
281
+ expect(calls.some(u => u.includes("get_provider_token"))).toBe(true);
282
+ expect(calls.some(u => u.includes("get_customized_auth_url"))).toBe(true);
283
+ // 代开发 48002 的根因就是错用了 get_pre_auth_code——这条断言防止回归
284
+ expect(calls.some(u => u.includes("get_pre_auth_code"))).toBe(false);
285
+
286
+ const body = bodies.find(b => b && b.templateid_list) as any;
287
+ expect(body).toBeTruthy();
288
+ // get_customized_auth_url 要求 state 必填(errcode 40058 state missing):本系统必传、非空
289
+ expect(body.state).toBe("st_1");
290
+ expect(body.templateid_list).toEqual([SUITE]);
291
+
292
+ expect(r.installUrl).toBe("https://open.work.weixin.qq.com/3rdservice/QR_1");
293
+ // 下发给管理员的就是文档里的地址(不带 callback_format=json,依据见 config.ts::eventUrlOf)
282
294
  expect(r.callbackUrl).toBe("https://site.example.com/api/auth/wecom/event");
283
- expect(r.expiresIn).toBe(600);
284
-
285
- const url = new URL(r.installUrl);
286
- expect(`${url.origin}${url.pathname}`).toBe("https://open.work.weixin.qq.com/3rdapp/install");
287
- expect(url.searchParams.get("suite_id")).toBe(SUITE);
288
- expect(url.searchParams.get("pre_auth_code")).toBe("PRE_1");
289
- expect(url.searchParams.get("redirect_uri")).toBe("https://site.example.com/login?wecom_installed=1");
290
- expect(url.searchParams.get("state")).toBe("st_1");
295
+ expect(r.expiresIn).toBe(7200);
291
296
  });
292
297
 
293
- it("子路径部署:redirect_uri 保留 /oj(回跳不落到宿主机根)", () => {
294
- installVars({ wecom_base_url: "https://site.example.com/oj/api/auth/wecom" });
298
+ it("调用方没传 state 时仍发默认 state(避免 40058 state missing)", async () => {
299
+ const { bodies } = stubUpstream();
295
300
  const cfg = readWecomConfig();
296
- expect(siteRootOf(cfg)).toBe("https://site.example.com/oj");
297
- const url = new URL(installUrlOf(cfg, "PRE_1", ""));
298
- expect(url.searchParams.get("redirect_uri")).toBe("https://site.example.com/oj/login?wecom_installed=1");
299
- // state 为空时不出现在 URL 里(不塞空串进去骗企微后台)
300
- expect(url.searchParams.has("state")).toBe(false);
301
+ const client = createWecomClient({ http: runtimeHttp(), cache: runtimeCache() });
302
+ await createInstallForTest(cfg, client, "");
303
+ const body = bodies.find(b => b && b.templateid_list) as any;
304
+ expect(body.state).toBeTruthy();
305
+ expect(body.state.length).toBeLessThanOrEqual(32);
301
306
  });
302
307
 
303
- it("缺 suite_ticket(回调未打通/已过期)→ 503,不白跑一趟上游", async () => {
304
- const calls = stubUpstream();
308
+ it("缺 provider 凭证(wecom_provider_id / wecom_provider_secret)→ 400 并点名缺哪两个 vars", async () => {
309
+ installVars({ wecom_provider_id: "", wecom_provider_secret: "" });
305
310
  const cfg = readWecomConfig();
306
311
  const client = createWecomClient({ http: runtimeHttp(), cache: runtimeCache() });
307
- await expect(createInstallForTest(cfg, client, "")).rejects.toMatchObject({ status: 503 });
308
- expect(calls).toEqual([]);
312
+ await expect(createInstallForTest(cfg, client, "")).rejects.toMatchObject({ status: 400 });
309
313
  });
310
314
 
311
- it("上游 errcode → 502(可重试语义,不是 500)", async () => {
312
- seedTicket();
313
- g.fetch = async () => ({ ok: true, json: async () => ({ errcode: 40001, errmsg: "invalid credential" }) });
315
+ it("上游 errcode(如 provider_secret 错)→ 502(可重试语义,不是 500)", async () => {
316
+ g.fetch = async () => ({ ok: true, json: async () => ({ errcode: 40001, errmsg: "invalid provider secret" }) });
314
317
  const cfg = readWecomConfig();
315
318
  const client = createWecomClient({ http: runtimeHttp(), cache: runtimeCache() });
316
319
  await expect(createInstallForTest(cfg, client, "")).rejects.toMatchObject({ status: 502 });
317
320
  });
321
+
322
+ it("get_customized_auth_url 返回 842002(模板未上架/上线)→ 502 且提示去后台提交上线(运营动作,非代码缺陷)", async () => {
323
+ g.fetch = async (url: string) => ({
324
+ ok: true,
325
+ json: async () => {
326
+ if (url.includes("get_provider_token"))
327
+ return { provider_access_token: "prov_tk", expires_in: 7200 };
328
+ if (url.includes("get_customized_auth_url"))
329
+ return { errcode: 842002, errmsg: "template not on shelves" };
330
+ throw new Error(`stub fetch 未预期:${url}`);
331
+ },
332
+ });
333
+ const cfg = readWecomConfig();
334
+ const client = createWecomClient({ http: runtimeHttp(), cache: runtimeCache() });
335
+ await expect(createInstallForTest(cfg, client, "st_1")).rejects.toMatchObject({
336
+ status: 502,
337
+ message: expect.stringContaining("代开发模板尚未上架/上线"),
338
+ });
339
+ });
318
340
  });
319
341
 
320
342
  describe("P3-D:端点接线(/auth/wecom/admin/*)", () => {
@@ -0,0 +1,99 @@
1
+ // 回归:企微 API 客户端在「服务端 isolate」下的超时与崩溃防护。
2
+ //
3
+ // 真实 oj 服务端 isolate 没有 `AbortSignal` 全局,也没有 `setTimeout`
4
+ // (oj-api-dev global.d.ts:「本运行时无 timer 全局,setTimeout 不可用」)。
5
+ // 旧实现 `fetch(url, { signal: AbortSignal.timeout(ms) })` 会直接 ReferenceError
6
+ // → 所有企微调用(换 token / 生成开通链接 / 登录)一律 502。
7
+ // 这里在测试里**模拟**该 isolate(删掉 AbortSignal、tasks 视情况),断言:
8
+ // 1. 无 AbortSignal 时调用照常工作(不抛 `AbortSignal is not defined`);
9
+ // 2. 不再把 `signal` 传给 fetch(旧 bug 的根);
10
+ // 3. tasks.sleep 存在 → 慢请求被软超时拦截并抛「超时」文案;
11
+ // 4. tasks 缺失 → 安全降级为「不超时」,快速请求正常返回。
12
+
13
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
14
+ import { runtimeHttp } from "../src/auth/api/_shared/wecom/client";
15
+
16
+ const g = globalThis as any;
17
+
18
+ let savedAbortSignal: unknown;
19
+ let savedTasks: unknown;
20
+ let savedFetch: unknown;
21
+
22
+ beforeEach(() => {
23
+ savedAbortSignal = (globalThis as { AbortSignal?: unknown }).AbortSignal;
24
+ savedTasks = g.tasks;
25
+ savedFetch = g.fetch;
26
+ });
27
+
28
+ afterEach(() => {
29
+ Object.defineProperty(globalThis, "AbortSignal", { value: savedAbortSignal, configurable: true, writable: true });
30
+ g.tasks = savedTasks;
31
+ g.fetch = savedFetch;
32
+ });
33
+
34
+ describe("wecom client:无 AbortSignal 的 isolate 不崩(回归)", () => {
35
+ it("abortSignal 被删掉时,getJson/postJson 仍能调用企微接口", async () => {
36
+ // 模拟服务端 isolate:把 AbortSignal 强制为 undefined(兼覆盖「整类不存在」与「.timeout 缺失」两种形态)
37
+ Object.defineProperty(globalThis, "AbortSignal", { value: undefined, configurable: true, writable: true });
38
+ g.tasks = undefined;
39
+
40
+ const fetchMock = vi.fn(async () => ({
41
+ ok: true,
42
+ json: async () => ({ errcode: 0, suite_access_token: "TOK", expires_in: 7200 }),
43
+ }));
44
+ g.fetch = fetchMock;
45
+
46
+ const http = runtimeHttp();
47
+ const data = await http.postJson("https://qyapi.weixin.qq.com/cgi-bin/service/get_suite_token", { a: 1 });
48
+ expect(data.errcode).toBe(0);
49
+ expect(fetchMock).toHaveBeenCalledTimes(1);
50
+ });
51
+
52
+ it("不再把 signal 传给 fetch(旧实现传 AbortSignal.timeout 的结果)", async () => {
53
+ Object.defineProperty(globalThis, "AbortSignal", { value: undefined, configurable: true, writable: true });
54
+ g.tasks = undefined;
55
+
56
+ const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ errcode: 0 }) }));
57
+ g.fetch = fetchMock;
58
+
59
+ await runtimeHttp().getJson("https://qyapi.weixin.qq.com/x");
60
+ const [, init] = fetchMock.mock.calls[0];
61
+ expect(init.signal).toBeUndefined();
62
+ });
63
+
64
+ it("abortSignal.timeout 绝不被调用(防止旧 bug 回潮)", async () => {
65
+ const spy = vi.spyOn(globalThis.AbortSignal as { timeout: (ms: number) => unknown }, "timeout");
66
+ g.tasks = undefined;
67
+ g.fetch = vi.fn(async () => ({ ok: true, json: async () => ({ errcode: 0 }) }));
68
+
69
+ await runtimeHttp().getJson("https://qyapi.weixin.qq.com/x");
70
+ expect(spy).not.toHaveBeenCalled();
71
+ spy.mockRestore();
72
+ });
73
+ });
74
+
75
+ describe("wecom client:软超时(tasks.sleep)", () => {
76
+ it("tasks.sleep 存在时,慢请求被软超时拦截并抛出「超时」文案", async () => {
77
+ // 用立即 resolve 的 sleep 模拟(不真的等 TIMEOUT_MS,测试秒级完成)
78
+ g.tasks = { sleep: () => Promise.resolve() };
79
+ let resolveFetch: (v: unknown) => void = () => {};
80
+ const fetchMock = vi.fn(() => new Promise<unknown>((resolve) => {
81
+ resolveFetch = resolve;
82
+ }));
83
+ g.fetch = fetchMock;
84
+
85
+ const p = runtimeHttp().getJson("https://qyapi.weixin.qq.com/x");
86
+ await expect(p).rejects.toThrow(/超时/);
87
+ // 收尾挂起的 fetch,避免 open handle
88
+ resolveFetch({ ok: true, json: async () => ({ errcode: 0 }) });
89
+ });
90
+
91
+ it("tasks 缺失时降级为不超时:快速请求正常返回", async () => {
92
+ g.tasks = undefined;
93
+ const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ errcode: 0 }) }));
94
+ g.fetch = fetchMock;
95
+
96
+ const data = await runtimeHttp().getJson("https://qyapi.weixin.qq.com/x");
97
+ expect(data.errcode).toBe(0);
98
+ });
99
+ });
@@ -19,7 +19,6 @@ import { createWecomClient, runtimeCache } from "../src/auth/api/_shared/wecom/c
19
19
  import {
20
20
  assertEncKey,
21
21
  eventUrlOf,
22
- installUrlOf,
23
22
  loginRedirectOf,
24
23
  readCallbackConfig,
25
24
  readWecomConfig,
@@ -38,6 +37,7 @@ import {
38
37
  putLoginState,
39
38
  takeLoginState,
40
39
  } from "../src/auth/api/_shared/wecom/login";
40
+ import { callbackFields, field } from "../src/auth/api/_shared/wecom/payload";
41
41
  import {
42
42
  claimLoginTicket,
43
43
  ensureTenant,
@@ -494,7 +494,7 @@ describe("P1-G:事件分发(suite_ticket / 授权 / 解绑)", () => {
494
494
  return "sat";
495
495
  },
496
496
  async permanentCodeByAuthCode() {
497
- return { permanentCode: "perm_new", corpId: "ww_corp_a", corpName: "测试企业", agentIds: ["100001", "100002"], state: null, ...overrides };
497
+ return { permanentCode: "perm_new", corpId: "ww_corp_a", corpName: "测试企业", agentIds: ["100001", "100002"], authModes: { 100001: 0, 100002: 0 }, state: null, ...overrides };
498
498
  },
499
499
  async corpAccessToken() {
500
500
  return "ct";
@@ -511,14 +511,39 @@ describe("P1-G:事件分发(suite_ticket / 授权 / 解绑)", () => {
511
511
  const deps = (client: unknown) => ({ cfg: CFG, client: client as never, store: storeDeps() });
512
512
 
513
513
  it("suite_ticket:落库并给 30 分钟有效期(企微 10 分钟推一次,过期即拒发 API)", async () => {
514
- await handleSuiteEvent("<xml><InfoType>suite_ticket</InfoType><SuiteId>suite_1</SuiteId><SuiteTicket>tk_1</SuiteTicket></xml>", deps(fakeClient()));
514
+ await handleSuiteEvent({ SuiteId: "suite_1", InfoType: "suite_ticket", SuiteTicket: "tk_1" }, deps(fakeClient()));
515
515
  expect(await getSuiteTicket(CFG.suiteId, storeDeps())).toEqual({ ticket: "tk_1", expiresAt: clock + 1800 });
516
516
  });
517
517
 
518
+ it("suite_ticket 顺势预热 suite token(授权路径只剩一次出网),换票失败也不影响票据落库", async () => {
519
+ const seen: string[] = [];
520
+ const warm = {
521
+ ...fakeClient(),
522
+ async suiteAccessToken(suiteId: string, secret: string, ticket: string) {
523
+ seen.push(`${suiteId}/${secret}/${ticket}`);
524
+ return "sat";
525
+ },
526
+ } as never;
527
+ await handleSuiteEvent({ SuiteId: "suite_1", InfoType: "suite_ticket", SuiteTicket: "tk_9" }, deps(warm));
528
+ expect(seen).toEqual(["suite_1/secret_1/tk_9"]);
529
+
530
+ // suite_secret 配错时只是预热失败:票据必须照旧落库,否则 create_auth 连门都进不去
531
+ const broken = {
532
+ ...fakeClient(),
533
+ async suiteAccessToken() {
534
+ throw new Error("企微 get_suite_token 失败:errcode=40001");
535
+ },
536
+ } as never;
537
+ await expect(handleSuiteEvent({ SuiteId: "suite_1", InfoType: "suite_ticket", SuiteTicket: "tk_10" }, deps(broken)))
538
+ .resolves
539
+ .toBe("suite_ticket");
540
+ expect((await getSuiteTicket(CFG.suiteId, storeDeps())).ticket).toBe("tk_10");
541
+ });
542
+
518
543
  it("create_auth:AuthCode 换永久码 → 每个 agent 一行 profile,并作废旧 corp token", async () => {
519
544
  await putSuiteTicket(CFG.suiteId, "tk_1", clock + 1800, storeDeps());
520
545
  const client = fakeClient();
521
- await handleSuiteEvent("<xml><InfoType>create_auth</InfoType><SuiteId>suite_1</SuiteId><AuthCode>AC_1</AuthCode><State>env=test</State></xml>", deps(client));
546
+ await handleSuiteEvent({ SuiteId: "suite_1", InfoType: "create_auth", AuthCode: "AC_1", State: "env=test" }, deps(client));
522
547
  const rows = dbo.prepare("SELECT agent_id, env, status FROM wecom_app_profile ORDER BY agent_id").all();
523
548
  expect(rows).toEqual([
524
549
  { agent_id: "100001", env: "test", status: "active" },
@@ -529,31 +554,111 @@ describe("P1-G:事件分发(suite_ticket / 授权 / 解绑)", () => {
529
554
  expect(dbo.prepare("SELECT action FROM sys_oplog WHERE action = 'wecom.auth.apply'").all()).toHaveLength(1);
530
555
  });
531
556
 
557
+ // 回归:线上「应用安装失败,联系服务商」的正因——回调是匿名请求(无 X-TENANT-ID),
558
+ // sys_oplog 是租户表,sql_guard: deny 下不绑租户就写会被守卫拒;而它发生在 profile 已落库之后,
559
+ // 于是「授权其实成功了,企微却判失败」。鉴据:审计行必须落在该企业自己的租户下。
560
+ it("create_auth 匿名回调(无租户头):审计前先按企业绑租户,行落进该企业 oplog", async () => {
561
+ await putSuiteTicket(CFG.suiteId, "tk_1", clock + 1800, storeDeps());
562
+ g.http = {};
563
+ await handleSuiteEvent({ SuiteId: "suite_1", InfoType: "create_auth", AuthCode: "AC_1" }, deps(fakeClient()));
564
+ expect(g.http.tenantId).toBe("ww_corp_a");
565
+ expect(dbo.prepare("SELECT tenant_id, target FROM sys_oplog WHERE action = 'wecom.auth.apply'").get())
566
+ .toMatchObject({ tenant_id: "ww_corp_a", target: "ww_corp_a" });
567
+ });
568
+
569
+ it("审计写失败不得把 create_auth 打成失败(授权已落库,企微必须拿到 success)", async () => {
570
+ await putSuiteTicket(CFG.suiteId, "tk_1", clock + 1800, storeDeps());
571
+ const orig = g.db.table;
572
+ g.db.table = (name: string) => {
573
+ if (name === "sys_oplog")
574
+ throw new Error("tenant guard: table(s) [\"sys_oplog\"] require tenant context (missing tenant header)");
575
+ return orig(name);
576
+ };
577
+ try {
578
+ await expect(handleSuiteEvent({ SuiteId: "suite_1", InfoType: "create_auth", AuthCode: "AC_1" }, deps(fakeClient())))
579
+ .resolves
580
+ .toBe("create_auth");
581
+ }
582
+ finally {
583
+ g.db.table = orig;
584
+ }
585
+ expect(dbo.prepare("SELECT COUNT(*) AS n FROM wecom_app_profile").get()).toMatchObject({ n: 2 });
586
+ });
587
+
532
588
  it("cancel_auth:profile 全部停用 + 该租户 refresh 串清空(解绑后旧会话不得续命)", async () => {
533
589
  await saveProfile(profile({ agent: "100001" }), storeDeps());
534
590
  await saveProfile(profile({ agent: "100002" }), storeDeps());
535
591
  dbo.prepare("INSERT INTO sys_refresh_token (token_hash, tenant_id, user_id, expires_at, revoked, created_at) VALUES ('h', 'ww_corp_a', 1, ?, 0, ?)").run(clock + 100, clock);
536
592
 
537
- await handleSuiteEvent("<xml><InfoType>cancel_auth</InfoType><SuiteId>suite_1</SuiteId><AuthCorpId>ww_corp_a</AuthCorpId></xml>", deps(fakeClient()));
593
+ g.http = {};
594
+ await handleSuiteEvent({ SuiteId: "suite_1", InfoType: "cancel_auth", AuthCorpId: "ww_corp_a" }, deps(fakeClient()));
538
595
 
539
596
  expect(dbo.prepare("SELECT DISTINCT status FROM wecom_app_profile").all()).toEqual([{ status: "disabled" }]);
540
597
  expect(dbo.prepare("SELECT COUNT(*) AS n FROM sys_refresh_token").get()).toMatchObject({ n: 0 });
541
598
  expect(dbo.prepare("SELECT level FROM sys_oplog WHERE action = 'wecom.auth.cancel'").get()).toMatchObject({ level: 4 });
599
+ // 清会话是裸 SQL,deny 档下同样要租户上下文
600
+ expect(g.http.tenantId).toBe("ww_corp_a");
542
601
  });
543
602
 
544
603
  it("SuiteId 与本部署不一致 → 400(不同套件的回调不得互相污染)", async () => {
545
- await expect(handleSuiteEvent("<xml><InfoType>suite_ticket</InfoType><SuiteId>other</SuiteId><SuiteTicket>t</SuiteTicket></xml>", deps(fakeClient())))
604
+ await expect(handleSuiteEvent({ SuiteId: "other", InfoType: "suite_ticket", SuiteTicket: "t" }, deps(fakeClient())))
546
605
  .rejects
547
606
  .toMatchObject({ status: 400 });
548
607
  });
549
608
 
550
- it("不消费的事件(通讯录变更等)回 success 语义的 InfoType,不抛错(否则企微重推三次)", async () => {
551
- expect(await handleSuiteEvent("<xml><InfoType>change_contact</InfoType><SuiteId>suite_1</SuiteId></xml>", deps(fakeClient()))).toBe("change_contact");
609
+ it("不消费的事件回 success 语义的 InfoType,不抛错(否则企微重推三次)", async () => {
610
+ expect(await handleSuiteEvent({ InfoType: "change_contact", SuiteId: "suite_1" }, deps(fakeClient()))).toBe("change_contact");
611
+ });
612
+
613
+ // 数据回调没有 InfoType,只有 MsgType=event + Event=change_contact:分流靠 Event 认,
614
+ // 认不出就会落进 default 被记成「不消费的事件」——而这条正是轨三的全部输入。
615
+ it("数据回调形态(无 InfoType)也能分流到轨三,且日志只留 ChangeType 不留值", async () => {
616
+ const dataCallback = {
617
+ ToUserName: "ww_corp_a",
618
+ CreateTime: 1700000000,
619
+ MsgType: "event",
620
+ Event: "change_contact",
621
+ ChangeType: "update_party",
622
+ Id: 2,
623
+ };
624
+ const logged: unknown[][] = [];
625
+ const orig = g.log;
626
+ const push = (...args: unknown[]) => void logged.push(args);
627
+ // 失败那条是 warn(成功才是 info):两个都要抓,否则「没抛错」会掩盖「根本没走到轨三」
628
+ g.log = { ...orig, info: push, warn: push };
629
+ try {
630
+ // 返回值是 ChangeType(日志只用它记一行),不是事件名
631
+ expect(await handleSuiteEvent(dataCallback, deps(fakeClient()))).toBe("update_party");
632
+ }
633
+ finally {
634
+ g.log = orig;
635
+ }
636
+ // 这家企业在本用例里没授权:失败只记一笔,绝不抛(抛了企微就重推三次)
637
+ const line = logged.find(args => args[0] === "wecom change_contact ignored");
638
+ expect(line).toBeDefined();
639
+ // ChangeType 是协议枚举,记下来才有排查价值;姓名/工号这类明文值一个都不许进日志
640
+ expect(line).toContain("update_party");
641
+ expect(JSON.stringify(line)).toContain("未授权");
642
+ });
643
+
644
+ // 兜底口径(payload.ts 头的说明):部分回调业务没有 JSON 文档,收到 XML 也必须能处理。
645
+ // 同一个事件两种格式走出来的效果必须一模一样,否则「换个格式就静默不认」会在真机上才暴露。
646
+ it("XML(文档形态)与 JSON 归一化结果一致:两种包体走同一条路径", async () => {
647
+ const asJson = { SuiteId: "suite_1", InfoType: "create_auth", AuthCode: "AC_xml" };
648
+ const asXml = [
649
+ "<xml><SuiteId><![CDATA[suite_1]]></SuiteId><InfoType><![CDATA[create_auth]]></InfoType>",
650
+ "<AuthCode><![CDATA[AC_xml]]></AuthCode></xml>",
651
+ ].join("");
652
+ const fromJson = callbackFields(asJson);
653
+ const fromXml = callbackFields(asXml);
654
+ expect([...fromXml.values.entries()].sort()).toEqual([...fromJson.values.entries()].sort());
655
+ // 信封小写、明文大写都认:上游 JSON 信封是 {"tousername","encrypt"}
656
+ expect(field(callbackFields({ tousername: "ww_corp_a", encrypt: "abc" }), "Encrypt")).toBe("abc");
552
657
  });
553
658
 
554
659
  it("create_auth 缺 AuthCode → 400(宁可拒也不落半行 profile)", async () => {
555
660
  await putSuiteTicket(CFG.suiteId, "tk_1", clock + 1800, storeDeps());
556
- await expect(handleSuiteEvent("<xml><InfoType>create_auth</InfoType><SuiteId>suite_1</SuiteId></xml>", deps(fakeClient())))
661
+ await expect(handleSuiteEvent({ SuiteId: "suite_1", InfoType: "create_auth" }, deps(fakeClient())))
557
662
  .rejects
558
663
  .toMatchObject({ status: 400 });
559
664
  });
@@ -657,6 +762,7 @@ describe("P1-J:部署常量读取(缺项一次报全 / 密钥格式 fail-fas
657
762
  installVars();
658
763
  const cfg = readWecomConfig();
659
764
  expect(cfg.baseUrl).toBe("https://site.example.com/api/auth/wecom");
765
+ // 不带 callback_format=json:官方文档只给「对外收款」写了这个参数,代开发的通用回调配置只有 XML
660
766
  expect(eventUrlOf(cfg)).toBe("https://site.example.com/api/auth/wecom/event");
661
767
  expect(loginRedirectOf(cfg)).toBe("https://site.example.com/api/auth/wecom/callback");
662
768
  expect(siteRootOf(cfg)).toBe("https://site.example.com");
@@ -668,8 +774,6 @@ describe("P1-J:部署常量读取(缺项一次报全 / 密钥格式 fail-fas
668
774
  expect(siteRootOf(cfg)).toBe("https://site.example.com/oj");
669
775
  expect(loginLandingUrl({ origin: siteRootOf(cfg), ticket: "tk", tenant: "ww_corp_a" }))
670
776
  .toBe("https://site.example.com/oj/login?wecom_ticket=tk&tenant=ww_corp_a");
671
- // 开通入口的 redirect_uri 同样要带子路径
672
- expect(installUrlOf(cfg, "PAC1", "env=prod")).toContain(`redirect_uri=${encodeURIComponent("https://site.example.com/oj/login?wecom_installed=1")}`);
673
777
  });
674
778
 
675
779
  it("baseUrl 后缀不对 → 400(宁可拒,也不猜站点根拼出错误回跳)", () => {
@@ -80,14 +80,23 @@ describe("aes256CbcDecrypt 对 WebCrypto", () => {
80
80
 
81
81
  it("pKCS#7 非法填充硬失败(不返回半截明文)", () => {
82
82
  expect(() => pkcs7Unpad(new Uint8Array(16))).toThrow(/bad padding/); // 全 0 填充
83
- const tooBig = new Uint8Array(16).fill(0);
84
- tooBig[15] = 17;
85
- expect(() => pkcs7Unpad(tooBig)).toThrow(/bad padding/);
83
+ const tooBig = new Uint8Array(64).fill(33);
84
+ expect(() => pkcs7Unpad(tooBig)).toThrow(/bad padding/); // 33 > 企微的 32 字节补位块
86
85
  const inconsistent = new Uint8Array(16).fill(0);
87
86
  inconsistent[14] = 3;
88
87
  inconsistent[15] = 3;
89
88
  expect(() => pkcs7Unpad(inconsistent)).toThrow(/bad padding/);
90
89
  });
90
+
91
+ it("企微的补位块是 32 字节:补位 17~32 必须能去掉(按 16 卡上限会误杀一半真实报文)", () => {
92
+ // 90968「概述」:数据采用 PKCS#7 填充至 **32 字节**的倍数。官方 demo 的 block_size 也是 32,
93
+ // 所以末字节可能取到 17~32;只认 1~16 会让这些报文全判 bad padding。
94
+ for (const pad of [1, 16, 17, 20, 31, 32]) {
95
+ const block = new Uint8Array(64).fill(0x41);
96
+ block.fill(pad, 64 - pad);
97
+ expect(pkcs7Unpad(block).length).toBe(64 - pad);
98
+ }
99
+ });
91
100
  });
92
101
 
93
102
  describe("wecomSignature:字典序排序后 sha1", () => {