@oj-module/cli 0.2.47 → 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 +237 -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__/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/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 +243 -31
- 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 +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 +3 -1
- 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/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 +115 -8
- 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
|
@@ -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,
|
|
@@ -493,7 +494,7 @@ describe("P1-G:事件分发(suite_ticket / 授权 / 解绑)", () => {
|
|
|
493
494
|
return "sat";
|
|
494
495
|
},
|
|
495
496
|
async permanentCodeByAuthCode() {
|
|
496
|
-
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 };
|
|
497
498
|
},
|
|
498
499
|
async corpAccessToken() {
|
|
499
500
|
return "ct";
|
|
@@ -510,14 +511,39 @@ describe("P1-G:事件分发(suite_ticket / 授权 / 解绑)", () => {
|
|
|
510
511
|
const deps = (client: unknown) => ({ cfg: CFG, client: client as never, store: storeDeps() });
|
|
511
512
|
|
|
512
513
|
it("suite_ticket:落库并给 30 分钟有效期(企微 10 分钟推一次,过期即拒发 API)", async () => {
|
|
513
|
-
await handleSuiteEvent("
|
|
514
|
+
await handleSuiteEvent({ SuiteId: "suite_1", InfoType: "suite_ticket", SuiteTicket: "tk_1" }, deps(fakeClient()));
|
|
514
515
|
expect(await getSuiteTicket(CFG.suiteId, storeDeps())).toEqual({ ticket: "tk_1", expiresAt: clock + 1800 });
|
|
515
516
|
});
|
|
516
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
|
+
|
|
517
543
|
it("create_auth:AuthCode 换永久码 → 每个 agent 一行 profile,并作废旧 corp token", async () => {
|
|
518
544
|
await putSuiteTicket(CFG.suiteId, "tk_1", clock + 1800, storeDeps());
|
|
519
545
|
const client = fakeClient();
|
|
520
|
-
await handleSuiteEvent("
|
|
546
|
+
await handleSuiteEvent({ SuiteId: "suite_1", InfoType: "create_auth", AuthCode: "AC_1", State: "env=test" }, deps(client));
|
|
521
547
|
const rows = dbo.prepare("SELECT agent_id, env, status FROM wecom_app_profile ORDER BY agent_id").all();
|
|
522
548
|
expect(rows).toEqual([
|
|
523
549
|
{ agent_id: "100001", env: "test", status: "active" },
|
|
@@ -528,31 +554,111 @@ describe("P1-G:事件分发(suite_ticket / 授权 / 解绑)", () => {
|
|
|
528
554
|
expect(dbo.prepare("SELECT action FROM sys_oplog WHERE action = 'wecom.auth.apply'").all()).toHaveLength(1);
|
|
529
555
|
});
|
|
530
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
|
+
|
|
531
588
|
it("cancel_auth:profile 全部停用 + 该租户 refresh 串清空(解绑后旧会话不得续命)", async () => {
|
|
532
589
|
await saveProfile(profile({ agent: "100001" }), storeDeps());
|
|
533
590
|
await saveProfile(profile({ agent: "100002" }), storeDeps());
|
|
534
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);
|
|
535
592
|
|
|
536
|
-
|
|
593
|
+
g.http = {};
|
|
594
|
+
await handleSuiteEvent({ SuiteId: "suite_1", InfoType: "cancel_auth", AuthCorpId: "ww_corp_a" }, deps(fakeClient()));
|
|
537
595
|
|
|
538
596
|
expect(dbo.prepare("SELECT DISTINCT status FROM wecom_app_profile").all()).toEqual([{ status: "disabled" }]);
|
|
539
597
|
expect(dbo.prepare("SELECT COUNT(*) AS n FROM sys_refresh_token").get()).toMatchObject({ n: 0 });
|
|
540
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");
|
|
541
601
|
});
|
|
542
602
|
|
|
543
603
|
it("SuiteId 与本部署不一致 → 400(不同套件的回调不得互相污染)", async () => {
|
|
544
|
-
await expect(handleSuiteEvent("
|
|
604
|
+
await expect(handleSuiteEvent({ SuiteId: "other", InfoType: "suite_ticket", SuiteTicket: "t" }, deps(fakeClient())))
|
|
545
605
|
.rejects
|
|
546
606
|
.toMatchObject({ status: 400 });
|
|
547
607
|
});
|
|
548
608
|
|
|
549
|
-
it("
|
|
550
|
-
expect(await handleSuiteEvent("
|
|
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");
|
|
551
657
|
});
|
|
552
658
|
|
|
553
659
|
it("create_auth 缺 AuthCode → 400(宁可拒也不落半行 profile)", async () => {
|
|
554
660
|
await putSuiteTicket(CFG.suiteId, "tk_1", clock + 1800, storeDeps());
|
|
555
|
-
await expect(handleSuiteEvent("
|
|
661
|
+
await expect(handleSuiteEvent({ SuiteId: "suite_1", InfoType: "create_auth" }, deps(fakeClient())))
|
|
556
662
|
.rejects
|
|
557
663
|
.toMatchObject({ status: 400 });
|
|
558
664
|
});
|
|
@@ -656,6 +762,7 @@ describe("P1-J:部署常量读取(缺项一次报全 / 密钥格式 fail-fas
|
|
|
656
762
|
installVars();
|
|
657
763
|
const cfg = readWecomConfig();
|
|
658
764
|
expect(cfg.baseUrl).toBe("https://site.example.com/api/auth/wecom");
|
|
765
|
+
// 不带 callback_format=json:官方文档只给「对外收款」写了这个参数,代开发的通用回调配置只有 XML
|
|
659
766
|
expect(eventUrlOf(cfg)).toBe("https://site.example.com/api/auth/wecom/event");
|
|
660
767
|
expect(loginRedirectOf(cfg)).toBe("https://site.example.com/api/auth/wecom/callback");
|
|
661
768
|
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'");
|
|
@@ -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);
|