@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.
- package/devkit/SKILL.md +1 -1
- package/devkit/framework-dev.md +2 -2
- package/devkit/manual.md +24 -14
- package/devkit/module-dev-and-cdn-playground.md +2 -2
- package/devkit/wecom-daikaifa.md +282 -27
- package/package.json +2 -2
- package/shell-dist/assets/runtime.js +2 -2
- package/shell-dist/index.html +2 -2
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/templates-tenant-scope.test.ts +7 -2
- package/src/init.ts +1 -1
- package/src/market/__tests__/bootstrap.test.ts +1 -1
- package/src/market/__tests__/setup.test.ts +7 -7
- package/src/market/bootstrap.ts +1 -1
- package/templates/README.md +1 -1
- package/templates/config.yaml +26 -7
- package/templates/init.include.yaml +12 -0
- package/templates/openapi.json +2863 -549
- package/templates/src/_platform/api/_shared/service/domain/contacts.ts +7 -0
- package/templates/src/_platform/api/_shared/service/domain/org-read.ts +451 -0
- package/templates/src/_platform/api/_shared/service/domain/org.ts +566 -0
- package/templates/src/_platform/api/_shared/service/domain/users.ts +140 -2
- package/templates/src/_platform/api/_shared/service/infra/query.ts +2 -1
- package/templates/src/_platform/api/fixtures/l1-test-user.sql +1 -1
- package/templates/src/_platform/api/manifest.yaml +3 -0
- package/templates/src/_platform/api/migrations/0016__org.mysql.sql +41 -0
- package/templates/src/_platform/api/migrations/0016__org.pg.sql +40 -0
- package/templates/src/_platform/api/migrations/0016__org.sql +62 -0
- package/templates/src/_platform/api/org/departments/api.ts +51 -0
- package/templates/src/_platform/api/org/is-member/api.ts +33 -0
- package/templates/src/_platform/api/org/members/api.ts +55 -0
- package/templates/src/_platform/api/org/picker/api.ts +59 -0
- package/templates/src/_platform/api/org/resolve/api.ts +38 -0
- package/templates/src/_platform/api/org/tree/api.ts +37 -0
- package/templates/src/_platform/api/org/user/api.ts +46 -0
- package/templates/src/_platform/api/schema.yaml +48 -0
- package/templates/src/_platform/api/seed.mysql.sql +10 -5
- package/templates/src/_platform/api/seed.pg.sql +10 -5
- package/templates/src/_platform/api/seed.sql +13 -5
- package/templates/src/_platform/web/__client/api.schemas.ts +126 -0
- package/templates/src/_platform/web/__client/api.ts +170 -1
- package/templates/src/auth/api/_shared/ldap-login.ts +2 -0
- package/templates/src/auth/api/_shared/session.ts +5 -1
- package/templates/src/auth/api/_shared/wecom/admin.ts +17 -10
- package/templates/src/auth/api/_shared/wecom/client.ts +311 -40
- package/templates/src/auth/api/_shared/wecom/config.ts +40 -17
- package/templates/src/auth/api/_shared/wecom/contacts.ts +263 -0
- package/templates/src/auth/api/_shared/wecom/crypt.ts +17 -7
- package/templates/src/auth/api/_shared/wecom/events.ts +111 -26
- package/templates/src/auth/api/_shared/wecom/login.ts +152 -1
- package/templates/src/auth/api/_shared/wecom/payload.ts +116 -0
- package/templates/src/auth/api/_shared/wecom/store.ts +65 -0
- package/templates/src/auth/api/_shared/wecom/sync.ts +487 -0
- package/templates/src/auth/api/manifest.yaml +3 -0
- package/templates/src/auth/api/migrations/0002__org_sync.mysql.sql +28 -0
- package/templates/src/auth/api/migrations/0002__org_sync.pg.sql +27 -0
- package/templates/src/auth/api/migrations/0002__org_sync.sql +41 -0
- package/templates/src/auth/api/schema.yaml +31 -0
- package/templates/src/auth/api/wecom/admin/api.ts +7 -5
- package/templates/src/auth/api/wecom/api.ts +100 -18
- package/templates/src/auth/api/wecom/sync/api.ts +199 -0
- package/templates/src/auth/web/__client/api.schemas.ts +97 -0
- package/templates/src/auth/web/__client/api.ts +73 -1
- package/templates/src/login/web/auth-provider.ts +9 -1
- package/templates/src/notification/api/seed.sql +1 -1
- package/templates/src/system/api/departments/api.ts +142 -0
- package/templates/src/system/api/departments/detach/api.ts +34 -0
- package/templates/src/system/api/departments/members/api.ts +142 -0
- package/templates/src/system/api/departments/move/api.ts +39 -0
- package/templates/src/system/api/users/api.ts +45 -5
- package/templates/src/system/api/users/profile/api.ts +44 -0
- package/templates/src/system/web/__client/api.schemas.ts +143 -0
- package/templates/src/system/web/__client/api.ts +293 -1
- package/templates/src/system/web/entry.ts +6 -0
- package/templates/src/system/web/locales/en-US.json +113 -7
- package/templates/src/system/web/locales/zh-CN.json +113 -7
- package/templates/src/system/web/pages/departments/index.tsx +346 -0
- package/templates/src/system/web/pages/departments/sync-bar.tsx +257 -0
- package/templates/src/system/web/pages/departments/tree.ts +66 -0
- package/templates/src/system/web/pages/errors.ts +1 -1
- package/templates/src/system/web/pages/users/index.tsx +49 -7
- package/templates/src/system/web/pages/wecom/index.tsx +16 -1
- package/templates/tests/l1-builder.test.ts +1 -1
- package/templates/tests/org-departments.spec.ts +311 -0
- package/templates/tests/org-read.spec.ts +469 -0
- package/templates/tests/rbac-contacts.spec.ts +13 -3
- package/templates/tests/rbac-seed.spec.ts +11 -0
- package/templates/tests/seed-role-accounts.spec.ts +6 -2
- package/templates/tests/wecom-admin.spec.ts +64 -42
- package/templates/tests/wecom-client-timeout.spec.ts +99 -0
- package/templates/tests/wecom-connector.spec.ts +115 -11
- package/templates/tests/wecom-crypt.spec.ts +12 -3
- package/templates/tests/wecom-endpoints.spec.ts +182 -14
- package/templates/tests/wecom-org-sync.spec.ts +598 -0
- package/templates/tests/wecom-sync.spec.ts +286 -0
- package/vendor/host-versions.json +3 -3
- package/templates/src/auth/api/_shared/wecom/xml.ts +0 -36
|
@@ -5,12 +5,13 @@
|
|
|
5
5
|
// 2. 企微事件端点必须回**纯文本** success / echostr(JSON 信封 = 企微判失败 → 重推三次);
|
|
6
6
|
// 3. 回调链的租户来自服务端推导(state→profile → db.asTenant),且票据只能兑换一次。
|
|
7
7
|
//
|
|
8
|
-
// json / vars / kv / fetch / crypto 全部桩掉(oj 运行时全局同名同面);密文用
|
|
8
|
+
// json / vars / kv / fetch / crypto 全部桩掉(oj 运行时全局同名同面);密文用 node:crypto 现造,
|
|
9
9
|
// 与 wecom-crypt.spec.ts 同一对照物,不拿自实现给自己背书。
|
|
10
10
|
/* eslint-disable test/prefer-lowercase-title -- AC 编号是验收锚点,保留大写 */
|
|
11
|
-
import {
|
|
11
|
+
import { Buffer } from "node:buffer";
|
|
12
|
+
import { createCipheriv, webcrypto } from "node:crypto";
|
|
12
13
|
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
13
|
-
import { bytesToB64, utf8Bytes, wecomSignature } from "../src/auth/api/_shared/wecom/crypt";
|
|
14
|
+
import { bytesToB64, sha1Hex, utf8Bytes, wecomSignature } from "../src/auth/api/_shared/wecom/crypt";
|
|
14
15
|
import { installDb } from "./fixture";
|
|
15
16
|
|
|
16
17
|
const g = globalThis as any;
|
|
@@ -110,21 +111,24 @@ function installCrypto(): void {
|
|
|
110
111
|
Object.defineProperty(globalThis, "crypto", { value: c, configurable: true });
|
|
111
112
|
}
|
|
112
113
|
|
|
113
|
-
/**
|
|
114
|
+
/**
|
|
115
|
+
* 企微侧 webhook 的密文包(random|len|msg|receiveid → AES-256-CBC,PKCS#7 **补到 32 字节**)。
|
|
116
|
+
*
|
|
117
|
+
* 补到 32 而不是 16:企微(90968)就是这么补的,补位值会落在 1~32——用 WebCrypto 的默认补位
|
|
118
|
+
* (固定 16 字节块)会永远只覆盖到 1~16,正好把线上约一半的报文排除在测试之外(真踩过)。
|
|
119
|
+
* 所以这里用 node:crypto 并关掉自动补位,自己按 32 补。
|
|
120
|
+
*/
|
|
114
121
|
async function seal(plain: string, receiveId = SUITE): Promise<string> {
|
|
115
122
|
const msg = utf8Bytes(plain);
|
|
116
123
|
const head = webcrypto.getRandomValues(new Uint8Array(16));
|
|
117
124
|
const len = new Uint8Array(4);
|
|
118
125
|
new DataView(len.buffer).setUint32(0, msg.length);
|
|
119
126
|
const recv = utf8Bytes(receiveId);
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const key = await webcrypto.subtle.importKey("raw", AES_KEY, { name: "AES-CBC" }, false, ["encrypt"]);
|
|
126
|
-
const cipher = new Uint8Array(await webcrypto.subtle.encrypt({ name: "AES-CBC", iv: AES_KEY.subarray(0, 16) }, key, buf));
|
|
127
|
-
return bytesToB64(cipher);
|
|
127
|
+
const body = Buffer.concat([Buffer.from(head), Buffer.from(len), Buffer.from(msg), Buffer.from(recv)]);
|
|
128
|
+
const pad = 32 - (body.length % 32);
|
|
129
|
+
const cipher = createCipheriv("aes-256-cbc", Buffer.from(AES_KEY), Buffer.from(AES_KEY.subarray(0, 16)));
|
|
130
|
+
cipher.setAutoPadding(false);
|
|
131
|
+
return Buffer.concat([cipher.update(Buffer.concat([body, Buffer.alloc(pad, pad)])), cipher.final()]).toString("base64");
|
|
128
132
|
}
|
|
129
133
|
|
|
130
134
|
function request(opts: { query?: Record<string, string>, body?: unknown, headers?: Record<string, string>, tenantId?: string } = {}): void {
|
|
@@ -163,7 +167,7 @@ beforeEach(() => {
|
|
|
163
167
|
installKv();
|
|
164
168
|
installCrypto();
|
|
165
169
|
g.fetch = async () => ({ ok: true, json: async () => ({}) });
|
|
166
|
-
for (const t of ["wecom_app_profile", "wecom_suite_ticket", "wecom_login_ticket", "sys_refresh_token", "sys_login_throttle", "sys_rate_window", "sys_oplog"])
|
|
170
|
+
for (const t of ["wecom_app_profile", "wecom_suite_ticket", "wecom_login_ticket", "sys_refresh_token", "sys_login_throttle", "sys_rate_window", "sys_oplog", "sys_dept_member", "sys_department"])
|
|
167
171
|
dbo.exec(`DELETE FROM ${t}`);
|
|
168
172
|
// JIT 建号是跨用例污染源:每个用例都从「本企业还没建过号」起步
|
|
169
173
|
dbo.exec("DELETE FROM users WHERE source = 'wecom'");
|
|
@@ -374,6 +378,28 @@ describe("P1-L:GET/POST /auth/wecom/event(企微指令回调)", () => {
|
|
|
374
378
|
return { ...query, msg_signature: wecomSignature(TOKEN, query.timestamp, query.nonce, encrypted) };
|
|
375
379
|
}
|
|
376
380
|
|
|
381
|
+
/** 抓这一段里所有 log.info / log.warn 的 (msg, kv):验收看的是日志,不是只看 HTTP 200 */
|
|
382
|
+
async function captureLogs(fn: () => Promise<void>): Promise<unknown[][]> {
|
|
383
|
+
const lines: unknown[][] = [];
|
|
384
|
+
const orig = g.log;
|
|
385
|
+
const push = (msg: string, ...kv: unknown[]) => void lines.push([msg, kv]);
|
|
386
|
+
g.log = { ...orig, info: push, warn: push };
|
|
387
|
+
try {
|
|
388
|
+
await fn();
|
|
389
|
+
}
|
|
390
|
+
finally {
|
|
391
|
+
g.log = orig;
|
|
392
|
+
}
|
|
393
|
+
return lines;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** 取某条日志的 kv 片段;没这条就当场失败,别让后面的断言在 undefined 上跑 */
|
|
397
|
+
function logOf(lines: unknown[][], msg: string): unknown {
|
|
398
|
+
const line = lines.find(a => a[0] === msg);
|
|
399
|
+
expect(line, `没抓到日志:${msg}`).toBeDefined();
|
|
400
|
+
return line![1];
|
|
401
|
+
}
|
|
402
|
+
|
|
377
403
|
it("URL 验证:验签通过 → 以 text/plain 原样回解出的 echostr(不套信封)", async () => {
|
|
378
404
|
const echo = await seal("echo_12345");
|
|
379
405
|
request({ query: { ...signedQuery(echo), echostr: echo } });
|
|
@@ -403,7 +429,8 @@ describe("P1-L:GET/POST /auth/wecom/event(企微指令回调)", () => {
|
|
|
403
429
|
expect(out.body.msg).toMatch(/签名/);
|
|
404
430
|
});
|
|
405
431
|
|
|
406
|
-
it("
|
|
432
|
+
it("指令回调(文档形态:XML)→ 落 suite_ticket → 回纯文本 success", async () => {
|
|
433
|
+
// 96238 §3.2 的报文就是这个形状,也是线上真正会到的形态
|
|
407
434
|
const plain = "<xml><InfoType>suite_ticket</InfoType><SuiteId>suite_1</SuiteId><SuiteTicket>tk_9</SuiteTicket></xml>";
|
|
408
435
|
const encrypted = await seal(plain);
|
|
409
436
|
request({ query: signedQuery(encrypted), body: `<xml><Encrypt><![CDATA[${encrypted}]]></Encrypt></xml>` });
|
|
@@ -413,6 +440,117 @@ describe("P1-L:GET/POST /auth/wecom/event(企微指令回调)", () => {
|
|
|
413
440
|
expect(dbo.prepare("SELECT suite_ticket FROM wecom_suite_ticket WHERE suite_id = ?").get(SUITE)).toMatchObject({ suite_ticket: "tk_9" });
|
|
414
441
|
});
|
|
415
442
|
|
|
443
|
+
it("指令回调(JSON 包体):信封小写键 + 明文字段照常落库", async () => {
|
|
444
|
+
// 回调地址不带 callback_format=json(文档没覆盖这个参数,见 config.ts::eventUrlOf),
|
|
445
|
+
// 但归一化那层两种都认:万一上游改发 JSON,上下两层分别是
|
|
446
|
+
// {"tousername","encrypt","agentid"}(小写)与 {"InfoType",…}(大写)。
|
|
447
|
+
// 这条钉的是「大小写不敏感」这一层,别再让人回头改一次字段名。
|
|
448
|
+
const plain = JSON.stringify({ InfoType: "suite_ticket", SuiteId: SUITE, SuiteTicket: "tk_json" });
|
|
449
|
+
const encrypted = await seal(plain);
|
|
450
|
+
request({ query: signedQuery(encrypted), body: { tousername: SUITE, encrypt: encrypted, agentid: "100001" } });
|
|
451
|
+
await (await api()).event.post();
|
|
452
|
+
expect(out.status).toBe(200);
|
|
453
|
+
expect(out.text).toBe("success");
|
|
454
|
+
expect(dbo.prepare("SELECT suite_ticket FROM wecom_suite_ticket WHERE suite_id = ?").get(SUITE)).toMatchObject({ suite_ticket: "tk_json" });
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it("指令回调(JSON 字符串包体):未经框架解析的原始串也能归一", async () => {
|
|
458
|
+
const plain = JSON.stringify({ InfoType: "suite_ticket", SuiteId: SUITE, SuiteTicket: "tk_str" });
|
|
459
|
+
const encrypted = await seal(plain);
|
|
460
|
+
request({ query: signedQuery(encrypted), body: JSON.stringify({ tousername: SUITE, encrypt: encrypted }) });
|
|
461
|
+
await (await api()).event.post();
|
|
462
|
+
expect(out.status).toBe(200);
|
|
463
|
+
expect(out.text).toBe("success");
|
|
464
|
+
expect(dbo.prepare("SELECT suite_ticket FROM wecom_suite_ticket WHERE suite_id = ?").get(SUITE)).toMatchObject({ suite_ticket: "tk_str" });
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("数据回调 change_contact(应用侧形态:MsgType/Event,无 InfoType)→ 走轨三建档", async () => {
|
|
468
|
+
// 90970「成员变更通知」的原样字段:ToUserName/FromUserName/CreateTime/MsgType=event/
|
|
469
|
+
// Event=change_contact/ChangeType/UserID/Name/Department。
|
|
470
|
+
// 这条钉的是整条接线:解密 → 按 Event 分流(InfoType 为空)→ 绑租户 → 落库。
|
|
471
|
+
seedCorp();
|
|
472
|
+
const plain = [
|
|
473
|
+
"<xml>",
|
|
474
|
+
"<ToUserName><![CDATA[ww_corp_a]]></ToUserName>",
|
|
475
|
+
"<FromUserName><![CDATA[sys]]></FromUserName>",
|
|
476
|
+
"<CreateTime>1700000000</CreateTime>",
|
|
477
|
+
"<MsgType><![CDATA[event]]></MsgType>",
|
|
478
|
+
"<Event><![CDATA[change_contact]]></Event>",
|
|
479
|
+
"<ChangeType><![CDATA[create_user]]></ChangeType>",
|
|
480
|
+
"<UserID><![CDATA[zhangsan]]></UserID>",
|
|
481
|
+
"<Name><![CDATA[张三]]></Name>",
|
|
482
|
+
"<Department>1</Department>",
|
|
483
|
+
"</xml>",
|
|
484
|
+
].join("");
|
|
485
|
+
// 应用回调的 receiveid 是 **corpid**(90968 附注),不是 suiteid——信封 ToUserName 也是它
|
|
486
|
+
const encrypted = await seal(plain, "ww_corp_a");
|
|
487
|
+
const lines = await captureLogs(async () => {
|
|
488
|
+
request({
|
|
489
|
+
query: signedQuery(encrypted),
|
|
490
|
+
body: `<xml><ToUserName><![CDATA[ww_corp_a]]></ToUserName><Encrypt><![CDATA[${encrypted}]]></Encrypt></xml>`,
|
|
491
|
+
});
|
|
492
|
+
await (await api()).event.post();
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
// 无论处理成没成,都必须回 success:否则企微 5 秒后重推三次
|
|
496
|
+
expect(out.status).toBe(200);
|
|
497
|
+
expect(out.text).toBe("success");
|
|
498
|
+
expect(logOf(lines, "wecom change_contact applied")).toEqual(["changeType", "create_user", "corpId", "ww_corp_a"]);
|
|
499
|
+
|
|
500
|
+
// 上游在本用例里拿不到 token(user/get 取不到)→ 退回事件自带字段:
|
|
501
|
+
// 姓名落库、**activated 保持 0**(建档 ≠ 用过本站,§9.1 的通讯录目录只收登录过的人)
|
|
502
|
+
expect(dbo.prepare("SELECT display_name, source, activated FROM users WHERE username = ?").get("wecom:zhangsan"))
|
|
503
|
+
.toMatchObject({ display_name: "张三", source: "wecom", activated: 0 });
|
|
504
|
+
// 值一律不进日志(姓名、工号都在明文里)
|
|
505
|
+
expect(JSON.stringify(lines)).not.toContain("张三");
|
|
506
|
+
expect(JSON.stringify(lines)).not.toContain("zhangsan");
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("数据回调 change_contact(代开发形态:InfoType)→ 同样进轨三,只给 Id 也能落库", async () => {
|
|
510
|
+
// 91134「成员通知事件」走的是模板指令回调,字段是 SuiteId/AuthCorpId/InfoType=change_contact,
|
|
511
|
+
// 没有 MsgType/Event。它与 suite_ticket 同用 InfoType 那一列,分流必须在 switch 之前——
|
|
512
|
+
// 落进 default 只会被记成「不消费的事件」,页面永远等不到部门。
|
|
513
|
+
seedCorp();
|
|
514
|
+
const plain = [
|
|
515
|
+
"<xml>",
|
|
516
|
+
"<SuiteId><![CDATA[suite_1]]></SuiteId>",
|
|
517
|
+
"<AuthCorpId><![CDATA[ww_corp_a]]></AuthCorpId>",
|
|
518
|
+
"<InfoType><![CDATA[change_contact]]></InfoType>",
|
|
519
|
+
"<ChangeType><![CDATA[update_party]]></ChangeType>",
|
|
520
|
+
"<Id>2</Id>",
|
|
521
|
+
"<Name><![CDATA[研发部]]></Name>",
|
|
522
|
+
"<ParentId>1</ParentId>",
|
|
523
|
+
"</xml>",
|
|
524
|
+
].join("");
|
|
525
|
+
const encrypted = await seal(plain);
|
|
526
|
+
const lines = await captureLogs(async () => {
|
|
527
|
+
request({ query: signedQuery(encrypted), body: `<xml><Encrypt><![CDATA[${encrypted}]]></Encrypt></xml>` });
|
|
528
|
+
await (await api()).event.post();
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
expect(out.status).toBe(200);
|
|
532
|
+
expect(out.text).toBe("success");
|
|
533
|
+
expect(logOf(lines, "wecom change_contact applied")).toEqual(["changeType", "update_party", "corpId", "ww_corp_a"]);
|
|
534
|
+
expect(dbo.prepare("SELECT origin, origin_id, name, deleted FROM sys_department WHERE origin = 'wecom'").get())
|
|
535
|
+
.toMatchObject({ origin: "wecom", origin_id: "2", name: "研发部", deleted: 0 });
|
|
536
|
+
expect(JSON.stringify(lines)).not.toContain("研发部");
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it("未授权企业的通讯录事件:只记一笔,照旧回 success(事件是加速器不是权威)", async () => {
|
|
540
|
+
// 没 seedCorp:企业没授权、没有永久码,什么都不该写——但也不能抛,
|
|
541
|
+
// 否则企微重推三次,而重推补不回「这家企业没授权」。
|
|
542
|
+
const plain = "<xml><ToUserName><![CDATA[ww_corp_z]]></ToUserName><MsgType>event</MsgType><Event>change_contact</Event><ChangeType>delete_user</ChangeType><UserID>lisi</UserID></xml>";
|
|
543
|
+
const encrypted = await seal(plain, "ww_corp_z");
|
|
544
|
+
const lines = await captureLogs(async () => {
|
|
545
|
+
request({ query: signedQuery(encrypted), body: `<xml><ToUserName><![CDATA[ww_corp_z]]></ToUserName><Encrypt><![CDATA[${encrypted}]]></Encrypt></xml>` });
|
|
546
|
+
await (await api()).event.post();
|
|
547
|
+
});
|
|
548
|
+
expect(out.status).toBe(200);
|
|
549
|
+
expect(out.text).toBe("success");
|
|
550
|
+
expect(JSON.stringify(logOf(lines, "wecom change_contact ignored"))).toContain("未授权");
|
|
551
|
+
expect(dbo.prepare("SELECT COUNT(*) AS n FROM users WHERE source = 'wecom'").get()).toMatchObject({ n: 0 });
|
|
552
|
+
});
|
|
553
|
+
|
|
416
554
|
it("报文缺 Encrypt → 400", async () => {
|
|
417
555
|
request({ query: signedQuery("x"), body: "<xml></xml>" });
|
|
418
556
|
await (await api()).event.post();
|
|
@@ -427,6 +565,36 @@ describe("P1-L:GET/POST /auth/wecom/event(企微指令回调)", () => {
|
|
|
427
565
|
expect(dbo.prepare("SELECT COUNT(*) AS n FROM wecom_suite_ticket").get()).toMatchObject({ n: 0 });
|
|
428
566
|
});
|
|
429
567
|
|
|
568
|
+
it("EncodingAESKey 与后台不一致(签名过、解密爆 pkcs7)→ 400,日志点名是哪一侧配错", async () => {
|
|
569
|
+
// 用户实报的原样症状:WARN reason=pkcs7: bad padding。签名先过说明后台 Token 与本部署一致,
|
|
570
|
+
// 所以只回一句 bad padding 等于没给线索——日志必须带「外层 ToUserName / 配置套件 / 实际加载的密钥指纹」。
|
|
571
|
+
const warns: unknown[][] = [];
|
|
572
|
+
const orig = g.log;
|
|
573
|
+
g.log = { ...orig, warn: (msg: string, ...kv: unknown[]) => warns.push([msg, kv]) };
|
|
574
|
+
try {
|
|
575
|
+
const encrypted = await seal(TICKET_XML);
|
|
576
|
+
// 后台用真钥匙加密,本部署加载另一把 43 字符(仍解 32 字节,不会先撞长度校验)
|
|
577
|
+
installVars({ wecom_encoding_aes_key: "Q".repeat(43) });
|
|
578
|
+
request({
|
|
579
|
+
query: signedQuery(encrypted),
|
|
580
|
+
body: `<xml><ToUserName><![CDATA[${SUITE}]]></ToUserName><Encrypt><![CDATA[${encrypted}]]></Encrypt></xml>`,
|
|
581
|
+
});
|
|
582
|
+
await (await api()).event.post();
|
|
583
|
+
}
|
|
584
|
+
finally {
|
|
585
|
+
g.log = orig;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
expect(out.status).toBe(400);
|
|
589
|
+
expect(out.body.msg).toMatch(/解密失败/);
|
|
590
|
+
const hit = warns.find(w => w[0] === "wecom callback decrypt failed");
|
|
591
|
+
expect(hit).toBeDefined();
|
|
592
|
+
const kv = hit![1] as unknown[];
|
|
593
|
+
expect(kv).toContain("pkcs7: bad padding");
|
|
594
|
+
expect(kv).toContain(SUITE); // envelopeToUser 与 configuredSuite 同值 → 指向「密钥不一致」而不是「套件不对」
|
|
595
|
+
expect(kv).toContain(sha1Hex("Q".repeat(43)).slice(0, 8));
|
|
596
|
+
});
|
|
597
|
+
|
|
430
598
|
it("timestamp 超出 ±5 分钟窗口 → 400(历史回调不得被无限期重放)", async () => {
|
|
431
599
|
const encrypted = await seal(TICKET_XML);
|
|
432
600
|
const stale = String(nowSecs() - 8 * 60);
|