@oj-module/cli 0.2.47 → 0.2.49
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/README.md +1 -1
- package/devkit/SKILL.md +23 -4
- package/devkit/framework-dev.md +2 -2
- package/devkit/manual.md +221 -14
- package/devkit/module-dev-and-cdn-playground.md +2 -2
- package/devkit/wecom-daikaifa.md +242 -14
- 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__/build-backend.test.ts +80 -0
- package/src/__tests__/templates-tenant-scope.test.ts +7 -2
- package/src/build.ts +50 -0
- 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/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 +3 -1
- package/templates/src/auth/api/_shared/wecom/client.ts +280 -32
- package/templates/src/auth/api/_shared/wecom/config.ts +12 -1
- 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 +188 -5
- package/templates/src/auth/api/_shared/wecom/payload.ts +116 -0
- package/templates/src/auth/api/_shared/wecom/replay.ts +44 -3
- package/templates/src/auth/api/_shared/wecom/store.ts +65 -0
- package/templates/src/auth/api/_shared/wecom/sync.ts +501 -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 +3 -1
- package/templates/src/auth/api/wecom/api.ts +155 -35
- 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/locales/en-US.json +2 -0
- package/templates/src/login/web/locales/zh-CN.json +2 -0
- package/templates/src/login/web/pages/login.tsx +39 -3
- 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 +112 -6
- package/templates/src/system/web/locales/zh-CN.json +112 -6
- 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 +14 -0
- 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 +1 -0
- package/templates/tests/wecom-connector.spec.ts +146 -10
- package/templates/tests/wecom-crypt.spec.ts +12 -3
- package/templates/tests/wecom-endpoints.spec.ts +247 -19
- 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
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
putLoginState,
|
|
38
38
|
takeLoginState,
|
|
39
39
|
} from "../src/auth/api/_shared/wecom/login";
|
|
40
|
+
import { callbackFields, field } from "../src/auth/api/_shared/wecom/payload";
|
|
40
41
|
import {
|
|
41
42
|
claimLoginTicket,
|
|
42
43
|
ensureTenant,
|
|
@@ -425,17 +426,46 @@ describe("P1-F:凭证链(client:缓存 + 失效重试 + 错误两态)",
|
|
|
425
426
|
await expect(client.userIdByCode("c", "a", "p", "CODE")).rejects.toMatchObject({ status: 502 });
|
|
426
427
|
});
|
|
427
428
|
|
|
428
|
-
it("get_permanent_code 缺 corpid / agent
|
|
429
|
+
it("get_permanent_code 缺 corpid / agent 列表为空且复核也没有 → 502(不静默落一行空 profile)", async () => {
|
|
429
430
|
const noAgent = createWecomClient({
|
|
431
|
+
// suite_access_token 由调用方传入,所以这里的应答从 get_permanent_code 开始
|
|
430
432
|
http: stubHttp([
|
|
431
|
-
{ suite_access_token: "sat", expires_in: 7200 },
|
|
432
433
|
{ permanent_code: "perm", auth_corp_info: { corpid: "ww_x" }, auth_info: { agent: [] } },
|
|
434
|
+
// 回源复核(get_auth_info)同样没有应用 → 这时才判死
|
|
435
|
+
{ auth_info: { agent: [] } },
|
|
433
436
|
]),
|
|
434
437
|
cache: fakeCache(),
|
|
435
438
|
});
|
|
436
439
|
await expect(noAgent.permanentCodeByAuthCode("sat", "AUTH")).rejects.toMatchObject({ status: 502 });
|
|
437
440
|
});
|
|
438
441
|
|
|
442
|
+
// 2026-11-01 生产日志:create_auth 拿到永久码,但 auth_info.agent 是空的 → 上一版直接 502,
|
|
443
|
+
// 企业管理员看到「应用安装失败」,而永久码白白作废(auth_code 一次有效)。
|
|
444
|
+
it("get_permanent_code 的 agent 为空 → 拿刚换到的永久码回源 get_auth_info 补齐", async () => {
|
|
445
|
+
const http = stubHttp([
|
|
446
|
+
{ permanent_code: "perm", auth_corp_info: { corpid: "ww_x" }, auth_info: { agent: [] } },
|
|
447
|
+
{ auth_info: { agent: [{ agentid: 1000012, auth_mode: 1 }] } },
|
|
448
|
+
]);
|
|
449
|
+
const client = createWecomClient({ http, cache: fakeCache() });
|
|
450
|
+
|
|
451
|
+
const granted = await client.permanentCodeByAuthCode("sat", "AUTH");
|
|
452
|
+
|
|
453
|
+
expect(granted.agentIds).toEqual(["1000012"]);
|
|
454
|
+
expect(granted.authModes["1000012"]).toBe(1);
|
|
455
|
+
expect(http.calls.some(u => u.includes("/service/get_auth_info"))).toBe(true);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it("auth_info.agent 给成单个对象(不是数组)也认——少一次「明明有应用却判成没授权」", async () => {
|
|
459
|
+
const client = createWecomClient({
|
|
460
|
+
http: stubHttp([
|
|
461
|
+
{ permanent_code: "perm", auth_corp_info: { corpid: "ww_x" }, auth_info: { agent: { agentid: 1000011, auth_mode: 0 } } },
|
|
462
|
+
]),
|
|
463
|
+
cache: fakeCache(),
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
expect((await client.permanentCodeByAuthCode("sat", "AUTH")).agentIds).toEqual(["1000011"]);
|
|
467
|
+
});
|
|
468
|
+
|
|
439
469
|
it("userNameById 拿到真名(去首尾空白)", async () => {
|
|
440
470
|
const http = stubHttp([{ access_token: "t", expires_in: 7200 }, { errcode: 0, name: " 张三 " }]);
|
|
441
471
|
const client = createWecomClient({ http, cache: fakeCache() });
|
|
@@ -493,7 +523,7 @@ describe("P1-G:事件分发(suite_ticket / 授权 / 解绑)", () => {
|
|
|
493
523
|
return "sat";
|
|
494
524
|
},
|
|
495
525
|
async permanentCodeByAuthCode() {
|
|
496
|
-
return { permanentCode: "perm_new", corpId: "ww_corp_a", corpName: "测试企业", agentIds: ["100001", "100002"], state: null, ...overrides };
|
|
526
|
+
return { permanentCode: "perm_new", corpId: "ww_corp_a", corpName: "测试企业", agentIds: ["100001", "100002"], authModes: { 100001: 0, 100002: 0 }, state: null, ...overrides };
|
|
497
527
|
},
|
|
498
528
|
async corpAccessToken() {
|
|
499
529
|
return "ct";
|
|
@@ -510,14 +540,39 @@ describe("P1-G:事件分发(suite_ticket / 授权 / 解绑)", () => {
|
|
|
510
540
|
const deps = (client: unknown) => ({ cfg: CFG, client: client as never, store: storeDeps() });
|
|
511
541
|
|
|
512
542
|
it("suite_ticket:落库并给 30 分钟有效期(企微 10 分钟推一次,过期即拒发 API)", async () => {
|
|
513
|
-
await handleSuiteEvent("
|
|
543
|
+
await handleSuiteEvent({ SuiteId: "suite_1", InfoType: "suite_ticket", SuiteTicket: "tk_1" }, deps(fakeClient()));
|
|
514
544
|
expect(await getSuiteTicket(CFG.suiteId, storeDeps())).toEqual({ ticket: "tk_1", expiresAt: clock + 1800 });
|
|
515
545
|
});
|
|
516
546
|
|
|
547
|
+
it("suite_ticket 顺势预热 suite token(授权路径只剩一次出网),换票失败也不影响票据落库", async () => {
|
|
548
|
+
const seen: string[] = [];
|
|
549
|
+
const warm = {
|
|
550
|
+
...fakeClient(),
|
|
551
|
+
async suiteAccessToken(suiteId: string, secret: string, ticket: string) {
|
|
552
|
+
seen.push(`${suiteId}/${secret}/${ticket}`);
|
|
553
|
+
return "sat";
|
|
554
|
+
},
|
|
555
|
+
} as never;
|
|
556
|
+
await handleSuiteEvent({ SuiteId: "suite_1", InfoType: "suite_ticket", SuiteTicket: "tk_9" }, deps(warm));
|
|
557
|
+
expect(seen).toEqual(["suite_1/secret_1/tk_9"]);
|
|
558
|
+
|
|
559
|
+
// suite_secret 配错时只是预热失败:票据必须照旧落库,否则 create_auth 连门都进不去
|
|
560
|
+
const broken = {
|
|
561
|
+
...fakeClient(),
|
|
562
|
+
async suiteAccessToken() {
|
|
563
|
+
throw new Error("企微 get_suite_token 失败:errcode=40001");
|
|
564
|
+
},
|
|
565
|
+
} as never;
|
|
566
|
+
await expect(handleSuiteEvent({ SuiteId: "suite_1", InfoType: "suite_ticket", SuiteTicket: "tk_10" }, deps(broken)))
|
|
567
|
+
.resolves
|
|
568
|
+
.toBe("suite_ticket");
|
|
569
|
+
expect((await getSuiteTicket(CFG.suiteId, storeDeps())).ticket).toBe("tk_10");
|
|
570
|
+
});
|
|
571
|
+
|
|
517
572
|
it("create_auth:AuthCode 换永久码 → 每个 agent 一行 profile,并作废旧 corp token", async () => {
|
|
518
573
|
await putSuiteTicket(CFG.suiteId, "tk_1", clock + 1800, storeDeps());
|
|
519
574
|
const client = fakeClient();
|
|
520
|
-
await handleSuiteEvent("
|
|
575
|
+
await handleSuiteEvent({ SuiteId: "suite_1", InfoType: "create_auth", AuthCode: "AC_1", State: "env=test" }, deps(client));
|
|
521
576
|
const rows = dbo.prepare("SELECT agent_id, env, status FROM wecom_app_profile ORDER BY agent_id").all();
|
|
522
577
|
expect(rows).toEqual([
|
|
523
578
|
{ agent_id: "100001", env: "test", status: "active" },
|
|
@@ -528,31 +583,111 @@ describe("P1-G:事件分发(suite_ticket / 授权 / 解绑)", () => {
|
|
|
528
583
|
expect(dbo.prepare("SELECT action FROM sys_oplog WHERE action = 'wecom.auth.apply'").all()).toHaveLength(1);
|
|
529
584
|
});
|
|
530
585
|
|
|
586
|
+
// 回归:线上「应用安装失败,联系服务商」的正因——回调是匿名请求(无 X-TENANT-ID),
|
|
587
|
+
// sys_oplog 是租户表,sql_guard: deny 下不绑租户就写会被守卫拒;而它发生在 profile 已落库之后,
|
|
588
|
+
// 于是「授权其实成功了,企微却判失败」。鉴据:审计行必须落在该企业自己的租户下。
|
|
589
|
+
it("create_auth 匿名回调(无租户头):审计前先按企业绑租户,行落进该企业 oplog", async () => {
|
|
590
|
+
await putSuiteTicket(CFG.suiteId, "tk_1", clock + 1800, storeDeps());
|
|
591
|
+
g.http = {};
|
|
592
|
+
await handleSuiteEvent({ SuiteId: "suite_1", InfoType: "create_auth", AuthCode: "AC_1" }, deps(fakeClient()));
|
|
593
|
+
expect(g.http.tenantId).toBe("ww_corp_a");
|
|
594
|
+
expect(dbo.prepare("SELECT tenant_id, target FROM sys_oplog WHERE action = 'wecom.auth.apply'").get())
|
|
595
|
+
.toMatchObject({ tenant_id: "ww_corp_a", target: "ww_corp_a" });
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it("审计写失败不得把 create_auth 打成失败(授权已落库,企微必须拿到 success)", async () => {
|
|
599
|
+
await putSuiteTicket(CFG.suiteId, "tk_1", clock + 1800, storeDeps());
|
|
600
|
+
const orig = g.db.table;
|
|
601
|
+
g.db.table = (name: string) => {
|
|
602
|
+
if (name === "sys_oplog")
|
|
603
|
+
throw new Error("tenant guard: table(s) [\"sys_oplog\"] require tenant context (missing tenant header)");
|
|
604
|
+
return orig(name);
|
|
605
|
+
};
|
|
606
|
+
try {
|
|
607
|
+
await expect(handleSuiteEvent({ SuiteId: "suite_1", InfoType: "create_auth", AuthCode: "AC_1" }, deps(fakeClient())))
|
|
608
|
+
.resolves
|
|
609
|
+
.toBe("create_auth");
|
|
610
|
+
}
|
|
611
|
+
finally {
|
|
612
|
+
g.db.table = orig;
|
|
613
|
+
}
|
|
614
|
+
expect(dbo.prepare("SELECT COUNT(*) AS n FROM wecom_app_profile").get()).toMatchObject({ n: 2 });
|
|
615
|
+
});
|
|
616
|
+
|
|
531
617
|
it("cancel_auth:profile 全部停用 + 该租户 refresh 串清空(解绑后旧会话不得续命)", async () => {
|
|
532
618
|
await saveProfile(profile({ agent: "100001" }), storeDeps());
|
|
533
619
|
await saveProfile(profile({ agent: "100002" }), storeDeps());
|
|
534
620
|
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);
|
|
535
621
|
|
|
536
|
-
|
|
622
|
+
g.http = {};
|
|
623
|
+
await handleSuiteEvent({ SuiteId: "suite_1", InfoType: "cancel_auth", AuthCorpId: "ww_corp_a" }, deps(fakeClient()));
|
|
537
624
|
|
|
538
625
|
expect(dbo.prepare("SELECT DISTINCT status FROM wecom_app_profile").all()).toEqual([{ status: "disabled" }]);
|
|
539
626
|
expect(dbo.prepare("SELECT COUNT(*) AS n FROM sys_refresh_token").get()).toMatchObject({ n: 0 });
|
|
540
627
|
expect(dbo.prepare("SELECT level FROM sys_oplog WHERE action = 'wecom.auth.cancel'").get()).toMatchObject({ level: 4 });
|
|
628
|
+
// 清会话是裸 SQL,deny 档下同样要租户上下文
|
|
629
|
+
expect(g.http.tenantId).toBe("ww_corp_a");
|
|
541
630
|
});
|
|
542
631
|
|
|
543
632
|
it("SuiteId 与本部署不一致 → 400(不同套件的回调不得互相污染)", async () => {
|
|
544
|
-
await expect(handleSuiteEvent("
|
|
633
|
+
await expect(handleSuiteEvent({ SuiteId: "other", InfoType: "suite_ticket", SuiteTicket: "t" }, deps(fakeClient())))
|
|
545
634
|
.rejects
|
|
546
635
|
.toMatchObject({ status: 400 });
|
|
547
636
|
});
|
|
548
637
|
|
|
549
|
-
it("
|
|
550
|
-
expect(await handleSuiteEvent("
|
|
638
|
+
it("不消费的事件回 success 语义的 InfoType,不抛错(否则企微重推三次)", async () => {
|
|
639
|
+
expect(await handleSuiteEvent({ InfoType: "change_contact", SuiteId: "suite_1" }, deps(fakeClient()))).toBe("change_contact");
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
// 数据回调没有 InfoType,只有 MsgType=event + Event=change_contact:分流靠 Event 认,
|
|
643
|
+
// 认不出就会落进 default 被记成「不消费的事件」——而这条正是轨三的全部输入。
|
|
644
|
+
it("数据回调形态(无 InfoType)也能分流到轨三,且日志只留 ChangeType 不留值", async () => {
|
|
645
|
+
const dataCallback = {
|
|
646
|
+
ToUserName: "ww_corp_a",
|
|
647
|
+
CreateTime: 1700000000,
|
|
648
|
+
MsgType: "event",
|
|
649
|
+
Event: "change_contact",
|
|
650
|
+
ChangeType: "update_party",
|
|
651
|
+
Id: 2,
|
|
652
|
+
};
|
|
653
|
+
const logged: unknown[][] = [];
|
|
654
|
+
const orig = g.log;
|
|
655
|
+
const push = (...args: unknown[]) => void logged.push(args);
|
|
656
|
+
// 失败那条是 warn(成功才是 info):两个都要抓,否则「没抛错」会掩盖「根本没走到轨三」
|
|
657
|
+
g.log = { ...orig, info: push, warn: push };
|
|
658
|
+
try {
|
|
659
|
+
// 返回值是 ChangeType(日志只用它记一行),不是事件名
|
|
660
|
+
expect(await handleSuiteEvent(dataCallback, deps(fakeClient()))).toBe("update_party");
|
|
661
|
+
}
|
|
662
|
+
finally {
|
|
663
|
+
g.log = orig;
|
|
664
|
+
}
|
|
665
|
+
// 这家企业在本用例里没授权:失败只记一笔,绝不抛(抛了企微就重推三次)
|
|
666
|
+
const line = logged.find(args => args[0] === "wecom change_contact ignored");
|
|
667
|
+
expect(line).toBeDefined();
|
|
668
|
+
// ChangeType 是协议枚举,记下来才有排查价值;姓名/工号这类明文值一个都不许进日志
|
|
669
|
+
expect(line).toContain("update_party");
|
|
670
|
+
expect(JSON.stringify(line)).toContain("未授权");
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
// 兜底口径(payload.ts 头的说明):部分回调业务没有 JSON 文档,收到 XML 也必须能处理。
|
|
674
|
+
// 同一个事件两种格式走出来的效果必须一模一样,否则「换个格式就静默不认」会在真机上才暴露。
|
|
675
|
+
it("XML(文档形态)与 JSON 归一化结果一致:两种包体走同一条路径", async () => {
|
|
676
|
+
const asJson = { SuiteId: "suite_1", InfoType: "create_auth", AuthCode: "AC_xml" };
|
|
677
|
+
const asXml = [
|
|
678
|
+
"<xml><SuiteId><![CDATA[suite_1]]></SuiteId><InfoType><![CDATA[create_auth]]></InfoType>",
|
|
679
|
+
"<AuthCode><![CDATA[AC_xml]]></AuthCode></xml>",
|
|
680
|
+
].join("");
|
|
681
|
+
const fromJson = callbackFields(asJson);
|
|
682
|
+
const fromXml = callbackFields(asXml);
|
|
683
|
+
expect([...fromXml.values.entries()].sort()).toEqual([...fromJson.values.entries()].sort());
|
|
684
|
+
// 信封小写、明文大写都认:上游 JSON 信封是 {"tousername","encrypt"}
|
|
685
|
+
expect(field(callbackFields({ tousername: "ww_corp_a", encrypt: "abc" }), "Encrypt")).toBe("abc");
|
|
551
686
|
});
|
|
552
687
|
|
|
553
688
|
it("create_auth 缺 AuthCode → 400(宁可拒也不落半行 profile)", async () => {
|
|
554
689
|
await putSuiteTicket(CFG.suiteId, "tk_1", clock + 1800, storeDeps());
|
|
555
|
-
await expect(handleSuiteEvent("
|
|
690
|
+
await expect(handleSuiteEvent({ SuiteId: "suite_1", InfoType: "create_auth" }, deps(fakeClient())))
|
|
556
691
|
.rejects
|
|
557
692
|
.toMatchObject({ status: 400 });
|
|
558
693
|
});
|
|
@@ -656,6 +791,7 @@ describe("P1-J:部署常量读取(缺项一次报全 / 密钥格式 fail-fas
|
|
|
656
791
|
installVars();
|
|
657
792
|
const cfg = readWecomConfig();
|
|
658
793
|
expect(cfg.baseUrl).toBe("https://site.example.com/api/auth/wecom");
|
|
794
|
+
// 不带 callback_format=json:官方文档只给「对外收款」写了这个参数,代开发的通用回调配置只有 XML
|
|
659
795
|
expect(eventUrlOf(cfg)).toBe("https://site.example.com/api/auth/wecom/event");
|
|
660
796
|
expect(loginRedirectOf(cfg)).toBe("https://site.example.com/api/auth/wecom/callback");
|
|
661
797
|
expect(siteRootOf(cfg)).toBe("https://site.example.com");
|
|
@@ -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(
|
|
84
|
-
tooBig
|
|
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", () => {
|
|
@@ -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'");
|
|
@@ -183,11 +187,30 @@ describe("P1-I:GET /auth/wecom/login(发起授权)", () => {
|
|
|
183
187
|
expect(out.location).toBeNull();
|
|
184
188
|
});
|
|
185
189
|
|
|
186
|
-
|
|
190
|
+
// 代开发模板常只服务一家企业:corp 缺省时从已安装档案自动解析,好让「应用主页」直接填
|
|
191
|
+
// `https://<域名>/login`(企微后台会因 URL 里的 `${corpid}` 占位符字符拒收)。三种情形分开钉。
|
|
192
|
+
it("缺企业代码且一家都没装 → 400,指向「生成开通链接」(不猜默认企业)", async () => {
|
|
187
193
|
request({ query: {} });
|
|
188
194
|
await (await api()).login.get();
|
|
189
195
|
expect(out.status).toBe(400);
|
|
190
|
-
expect(out.body.msg).toMatch(
|
|
196
|
+
expect(out.body.msg).toMatch(/尚未完成企业微信授权/);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("缺企业代码但只装了一家 → 302 到该企业的授权地址(省掉手填企业代码)", async () => {
|
|
200
|
+
seedCorp();
|
|
201
|
+
request({ query: {}, headers: { "user-agent": "Mozilla/5.0 Chrome/120" } });
|
|
202
|
+
await (await api()).login.get();
|
|
203
|
+
expect(out.status).toBe(302);
|
|
204
|
+
expect(out.location).toContain("appid=ww_corp_a");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("缺企业代码且装了多家 → 400,要求链接里带 corp 参数区分", async () => {
|
|
208
|
+
seedCorp("ww_corp_a");
|
|
209
|
+
seedCorp("ww_corp_b");
|
|
210
|
+
request({ query: {} });
|
|
211
|
+
await (await api()).login.get();
|
|
212
|
+
expect(out.status).toBe(400);
|
|
213
|
+
expect(out.body.msg).toMatch(/corp/);
|
|
191
214
|
});
|
|
192
215
|
|
|
193
216
|
it("未授权企业 → 400(文案不区分「没装」与「已停用」)", async () => {
|
|
@@ -374,6 +397,28 @@ describe("P1-L:GET/POST /auth/wecom/event(企微指令回调)", () => {
|
|
|
374
397
|
return { ...query, msg_signature: wecomSignature(TOKEN, query.timestamp, query.nonce, encrypted) };
|
|
375
398
|
}
|
|
376
399
|
|
|
400
|
+
/** 抓这一段里所有 log.info / log.warn 的 (msg, kv):验收看的是日志,不是只看 HTTP 200 */
|
|
401
|
+
async function captureLogs(fn: () => Promise<void>): Promise<unknown[][]> {
|
|
402
|
+
const lines: unknown[][] = [];
|
|
403
|
+
const orig = g.log;
|
|
404
|
+
const push = (msg: string, ...kv: unknown[]) => void lines.push([msg, kv]);
|
|
405
|
+
g.log = { ...orig, info: push, warn: push };
|
|
406
|
+
try {
|
|
407
|
+
await fn();
|
|
408
|
+
}
|
|
409
|
+
finally {
|
|
410
|
+
g.log = orig;
|
|
411
|
+
}
|
|
412
|
+
return lines;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** 取某条日志的 kv 片段;没这条就当场失败,别让后面的断言在 undefined 上跑 */
|
|
416
|
+
function logOf(lines: unknown[][], msg: string): unknown {
|
|
417
|
+
const line = lines.find(a => a[0] === msg);
|
|
418
|
+
expect(line, `没抓到日志:${msg}`).toBeDefined();
|
|
419
|
+
return line![1];
|
|
420
|
+
}
|
|
421
|
+
|
|
377
422
|
it("URL 验证:验签通过 → 以 text/plain 原样回解出的 echostr(不套信封)", async () => {
|
|
378
423
|
const echo = await seal("echo_12345");
|
|
379
424
|
request({ query: { ...signedQuery(echo), echostr: echo } });
|
|
@@ -403,7 +448,8 @@ describe("P1-L:GET/POST /auth/wecom/event(企微指令回调)", () => {
|
|
|
403
448
|
expect(out.body.msg).toMatch(/签名/);
|
|
404
449
|
});
|
|
405
450
|
|
|
406
|
-
it("
|
|
451
|
+
it("指令回调(文档形态:XML)→ 落 suite_ticket → 回纯文本 success", async () => {
|
|
452
|
+
// 96238 §3.2 的报文就是这个形状,也是线上真正会到的形态
|
|
407
453
|
const plain = "<xml><InfoType>suite_ticket</InfoType><SuiteId>suite_1</SuiteId><SuiteTicket>tk_9</SuiteTicket></xml>";
|
|
408
454
|
const encrypted = await seal(plain);
|
|
409
455
|
request({ query: signedQuery(encrypted), body: `<xml><Encrypt><![CDATA[${encrypted}]]></Encrypt></xml>` });
|
|
@@ -413,6 +459,117 @@ describe("P1-L:GET/POST /auth/wecom/event(企微指令回调)", () => {
|
|
|
413
459
|
expect(dbo.prepare("SELECT suite_ticket FROM wecom_suite_ticket WHERE suite_id = ?").get(SUITE)).toMatchObject({ suite_ticket: "tk_9" });
|
|
414
460
|
});
|
|
415
461
|
|
|
462
|
+
it("指令回调(JSON 包体):信封小写键 + 明文字段照常落库", async () => {
|
|
463
|
+
// 回调地址不带 callback_format=json(文档没覆盖这个参数,见 config.ts::eventUrlOf),
|
|
464
|
+
// 但归一化那层两种都认:万一上游改发 JSON,上下两层分别是
|
|
465
|
+
// {"tousername","encrypt","agentid"}(小写)与 {"InfoType",…}(大写)。
|
|
466
|
+
// 这条钉的是「大小写不敏感」这一层,别再让人回头改一次字段名。
|
|
467
|
+
const plain = JSON.stringify({ InfoType: "suite_ticket", SuiteId: SUITE, SuiteTicket: "tk_json" });
|
|
468
|
+
const encrypted = await seal(plain);
|
|
469
|
+
request({ query: signedQuery(encrypted), body: { tousername: SUITE, encrypt: encrypted, agentid: "100001" } });
|
|
470
|
+
await (await api()).event.post();
|
|
471
|
+
expect(out.status).toBe(200);
|
|
472
|
+
expect(out.text).toBe("success");
|
|
473
|
+
expect(dbo.prepare("SELECT suite_ticket FROM wecom_suite_ticket WHERE suite_id = ?").get(SUITE)).toMatchObject({ suite_ticket: "tk_json" });
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it("指令回调(JSON 字符串包体):未经框架解析的原始串也能归一", async () => {
|
|
477
|
+
const plain = JSON.stringify({ InfoType: "suite_ticket", SuiteId: SUITE, SuiteTicket: "tk_str" });
|
|
478
|
+
const encrypted = await seal(plain);
|
|
479
|
+
request({ query: signedQuery(encrypted), body: JSON.stringify({ tousername: SUITE, encrypt: encrypted }) });
|
|
480
|
+
await (await api()).event.post();
|
|
481
|
+
expect(out.status).toBe(200);
|
|
482
|
+
expect(out.text).toBe("success");
|
|
483
|
+
expect(dbo.prepare("SELECT suite_ticket FROM wecom_suite_ticket WHERE suite_id = ?").get(SUITE)).toMatchObject({ suite_ticket: "tk_str" });
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it("数据回调 change_contact(应用侧形态:MsgType/Event,无 InfoType)→ 走轨三建档", async () => {
|
|
487
|
+
// 90970「成员变更通知」的原样字段:ToUserName/FromUserName/CreateTime/MsgType=event/
|
|
488
|
+
// Event=change_contact/ChangeType/UserID/Name/Department。
|
|
489
|
+
// 这条钉的是整条接线:解密 → 按 Event 分流(InfoType 为空)→ 绑租户 → 落库。
|
|
490
|
+
seedCorp();
|
|
491
|
+
const plain = [
|
|
492
|
+
"<xml>",
|
|
493
|
+
"<ToUserName><![CDATA[ww_corp_a]]></ToUserName>",
|
|
494
|
+
"<FromUserName><![CDATA[sys]]></FromUserName>",
|
|
495
|
+
"<CreateTime>1700000000</CreateTime>",
|
|
496
|
+
"<MsgType><![CDATA[event]]></MsgType>",
|
|
497
|
+
"<Event><![CDATA[change_contact]]></Event>",
|
|
498
|
+
"<ChangeType><![CDATA[create_user]]></ChangeType>",
|
|
499
|
+
"<UserID><![CDATA[zhangsan]]></UserID>",
|
|
500
|
+
"<Name><![CDATA[张三]]></Name>",
|
|
501
|
+
"<Department>1</Department>",
|
|
502
|
+
"</xml>",
|
|
503
|
+
].join("");
|
|
504
|
+
// 应用回调的 receiveid 是 **corpid**(90968 附注),不是 suiteid——信封 ToUserName 也是它
|
|
505
|
+
const encrypted = await seal(plain, "ww_corp_a");
|
|
506
|
+
const lines = await captureLogs(async () => {
|
|
507
|
+
request({
|
|
508
|
+
query: signedQuery(encrypted),
|
|
509
|
+
body: `<xml><ToUserName><![CDATA[ww_corp_a]]></ToUserName><Encrypt><![CDATA[${encrypted}]]></Encrypt></xml>`,
|
|
510
|
+
});
|
|
511
|
+
await (await api()).event.post();
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
// 无论处理成没成,都必须回 success:否则企微 5 秒后重推三次
|
|
515
|
+
expect(out.status).toBe(200);
|
|
516
|
+
expect(out.text).toBe("success");
|
|
517
|
+
expect(logOf(lines, "wecom change_contact applied")).toEqual(["changeType", "create_user", "corpId", "ww_corp_a"]);
|
|
518
|
+
|
|
519
|
+
// 上游在本用例里拿不到 token(user/get 取不到)→ 退回事件自带字段:
|
|
520
|
+
// 姓名落库、**activated 保持 0**(建档 ≠ 用过本站,§9.1 的通讯录目录只收登录过的人)
|
|
521
|
+
expect(dbo.prepare("SELECT display_name, source, activated FROM users WHERE username = ?").get("wecom:zhangsan"))
|
|
522
|
+
.toMatchObject({ display_name: "张三", source: "wecom", activated: 0 });
|
|
523
|
+
// 值一律不进日志(姓名、工号都在明文里)
|
|
524
|
+
expect(JSON.stringify(lines)).not.toContain("张三");
|
|
525
|
+
expect(JSON.stringify(lines)).not.toContain("zhangsan");
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it("数据回调 change_contact(代开发形态:InfoType)→ 同样进轨三,只给 Id 也能落库", async () => {
|
|
529
|
+
// 91134「成员通知事件」走的是模板指令回调,字段是 SuiteId/AuthCorpId/InfoType=change_contact,
|
|
530
|
+
// 没有 MsgType/Event。它与 suite_ticket 同用 InfoType 那一列,分流必须在 switch 之前——
|
|
531
|
+
// 落进 default 只会被记成「不消费的事件」,页面永远等不到部门。
|
|
532
|
+
seedCorp();
|
|
533
|
+
const plain = [
|
|
534
|
+
"<xml>",
|
|
535
|
+
"<SuiteId><![CDATA[suite_1]]></SuiteId>",
|
|
536
|
+
"<AuthCorpId><![CDATA[ww_corp_a]]></AuthCorpId>",
|
|
537
|
+
"<InfoType><![CDATA[change_contact]]></InfoType>",
|
|
538
|
+
"<ChangeType><![CDATA[update_party]]></ChangeType>",
|
|
539
|
+
"<Id>2</Id>",
|
|
540
|
+
"<Name><![CDATA[研发部]]></Name>",
|
|
541
|
+
"<ParentId>1</ParentId>",
|
|
542
|
+
"</xml>",
|
|
543
|
+
].join("");
|
|
544
|
+
const encrypted = await seal(plain);
|
|
545
|
+
const lines = await captureLogs(async () => {
|
|
546
|
+
request({ query: signedQuery(encrypted), body: `<xml><Encrypt><![CDATA[${encrypted}]]></Encrypt></xml>` });
|
|
547
|
+
await (await api()).event.post();
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
expect(out.status).toBe(200);
|
|
551
|
+
expect(out.text).toBe("success");
|
|
552
|
+
expect(logOf(lines, "wecom change_contact applied")).toEqual(["changeType", "update_party", "corpId", "ww_corp_a"]);
|
|
553
|
+
expect(dbo.prepare("SELECT origin, origin_id, name, deleted FROM sys_department WHERE origin = 'wecom'").get())
|
|
554
|
+
.toMatchObject({ origin: "wecom", origin_id: "2", name: "研发部", deleted: 0 });
|
|
555
|
+
expect(JSON.stringify(lines)).not.toContain("研发部");
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
it("未授权企业的通讯录事件:只记一笔,照旧回 success(事件是加速器不是权威)", async () => {
|
|
559
|
+
// 没 seedCorp:企业没授权、没有永久码,什么都不该写——但也不能抛,
|
|
560
|
+
// 否则企微重推三次,而重推补不回「这家企业没授权」。
|
|
561
|
+
const plain = "<xml><ToUserName><![CDATA[ww_corp_z]]></ToUserName><MsgType>event</MsgType><Event>change_contact</Event><ChangeType>delete_user</ChangeType><UserID>lisi</UserID></xml>";
|
|
562
|
+
const encrypted = await seal(plain, "ww_corp_z");
|
|
563
|
+
const lines = await captureLogs(async () => {
|
|
564
|
+
request({ query: signedQuery(encrypted), body: `<xml><ToUserName><![CDATA[ww_corp_z]]></ToUserName><Encrypt><![CDATA[${encrypted}]]></Encrypt></xml>` });
|
|
565
|
+
await (await api()).event.post();
|
|
566
|
+
});
|
|
567
|
+
expect(out.status).toBe(200);
|
|
568
|
+
expect(out.text).toBe("success");
|
|
569
|
+
expect(JSON.stringify(logOf(lines, "wecom change_contact ignored"))).toContain("未授权");
|
|
570
|
+
expect(dbo.prepare("SELECT COUNT(*) AS n FROM users WHERE source = 'wecom'").get()).toMatchObject({ n: 0 });
|
|
571
|
+
});
|
|
572
|
+
|
|
416
573
|
it("报文缺 Encrypt → 400", async () => {
|
|
417
574
|
request({ query: signedQuery("x"), body: "<xml></xml>" });
|
|
418
575
|
await (await api()).event.post();
|
|
@@ -427,6 +584,72 @@ describe("P1-L:GET/POST /auth/wecom/event(企微指令回调)", () => {
|
|
|
427
584
|
expect(dbo.prepare("SELECT COUNT(*) AS n FROM wecom_suite_ticket").get()).toMatchObject({ n: 0 });
|
|
428
585
|
});
|
|
429
586
|
|
|
587
|
+
// 2026-11-01 生产时序:create_auth → 上游 502 → 企微 5 秒后重推 → 被「nonce 重复」挡回。
|
|
588
|
+
// 重推是瞬态故障唯一的恢复路径,让它废在重放守卫上等于把恢复路径拆了。
|
|
589
|
+
it("处理失败的 nonce 会被放回:重推按真实原因重试,不被判成重放", async () => {
|
|
590
|
+
const plain = "<xml><InfoType>suite_ticket</InfoType><SuiteId>other_suite</SuiteId><SuiteTicket>tk_x</SuiteTicket></xml>";
|
|
591
|
+
const encrypted = await seal(plain);
|
|
592
|
+
const query = signedQuery(encrypted, { nonce: "retry_once" });
|
|
593
|
+
const body = `<xml><Encrypt>${encrypted}</Encrypt></xml>`;
|
|
594
|
+
|
|
595
|
+
request({ query, body });
|
|
596
|
+
await (await api()).event.post();
|
|
597
|
+
expect(out.status).toBe(400);
|
|
598
|
+
expect(out.body.msg).toMatch(/SuiteId/);
|
|
599
|
+
|
|
600
|
+
// 同一条报文重推(企微的语义):仍按真实原因失败,而不是 400「nonce 重复」
|
|
601
|
+
request({ query, body });
|
|
602
|
+
await (await api()).event.post();
|
|
603
|
+
expect(out.body.msg).toMatch(/SuiteId/);
|
|
604
|
+
expect(out.body.msg).not.toMatch(/nonce 重复/);
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
// 重放闸门的本体:同 nonce 已被占用、而首推还没标「已落库」——此刻重复投递必须挡下
|
|
608
|
+
// (可能是真重放,也可能是首推仍在处理中;两种都不该并发出第二份副作用)。
|
|
609
|
+
it("同 nonce 已被占用且未标已落库 → 400「nonce 重复」(重放闸门本体)", async () => {
|
|
610
|
+
const encrypted = await seal(TICKET_XML);
|
|
611
|
+
const query = signedQuery(encrypted, { nonce: "inflight" });
|
|
612
|
+
const body = `<xml><Encrypt>${encrypted}</Encrypt></xml>`;
|
|
613
|
+
// 模拟「首推正在处理」:计数已到 1,但没有 applied 标记
|
|
614
|
+
await g.kv.incr("wecom:nonce:inflight");
|
|
615
|
+
|
|
616
|
+
request({ query, body });
|
|
617
|
+
await (await api()).event.post();
|
|
618
|
+
expect(out.status).toBe(400);
|
|
619
|
+
expect(out.body.msg).toMatch(/nonce 重复/);
|
|
620
|
+
expect(dbo.prepare("SELECT COUNT(*) AS n FROM wecom_suite_ticket").get()).toMatchObject({ n: 0 });
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
it("EncodingAESKey 与后台不一致(签名过、解密爆 pkcs7)→ 400,日志点名是哪一侧配错", async () => {
|
|
624
|
+
// 用户实报的原样症状:WARN reason=pkcs7: bad padding。签名先过说明后台 Token 与本部署一致,
|
|
625
|
+
// 所以只回一句 bad padding 等于没给线索——日志必须带「外层 ToUserName / 配置套件 / 实际加载的密钥指纹」。
|
|
626
|
+
const warns: unknown[][] = [];
|
|
627
|
+
const orig = g.log;
|
|
628
|
+
g.log = { ...orig, warn: (msg: string, ...kv: unknown[]) => warns.push([msg, kv]) };
|
|
629
|
+
try {
|
|
630
|
+
const encrypted = await seal(TICKET_XML);
|
|
631
|
+
// 后台用真钥匙加密,本部署加载另一把 43 字符(仍解 32 字节,不会先撞长度校验)
|
|
632
|
+
installVars({ wecom_encoding_aes_key: "Q".repeat(43) });
|
|
633
|
+
request({
|
|
634
|
+
query: signedQuery(encrypted),
|
|
635
|
+
body: `<xml><ToUserName><![CDATA[${SUITE}]]></ToUserName><Encrypt><![CDATA[${encrypted}]]></Encrypt></xml>`,
|
|
636
|
+
});
|
|
637
|
+
await (await api()).event.post();
|
|
638
|
+
}
|
|
639
|
+
finally {
|
|
640
|
+
g.log = orig;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
expect(out.status).toBe(400);
|
|
644
|
+
expect(out.body.msg).toMatch(/解密失败/);
|
|
645
|
+
const hit = warns.find(w => w[0] === "wecom callback decrypt failed");
|
|
646
|
+
expect(hit).toBeDefined();
|
|
647
|
+
const kv = hit![1] as unknown[];
|
|
648
|
+
expect(kv).toContain("pkcs7: bad padding");
|
|
649
|
+
expect(kv).toContain(SUITE); // envelopeToUser 与 configuredSuite 同值 → 指向「密钥不一致」而不是「套件不对」
|
|
650
|
+
expect(kv).toContain(sha1Hex("Q".repeat(43)).slice(0, 8));
|
|
651
|
+
});
|
|
652
|
+
|
|
430
653
|
it("timestamp 超出 ±5 分钟窗口 → 400(历史回调不得被无限期重放)", async () => {
|
|
431
654
|
const encrypted = await seal(TICKET_XML);
|
|
432
655
|
const stale = String(nowSecs() - 8 * 60);
|
|
@@ -437,19 +660,24 @@ describe("P1-L:GET/POST /auth/wecom/event(企微指令回调)", () => {
|
|
|
437
660
|
expect(dbo.prepare("SELECT COUNT(*) AS n FROM wecom_suite_ticket").get()).toMatchObject({ n: 0 });
|
|
438
661
|
});
|
|
439
662
|
|
|
440
|
-
|
|
663
|
+
// 处理成功后的重推:回 success 而不是 400——企微只在「拿不到 success」时重推,首推若因回包慢
|
|
664
|
+
// 被判超时,重推再被挡成 400,管理员端就会看到「应用安装失败」。副作用仍然只发生一次。
|
|
665
|
+
it("同一条回调连发两次(成功后的重推)→ 第二次幂等回 success,票据只落一次", async () => {
|
|
441
666
|
const encrypted = await seal(TICKET_XML);
|
|
442
667
|
const body = `<xml><Encrypt>${encrypted}</Encrypt></xml>`;
|
|
443
668
|
request({ query: signedQuery(encrypted), body });
|
|
444
669
|
await (await api()).event.post();
|
|
445
670
|
expect(out.status).toBe(200);
|
|
671
|
+
expect(out.text).toBe("success");
|
|
446
672
|
|
|
447
673
|
installJson();
|
|
448
674
|
request({ query: signedQuery(encrypted), body });
|
|
449
675
|
await (await api()).event.post();
|
|
450
|
-
expect(out.status).toBe(
|
|
451
|
-
expect(out.
|
|
676
|
+
expect(out.status).toBe(200);
|
|
677
|
+
expect(out.text).toBe("success");
|
|
678
|
+
// 幂等成功不等于重放执行:库里仍是首推那次的值
|
|
452
679
|
expect(dbo.prepare("SELECT suite_ticket FROM wecom_suite_ticket WHERE suite_id = ?").get(SUITE)).toMatchObject({ suite_ticket: "tk_9" });
|
|
680
|
+
expect(dbo.prepare("SELECT COUNT(*) AS n FROM wecom_suite_ticket").get()).toMatchObject({ n: 1 });
|
|
453
681
|
});
|
|
454
682
|
|
|
455
683
|
it("换一个新 nonce 的同内容回调仍能落(重放闸门没把正常重试误伤)", async () => {
|