@oj-module/cli 0.2.43 → 0.2.46
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 +7 -5
- package/devkit/SKILL.md +22 -2
- package/devkit/manual.md +5 -0
- package/devkit/wecom-daikaifa.md +421 -0
- package/package.json +2 -2
- package/shell-dist/assets/runtime.js +102 -39
- package/shell-dist/index.html +2 -2
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/manifest-routes-derive.test.ts +22 -0
- package/src/__tests__/site-tenant-header.test.ts +54 -0
- package/src/__tests__/templates-tenant-scope.test.ts +139 -0
- package/src/build.ts +9 -0
- package/src/init.ts +4 -1
- package/src/install.ts +2 -2
- package/src/market/bootstrap.ts +15 -0
- package/src/market/setup.ts +3 -3
- package/src/market/walkthrough.ts +35 -29
- package/src/site.ts +17 -1
- package/templates/config.yaml +72 -2
- package/templates/openapi.json +319 -0
- package/templates/src/_platform/api/_lifecycle/steps/download.ts +6 -2
- package/templates/src/_platform/api/_lifecycle/steps/smoke.ts +17 -9
- package/templates/src/_platform/api/_shared/respond.ts +11 -6
- package/templates/src/_platform/api/_shared/service/domain/settings.ts +5 -3
- package/templates/src/_platform/api/_shared/service/domain/users.ts +9 -3
- package/templates/src/_platform/api/_shared/service/infra/access.ts +59 -4
- package/templates/src/_platform/api/_shared/service/infra/query.ts +5 -1
- package/templates/src/_platform/api/contacts/api.ts +4 -1
- package/templates/src/_platform/api/contacts/detail/api.ts +3 -0
- package/templates/src/_platform/api/migrations/0015__tenant.mysql.sql +12 -0
- package/templates/src/_platform/api/migrations/0015__tenant.pg.sql +11 -0
- package/templates/src/_platform/api/migrations/0015__tenant.sql +44 -0
- package/templates/src/_platform/api/schema.yaml +28 -0
- package/templates/src/_platform/api/seed.sql +3 -0
- package/templates/src/auth/api/_shared/wecom/admin.ts +112 -0
- package/templates/src/auth/api/_shared/wecom/client.ts +243 -0
- package/templates/src/auth/api/_shared/wecom/config.ts +176 -0
- package/templates/src/auth/api/_shared/wecom/crypt.ts +317 -0
- package/templates/src/auth/api/_shared/wecom/events.ts +127 -0
- package/templates/src/auth/api/_shared/wecom/login.ts +137 -0
- package/templates/src/auth/api/_shared/wecom/replay.ts +66 -0
- package/templates/src/auth/api/_shared/wecom/store.ts +230 -0
- package/templates/src/auth/api/_shared/wecom/xml.ts +36 -0
- package/templates/src/auth/api/manifest.yaml +6 -0
- package/templates/src/auth/api/migrations/0001__wecom.mysql.sql +35 -0
- package/templates/src/auth/api/migrations/0001__wecom.pg.sql +33 -0
- package/templates/src/auth/api/migrations/0001__wecom.sql +47 -0
- package/templates/src/auth/api/schema.yaml +48 -0
- package/templates/src/auth/api/wecom/admin/api.ts +121 -0
- package/templates/src/auth/api/wecom/api.ts +246 -0
- package/templates/src/auth/web/__client/api.schemas.ts +36 -0
- package/templates/src/auth/web/__client/api.ts +148 -1
- package/templates/src/demo/api/migrations/0002__tenant.mysql.sql +2 -0
- package/templates/src/demo/api/migrations/0002__tenant.sql +2 -0
- package/templates/src/demo/api/schema.yaml +1 -0
- package/templates/src/home/api/migrations/0002__tenant.mysql.sql +3 -0
- package/templates/src/home/api/migrations/0002__tenant.sql +3 -0
- package/templates/src/home/api/schema.yaml +2 -0
- package/templates/src/login/web/auth-provider.ts +48 -0
- package/templates/src/login/web/entry.ts +21 -1
- package/templates/src/login/web/locales/en-US.json +11 -1
- package/templates/src/login/web/locales/zh-CN.json +11 -1
- package/templates/src/login/web/pages/login.tsx +154 -25
- package/templates/src/login/web/wecom.ts +51 -0
- package/templates/src/notification/api/_shared/notify.ts +9 -5
- package/templates/src/notification/api/api.ts +25 -0
- package/templates/src/notification/api/del/api.ts +5 -0
- package/templates/src/notification/api/manifest.yaml +3 -0
- package/templates/src/notification/api/migrations/0005__tenant.mysql.sql +6 -0
- package/templates/src/notification/api/migrations/0005__tenant.sql +6 -0
- package/templates/src/notification/api/schema.yaml +4 -0
- package/templates/src/personal-center/api/profile/api.ts +13 -0
- package/templates/src/personal-center/api/upload/api.ts +8 -1
- package/templates/src/system/web/entry.ts +8 -0
- package/templates/src/system/web/locales/en-US.json +33 -2
- package/templates/src/system/web/locales/zh-CN.json +33 -2
- package/templates/src/system/web/pages/login-methods/index.tsx +2 -1
- package/templates/src/system/web/pages/users/index.tsx +1 -1
- package/templates/src/system/web/pages/wecom/index.tsx +205 -0
- package/templates/tests/fixture.ts +91 -11
- package/templates/tests/guard.spec.ts +9 -3
- package/templates/tests/lifecycle/deploy-smoke.spec.ts +8 -6
- package/templates/tests/lifecycle/installer.spec.ts +76 -0
- package/templates/tests/lifecycle/trust.spec.ts +1 -1
- package/templates/tests/query-utils.spec.ts +12 -9
- package/templates/tests/rbac-guard.spec.ts +18 -1
- package/templates/tests/tenant-access-cache.spec.ts +99 -0
- package/templates/tests/tenant-forgery.spec.ts +152 -0
- package/templates/tests/tenant-isolation.spec.ts +133 -0
- package/templates/tests/username-namespace.spec.ts +118 -0
- package/templates/tests/wecom-admin.spec.ts +386 -0
- package/templates/tests/wecom-connector.spec.ts +701 -0
- package/templates/tests/wecom-crypt.spec.ts +155 -0
- package/templates/tests/wecom-endpoints.spec.ts +468 -0
- package/templates/tests/wecom-landing.spec.ts +54 -0
- package/vendor/host-versions.json +3 -3
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// 企微回调加解密原语(手写 SHA-1 / AES-256-CBC)——以 Node/WebCrypto 为唯一对照物:
|
|
2
|
+
// 这里不验证「我的实现与我的实现一致」,而是逐字节比对标准实装,避免自洽的错。
|
|
3
|
+
// 加密侧走 `crypto.webcrypto.subtle`(AES-CBC 自带 PKCS#7,与企微协议同语义);
|
|
4
|
+
// 不用 `createCipheriv`:本机 Node 22.21 的它对着整块明文 `final()` 仍吐一块(实测 48B→64B),
|
|
5
|
+
// 会把「多一块」的差异误记到解密实现账上。
|
|
6
|
+
import { Buffer } from "node:buffer";
|
|
7
|
+
import { webcrypto } from "node:crypto";
|
|
8
|
+
import { createRequire } from "node:module";
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it } from "vitest";
|
|
11
|
+
import {
|
|
12
|
+
aes256CbcDecrypt,
|
|
13
|
+
b64ToBytes,
|
|
14
|
+
bytesToB64,
|
|
15
|
+
pkcs7Unpad,
|
|
16
|
+
sha1Hex,
|
|
17
|
+
unwrapWecomMessage,
|
|
18
|
+
utf8Bytes,
|
|
19
|
+
wecomSignature,
|
|
20
|
+
} from "../src/auth/api/_shared/wecom/crypt";
|
|
21
|
+
|
|
22
|
+
const req = createRequire(import.meta.url);
|
|
23
|
+
const nodeCrypto = req("node:crypto") as typeof import("node:crypto");
|
|
24
|
+
|
|
25
|
+
const AES_KEY = webcrypto.getRandomValues(new Uint8Array(32));
|
|
26
|
+
const IV = webcrypto.getRandomValues(new Uint8Array(16));
|
|
27
|
+
|
|
28
|
+
/** 标准 AES-256-CBC 加密(WebCrypto 自动加 PKCS#7,与企微一致) */
|
|
29
|
+
async function encryptCbc(plain: Uint8Array, key: Uint8Array = AES_KEY, iv: Uint8Array = IV): Promise<Uint8Array> {
|
|
30
|
+
const k = await webcrypto.subtle.importKey("raw", key, { name: "AES-CBC" }, false, ["encrypt"]);
|
|
31
|
+
return new Uint8Array(await webcrypto.subtle.encrypt({ name: "AES-CBC", iv }, k, plain));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("sha1Hex 对 OpenSSL", () => {
|
|
35
|
+
it("空串与 ASCII", () => {
|
|
36
|
+
expect(sha1Hex("")).toBe(nodeCrypto.createHash("sha1").update("").digest("hex"));
|
|
37
|
+
expect(sha1Hex("abc")).toBe("a9993e364706816aba3e25717850c26c9cd0d89d");
|
|
38
|
+
expect(sha1Hex("The quick brown fox jumps over the lazy dog")).toBe("2fd4e1c67a2d28fced849ee1bb76e7391b93eb12");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("uTF-8 多字节(走 TextEncoder 分流)", () => {
|
|
42
|
+
const s = "企业微信 wecom 中文 🙂";
|
|
43
|
+
expect(sha1Hex(s)).toBe(nodeCrypto.createHash("sha1").update(s, "utf8").digest("hex"));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("块边界与跨块长度(1/55/56/63/64/65/119/120/200 字节)", () => {
|
|
47
|
+
for (const n of [1, 55, 56, 63, 64, 65, 119, 120, 200]) {
|
|
48
|
+
const buf = nodeCrypto.randomBytes(n);
|
|
49
|
+
expect(sha1Hex(new Uint8Array(buf))).toBe(nodeCrypto.createHash("sha1").update(buf).digest("hex"));
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("aes256CbcDecrypt 对 WebCrypto", () => {
|
|
55
|
+
it("多种明文长度逐一比对(含整块时补整块填充的边界)", async () => {
|
|
56
|
+
for (const n of [0, 1, 15, 16, 17, 32, 38, 100, 255]) {
|
|
57
|
+
const plain = webcrypto.getRandomValues(new Uint8Array(n));
|
|
58
|
+
const cipher = await encryptCbc(plain);
|
|
59
|
+
const got = aes256CbcDecrypt(cipher, AES_KEY, IV);
|
|
60
|
+
expect(got.length).toBe(cipher.length);
|
|
61
|
+
expect(Buffer.from(pkcs7Unpad(got)).equals(Buffer.from(plain))).toBe(true);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("密钥/IV 错则明文不同(防实现自洽:不同钥必须解出不同结果)", async () => {
|
|
66
|
+
const plain = webcrypto.getRandomValues(new Uint8Array(32));
|
|
67
|
+
const cipher = await encryptCbc(plain);
|
|
68
|
+
const wrongKey = webcrypto.getRandomValues(new Uint8Array(32));
|
|
69
|
+
const wrongIv = webcrypto.getRandomValues(new Uint8Array(16));
|
|
70
|
+
expect(Buffer.from(aes256CbcDecrypt(cipher, wrongKey, IV)).equals(Buffer.from(plain))).toBe(false);
|
|
71
|
+
expect(Buffer.from(aes256CbcDecrypt(cipher, AES_KEY, wrongIv)).equals(Buffer.from(plain))).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("非法输入硬失败:长度非 16 倍数、空串、IV 长度错、密钥长度错", () => {
|
|
75
|
+
expect(() => aes256CbcDecrypt(new Uint8Array(15), AES_KEY, IV)).toThrow(/multiple of 16/);
|
|
76
|
+
expect(() => aes256CbcDecrypt(new Uint8Array(0), AES_KEY, IV)).toThrow(/multiple of 16/);
|
|
77
|
+
expect(() => aes256CbcDecrypt(new Uint8Array(16), AES_KEY, new Uint8Array(15))).toThrow(/iv must be 16/);
|
|
78
|
+
expect(() => aes256CbcDecrypt(new Uint8Array(16), new Uint8Array(16), IV)).toThrow(/key must be 32/);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("pKCS#7 非法填充硬失败(不返回半截明文)", () => {
|
|
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/);
|
|
86
|
+
const inconsistent = new Uint8Array(16).fill(0);
|
|
87
|
+
inconsistent[14] = 3;
|
|
88
|
+
inconsistent[15] = 3;
|
|
89
|
+
expect(() => pkcs7Unpad(inconsistent)).toThrow(/bad padding/);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("wecomSignature:字典序排序后 sha1", () => {
|
|
94
|
+
it("与手算 OpenSSL 一致(乱序入参同结果)", () => {
|
|
95
|
+
const token = "QDG6eK";
|
|
96
|
+
const ts = "1409659813";
|
|
97
|
+
const nonce = "1372623149";
|
|
98
|
+
const enc = "RypEvHKD8QQKFhvQ6QleEB4J58tiPdvo+rtK1I9qca6aM/wvqnLSV5zE";
|
|
99
|
+
const sig = wecomSignature(token, ts, nonce, enc);
|
|
100
|
+
const manual = nodeCrypto.createHash("sha1")
|
|
101
|
+
.update([token, ts, nonce, enc].sort().join(""), "utf8")
|
|
102
|
+
.digest("hex");
|
|
103
|
+
expect(sig).toBe(manual);
|
|
104
|
+
expect(wecomSignature(enc, nonce, ts, token)).toBe(sig);
|
|
105
|
+
expect(wecomSignature(token, ts, nonce)).not.toBe(sig); // 少一项必须不同
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("unwrapWecomMessage:random(16) | len(4) | msg | receiveid 结构", () => {
|
|
110
|
+
const key = webcrypto.getRandomValues(new Uint8Array(32));
|
|
111
|
+
const encodingAesKey = Buffer.from(key).toString("base64").slice(0, 43);
|
|
112
|
+
|
|
113
|
+
/** 按企微明文结构造包并加密(与解密侧严格互逆) */
|
|
114
|
+
async function buildEnvelope(msg: string, receiver: string): Promise<string> {
|
|
115
|
+
const msgBytes = utf8Bytes(msg);
|
|
116
|
+
const recv = utf8Bytes(receiver);
|
|
117
|
+
const body = new Uint8Array(20 + msgBytes.length + recv.length);
|
|
118
|
+
body.set(webcrypto.getRandomValues(new Uint8Array(16)), 0);
|
|
119
|
+
new DataView(body.buffer).setUint32(16, msgBytes.length);
|
|
120
|
+
body.set(msgBytes, 20);
|
|
121
|
+
body.set(recv, 20 + msgBytes.length);
|
|
122
|
+
return bytesToB64(await encryptCbc(body, key, key.subarray(0, 16)));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
it("解出 <xml> 明文,receiver 命中 suite_id", async () => {
|
|
126
|
+
const xml = "<xml><SuiteId>ww123</SuiteId><InfoType>suite_ticket</InfoType><SuiteTicket>abc</SuiteTicket></xml>";
|
|
127
|
+
expect(unwrapWecomMessage(await buildEnvelope(xml, "ww123"), encodingAesKey, "ww123")).toBe(xml);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("空 msg 也能解出空串(不把长度 0 当错)", async () => {
|
|
131
|
+
expect(unwrapWecomMessage(await buildEnvelope("", "ww123"), encodingAesKey, "ww123")).toBe("");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("uTF-8 明文(中文企业名)不被截坏——长度按字节而非字符", async () => {
|
|
135
|
+
const xml = "<xml><CorpName>示例科技(上海)有限公司</CorpName></xml>";
|
|
136
|
+
expect(unwrapWecomMessage(await buildEnvelope(xml, "ww123"), encodingAesKey, "ww123")).toBe(xml);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("receiver 不匹配必须拒(错套件/错钥混用 fail-closed)", async () => {
|
|
140
|
+
const enc = await buildEnvelope("<xml/>", "ww-other");
|
|
141
|
+
expect(() => unwrapWecomMessage(enc, encodingAesKey, "ww123")).toThrow(/receiver mismatch/);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("encodingAESKey 非法(长度/字符)必须硬失败", async () => {
|
|
145
|
+
const enc = await buildEnvelope("x", "ww123");
|
|
146
|
+
expect(() => unwrapWecomMessage(enc, `${encodingAesKey.slice(0, 40)}=`, "ww123")).toThrow();
|
|
147
|
+
expect(() => unwrapWecomMessage(enc, "@".repeat(43), "ww123")).toThrow();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("密文损坏 → 填充校验硬失败(不返回半截明文)", async () => {
|
|
151
|
+
const bytes = b64ToBytes(await buildEnvelope("hello", "ww123"));
|
|
152
|
+
bytes[bytes.length - 1] ^= 0xFF;
|
|
153
|
+
expect(() => unwrapWecomMessage(bytesToB64(bytes), encodingAesKey, "ww123")).toThrow();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
// BDD:企微代开发四个端点的**接线**(不是领域逻辑——那在 wecom-connector.spec.ts)。
|
|
2
|
+
//
|
|
3
|
+
// 这里钉的是最容易写错、且错得很安静的三件事:
|
|
4
|
+
// 1. 302 端点不能落进 respond(它末尾 json.ok 会把 302 覆盖成 200 信封);
|
|
5
|
+
// 2. 企微事件端点必须回**纯文本** success / echostr(JSON 信封 = 企微判失败 → 重推三次);
|
|
6
|
+
// 3. 回调链的租户来自服务端推导(state→profile → db.asTenant),且票据只能兑换一次。
|
|
7
|
+
//
|
|
8
|
+
// json / vars / kv / fetch / crypto 全部桩掉(oj 运行时全局同名同面);密文用 WebCrypto 现造,
|
|
9
|
+
// 与 wecom-crypt.spec.ts 同一对照物,不拿自实现给自己背书。
|
|
10
|
+
/* eslint-disable test/prefer-lowercase-title -- AC 编号是验收锚点,保留大写 */
|
|
11
|
+
import { webcrypto } from "node:crypto";
|
|
12
|
+
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
13
|
+
import { bytesToB64, utf8Bytes, wecomSignature } from "../src/auth/api/_shared/wecom/crypt";
|
|
14
|
+
import { installDb } from "./fixture";
|
|
15
|
+
|
|
16
|
+
const g = globalThis as any;
|
|
17
|
+
let dbo: ReturnType<typeof installDb>;
|
|
18
|
+
|
|
19
|
+
const SUITE = "suite_1";
|
|
20
|
+
const TOKEN = "cb_token";
|
|
21
|
+
const AES_KEY = webcrypto.getRandomValues(new Uint8Array(32));
|
|
22
|
+
const AES_KEY_B64 = bytesToB64(AES_KEY);
|
|
23
|
+
|
|
24
|
+
interface Captured { status: number | null, body: any, location: string | null, text: string | null, contentType: string | null }
|
|
25
|
+
|
|
26
|
+
let out: Captured;
|
|
27
|
+
|
|
28
|
+
function installJson(): void {
|
|
29
|
+
out = { status: null, body: null, location: null, text: null, contentType: null };
|
|
30
|
+
const redirect = (url: string, code = 302) => {
|
|
31
|
+
out.status = code;
|
|
32
|
+
out.location = url;
|
|
33
|
+
};
|
|
34
|
+
g.json = {
|
|
35
|
+
ok: (data?: unknown) => {
|
|
36
|
+
out.status = 200;
|
|
37
|
+
out.body = data;
|
|
38
|
+
},
|
|
39
|
+
fail: (code: number, msg: string, data?: unknown) => {
|
|
40
|
+
out.status = code;
|
|
41
|
+
out.body = data === undefined ? { code, msg } : { code, msg, data };
|
|
42
|
+
},
|
|
43
|
+
header: () => {},
|
|
44
|
+
raw: (data: unknown) => {
|
|
45
|
+
out.status = 200;
|
|
46
|
+
out.body = data;
|
|
47
|
+
},
|
|
48
|
+
redirect: Object.assign(redirect, {
|
|
49
|
+
found: (url: string) => redirect(url, 302),
|
|
50
|
+
seeOther: (url: string) => redirect(url, 303),
|
|
51
|
+
}),
|
|
52
|
+
stream: (opts?: { contentType?: string }) => {
|
|
53
|
+
out.status = 200;
|
|
54
|
+
out.contentType = opts?.contentType ?? null;
|
|
55
|
+
return {
|
|
56
|
+
write: (chunk: string) => {
|
|
57
|
+
out.text = (out.text ?? "") + chunk;
|
|
58
|
+
},
|
|
59
|
+
end: () => {},
|
|
60
|
+
};
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function installVars(overrides: Record<string, string> = {}): void {
|
|
66
|
+
const base: Record<string, string> = {
|
|
67
|
+
wecom_suite_id: SUITE,
|
|
68
|
+
wecom_suite_secret: "suite_secret",
|
|
69
|
+
wecom_callback_token: TOKEN,
|
|
70
|
+
wecom_encoding_aes_key: AES_KEY_B64.replace(/=$/, ""),
|
|
71
|
+
// 32 字符 hex = 16 字节,是 oj aesGcmEncrypt 接受的两种尺寸之一
|
|
72
|
+
wecom_enc_key: "0123456789abcdef0123456789abcdef",
|
|
73
|
+
wecom_base_url: "https://site.example.com/api/auth/wecom",
|
|
74
|
+
...overrides,
|
|
75
|
+
};
|
|
76
|
+
g.vars = { get: (name: string) => base[name] ?? null };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function installKv(): Map<string, string> {
|
|
80
|
+
const store = new Map<string, string>();
|
|
81
|
+
// 计数也是真的(有理):重放防护靠 incr 的原子返回值判断「谁是第一个」,
|
|
82
|
+
// 恒返回 1 的桩会把这条防线伪装成通过。
|
|
83
|
+
const counters = new Map<string, number>();
|
|
84
|
+
g.kv = {
|
|
85
|
+
get: async (k: string) => store.get(k) ?? null,
|
|
86
|
+
set: async (k: string, v: string) => {
|
|
87
|
+
store.set(k, v);
|
|
88
|
+
},
|
|
89
|
+
del: async (k: string) => {
|
|
90
|
+
store.delete(k);
|
|
91
|
+
counters.delete(k);
|
|
92
|
+
},
|
|
93
|
+
expire: async () => 1,
|
|
94
|
+
incr: async (k: string) => {
|
|
95
|
+
const n = (counters.get(k) ?? 0) + 1;
|
|
96
|
+
counters.set(k, n);
|
|
97
|
+
return n;
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
return store;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function installCrypto(): void {
|
|
104
|
+
const c = {
|
|
105
|
+
randomHex: (n = 32) => (n === 16 ? "state_nonce_0001" : `ticket_${"a".repeat(n * 2)}`),
|
|
106
|
+
sha256Hex: (s: string) => `sha(${s})`,
|
|
107
|
+
aesGcmEncrypt: (plain: string) => `gcm(${plain})`,
|
|
108
|
+
aesGcmDecrypt: (blob: string) => blob.replace(/^gcm\((.*)\)$/, "$1"),
|
|
109
|
+
};
|
|
110
|
+
Object.defineProperty(globalThis, "crypto", { value: c, configurable: true });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 企微侧 webhook 的密文包(random|len|msg|receiveid → AES-256-CBC/PKCS#7) */
|
|
114
|
+
async function seal(plain: string, receiveId = SUITE): Promise<string> {
|
|
115
|
+
const msg = utf8Bytes(plain);
|
|
116
|
+
const head = webcrypto.getRandomValues(new Uint8Array(16));
|
|
117
|
+
const len = new Uint8Array(4);
|
|
118
|
+
new DataView(len.buffer).setUint32(0, msg.length);
|
|
119
|
+
const recv = utf8Bytes(receiveId);
|
|
120
|
+
const buf = new Uint8Array(20 + msg.length + recv.length);
|
|
121
|
+
buf.set(head, 0);
|
|
122
|
+
buf.set(len, 16);
|
|
123
|
+
buf.set(msg, 20);
|
|
124
|
+
buf.set(recv, 20 + msg.length);
|
|
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);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function request(opts: { query?: Record<string, string>, body?: unknown, headers?: Record<string, string>, tenantId?: string } = {}): void {
|
|
131
|
+
g.http = {
|
|
132
|
+
method: "GET",
|
|
133
|
+
query: opts.query ?? {},
|
|
134
|
+
body: opts.body ?? null,
|
|
135
|
+
headers: opts.headers ?? {},
|
|
136
|
+
...(opts.tenantId ? { tenantId: opts.tenantId } : {}),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 预置一家已授权的企业(相当于 create_auth 已跑过) */
|
|
141
|
+
function seedCorp(corpId = "ww_corp_a", agentId = "100001"): void {
|
|
142
|
+
dbo.prepare("INSERT INTO wecom_app_profile (suite_id, corp_id, agent_id, auth_corp_id, env, corp_name, status, permanent_code_enc, installed_at, update_time) VALUES (?, ?, ?, ?, 'prod', '测试企业', 'active', ?, 0, 0)")
|
|
143
|
+
.run(SUITE, corpId, agentId, corpId, `gcm(perm_${corpId})`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const api = async () => (await import("../src/auth/api/wecom/api")).default;
|
|
147
|
+
|
|
148
|
+
beforeAll(() => {
|
|
149
|
+
dbo = installDb();
|
|
150
|
+
g.db.asTenant = (id: string) => {
|
|
151
|
+
if (g.http?.tenantId)
|
|
152
|
+
throw new Error("tenant already set");
|
|
153
|
+
g.http = { ...(g.http ?? {}), tenantId: id };
|
|
154
|
+
};
|
|
155
|
+
g.log = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} };
|
|
156
|
+
g.jwt = { sign: (p: { sub: string }) => `jwt(${p.sub})`, accessDuration: 7200, refreshDuration: 2_592_000 };
|
|
157
|
+
g.bcrypt = { hash: async (s: string) => `hashed:${s}`, verify: async () => false };
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
beforeEach(() => {
|
|
161
|
+
installJson();
|
|
162
|
+
installVars();
|
|
163
|
+
installKv();
|
|
164
|
+
installCrypto();
|
|
165
|
+
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"])
|
|
167
|
+
dbo.exec(`DELETE FROM ${t}`);
|
|
168
|
+
// JIT 建号是跨用例污染源:每个用例都从「本企业还没建过号」起步
|
|
169
|
+
dbo.exec("DELETE FROM users WHERE source = 'wecom'");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
afterEach(() => {
|
|
173
|
+
g.http = {};
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("P1-I:GET /auth/wecom/login(发起授权)", () => {
|
|
177
|
+
it("部署常量缺失 → 400 信封并点出缺哪几个 vars(不是 500)", async () => {
|
|
178
|
+
installVars({ wecom_suite_id: "", wecom_enc_key: "" });
|
|
179
|
+
request({ query: { corp: "ww_corp_a" } });
|
|
180
|
+
await (await api()).login.get();
|
|
181
|
+
expect(out.status).toBe(400);
|
|
182
|
+
expect(out.body.msg).toMatch(/wecom_suite_id/);
|
|
183
|
+
expect(out.location).toBeNull();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("缺企业代码 → 400,引导去登录页填(不猜默认企业)", async () => {
|
|
187
|
+
request({ query: {} });
|
|
188
|
+
await (await api()).login.get();
|
|
189
|
+
expect(out.status).toBe(400);
|
|
190
|
+
expect(out.body.msg).toMatch(/企业代码/);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("未授权企业 → 400(文案不区分「没装」与「已停用」)", async () => {
|
|
194
|
+
request({ query: { corp: "ww_unknown" } });
|
|
195
|
+
await (await api()).login.get();
|
|
196
|
+
expect(out.status).toBe(400);
|
|
197
|
+
expect(out.body.msg).toMatch(/无效或应用未开通/);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("企微客户端内 → 302 到 oauth2 静默授权,redirect_uri 指向 <base>/callback", async () => {
|
|
201
|
+
seedCorp();
|
|
202
|
+
request({ query: { corp: "ww_corp_a" }, headers: { "user-agent": "Mozilla/5.0 wxwork/4.1.0" } });
|
|
203
|
+
await (await api()).login.get();
|
|
204
|
+
expect(out.status).toBe(302);
|
|
205
|
+
expect(out.location).toContain("connect/oauth2/authorize");
|
|
206
|
+
expect(out.location).toContain("agentid=100001");
|
|
207
|
+
expect(out.location).toContain(encodeURIComponent("https://site.example.com/api/auth/wecom/callback"));
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("普通浏览器 → 302 到 qrConnect 扫码(appid = corpid)", async () => {
|
|
211
|
+
seedCorp();
|
|
212
|
+
request({ query: { corp: "ww_corp_a" }, headers: { "user-agent": "Mozilla/5.0 Chrome/120" } });
|
|
213
|
+
await (await api()).login.get();
|
|
214
|
+
expect(out.status).toBe(302);
|
|
215
|
+
expect(out.location).toContain("wwopen/sso/qrConnect");
|
|
216
|
+
expect(out.location).toContain("appid=ww_corp_a");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("同一企业两个应用:未指定 agent → 400 提示带 agent 参数", async () => {
|
|
220
|
+
seedCorp("ww_corp_a", "100001");
|
|
221
|
+
seedCorp("ww_corp_a", "100002");
|
|
222
|
+
request({ query: { corp: "ww_corp_a" }, headers: { "user-agent": "wxwork" } });
|
|
223
|
+
await (await api()).login.get();
|
|
224
|
+
expect(out.status).toBe(400);
|
|
225
|
+
expect(out.body.msg).toMatch(/agent/);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe("P1-J:GET /auth/wecom/callback(回跳换人 + 发票)", () => {
|
|
230
|
+
async function startLogin(corp = "ww_corp_a"): Promise<string> {
|
|
231
|
+
seedCorp(corp);
|
|
232
|
+
request({ query: { corp }, headers: { "user-agent": "wxwork" } });
|
|
233
|
+
await (await api()).login.get();
|
|
234
|
+
const state = new URL(out.location!).searchParams.get("state")!;
|
|
235
|
+
return state;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function stubUpstream(userid: string | null = "zhangsan", memberName: string | null = "张三"): void {
|
|
239
|
+
g.fetch = async (url: string) => ({
|
|
240
|
+
ok: true,
|
|
241
|
+
json: async () => {
|
|
242
|
+
if (url.includes("/cgi-bin/gettoken"))
|
|
243
|
+
return { access_token: "corp_tk", expires_in: 7200 };
|
|
244
|
+
if (url.includes("/cgi-bin/auth/getuserinfo"))
|
|
245
|
+
return userid ? { errcode: 0, userid } : { errcode: 0, openid: "o_ext" };
|
|
246
|
+
// 通讯录读权限:没有就是 60011,补名必须降级而不是失败
|
|
247
|
+
if (url.includes("/cgi-bin/user/get"))
|
|
248
|
+
return memberName ? { errcode: 0, name: memberName } : { errcode: 60011, errmsg: "no privilege" };
|
|
249
|
+
throw new Error(`stub fetch 未预期:${url}`);
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
it("缺 code(用户取消授权)→ 400,不建号不发票", async () => {
|
|
255
|
+
const state = await startLogin();
|
|
256
|
+
request({ query: { state } });
|
|
257
|
+
await (await api()).callback.get();
|
|
258
|
+
expect(out.status).toBe(400);
|
|
259
|
+
expect(out.body.msg).toMatch(/code/);
|
|
260
|
+
expect(dbo.prepare("SELECT COUNT(*) AS n FROM wecom_login_ticket").get()).toMatchObject({ n: 0 });
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("正常回跳 → 302 到 SPA 并只带**一次性票据**与租户;JIT 建号落 corp_id", async () => {
|
|
264
|
+
const state = await startLogin();
|
|
265
|
+
stubUpstream();
|
|
266
|
+
request({ query: { code: "CODE_1", state } });
|
|
267
|
+
await (await api()).callback.get();
|
|
268
|
+
|
|
269
|
+
expect(out.status).toBe(302);
|
|
270
|
+
const loc = new URL(out.location!);
|
|
271
|
+
expect(loc.origin).toBe("https://site.example.com");
|
|
272
|
+
expect(loc.pathname).toBe("/login");
|
|
273
|
+
expect(loc.searchParams.get("tenant")).toBe("ww_corp_a");
|
|
274
|
+
// URL 里只能是票据,不能出现 token
|
|
275
|
+
expect(loc.search).not.toMatch(/access_token|refresh_token/);
|
|
276
|
+
expect(loc.searchParams.get("wecom_ticket")).toBeTruthy();
|
|
277
|
+
|
|
278
|
+
const row = dbo.prepare("SELECT username, source, corp_id, display_name FROM users WHERE source = 'wecom'").get();
|
|
279
|
+
expect(row).toMatchObject({ username: "wecom:zhangsan", corp_id: "ww_corp_a", display_name: "张三" });
|
|
280
|
+
expect(dbo.prepare("SELECT COUNT(*) AS n FROM wecom_login_ticket").get()).toMatchObject({ n: 1 });
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("取不到真实姓名(套件无通讯录权限)→ 用工号兜底建档,登录照常成功", async () => {
|
|
284
|
+
const state = await startLogin();
|
|
285
|
+
stubUpstream("zhangsan", null);
|
|
286
|
+
request({ query: { code: "CODE_1", state } });
|
|
287
|
+
await (await api()).callback.get();
|
|
288
|
+
|
|
289
|
+
expect(out.status).toBe(302);
|
|
290
|
+
expect(dbo.prepare("SELECT display_name FROM users WHERE username = 'wecom:zhangsan'").get()).toMatchObject({ display_name: "zhangsan" });
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("state 一次性:同一 state 重放 → 400(不重复发票)", async () => {
|
|
294
|
+
const state = await startLogin();
|
|
295
|
+
stubUpstream();
|
|
296
|
+
request({ query: { code: "CODE_1", state } });
|
|
297
|
+
await (await api()).callback.get();
|
|
298
|
+
expect(out.status).toBe(302);
|
|
299
|
+
|
|
300
|
+
installJson();
|
|
301
|
+
request({ query: { code: "CODE_1", state } });
|
|
302
|
+
await (await api()).callback.get();
|
|
303
|
+
expect(out.status).toBe(400);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("非本企业成员(企微只回 openid)→ 401,且不留半截账号", async () => {
|
|
307
|
+
const state = await startLogin();
|
|
308
|
+
stubUpstream(null);
|
|
309
|
+
request({ query: { code: "CODE_1", state } });
|
|
310
|
+
await (await api()).callback.get();
|
|
311
|
+
expect(out.status).toBe(401);
|
|
312
|
+
expect(dbo.prepare("SELECT COUNT(*) AS n FROM users WHERE source = 'wecom'").get()).toMatchObject({ n: 0 });
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
describe("P1-K:POST /auth/wecom/ticket(票据换会话)", () => {
|
|
317
|
+
async function issueTicket(): Promise<string> {
|
|
318
|
+
const state = "st_1";
|
|
319
|
+
installKv();
|
|
320
|
+
await g.kv.set(`wecom:state:${state}`, JSON.stringify({ corpId: "ww_corp_a", agentId: "100001", env: "prod" }));
|
|
321
|
+
seedCorp();
|
|
322
|
+
g.fetch = async (url: string) => ({
|
|
323
|
+
ok: true,
|
|
324
|
+
json: async () => (url.includes("gettoken") ? { access_token: "t", expires_in: 7200 } : { errcode: 0, userid: "zhangsan" }),
|
|
325
|
+
});
|
|
326
|
+
request({ query: { code: "CODE_1", state } });
|
|
327
|
+
await (await api()).callback.get();
|
|
328
|
+
return new URL(out.location!).searchParams.get("wecom_ticket")!;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
it("换出 TokenBundle(access + refresh + user),票据随即作废", async () => {
|
|
332
|
+
const ticket = await issueTicket();
|
|
333
|
+
installJson();
|
|
334
|
+
request({ body: { ticket }, tenantId: "ww_corp_a" });
|
|
335
|
+
await (await api()).ticket.post();
|
|
336
|
+
|
|
337
|
+
expect(out.status).toBe(200);
|
|
338
|
+
expect(out.body.access_token).toMatch(/^jwt\(/);
|
|
339
|
+
expect(out.body.refresh_token).toMatch(/^ticket_/);
|
|
340
|
+
expect(out.body.user.username).toBe("wecom:zhangsan");
|
|
341
|
+
expect(dbo.prepare("SELECT COUNT(*) AS n FROM sys_refresh_token").get()).toMatchObject({ n: 1 });
|
|
342
|
+
|
|
343
|
+
installJson();
|
|
344
|
+
request({ body: { ticket }, tenantId: "ww_corp_a" });
|
|
345
|
+
await (await api()).ticket.post();
|
|
346
|
+
expect(out.status).toBe(401);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("租户头与票据不一致 → 403(前端忘了带 ?tenant= 必须当场炸,不能悄悄落默认租户)", async () => {
|
|
350
|
+
const ticket = await issueTicket();
|
|
351
|
+
installJson();
|
|
352
|
+
request({ body: { ticket }, tenantId: "default" });
|
|
353
|
+
await (await api()).ticket.post();
|
|
354
|
+
expect(out.status).toBe(403);
|
|
355
|
+
expect(dbo.prepare("SELECT COUNT(*) AS n FROM sys_refresh_token").get()).toMatchObject({ n: 0 });
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("缺 ticket → 400", async () => {
|
|
359
|
+
request({ body: {} });
|
|
360
|
+
await (await api()).ticket.post();
|
|
361
|
+
expect(out.status).toBe(400);
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
describe("P1-L:GET/POST /auth/wecom/event(企微指令回调)", () => {
|
|
366
|
+
// 必须是「当下」:服务端按 ±5 分钟窗口挡重放,写死历史时间戳等于主动把自己判成重放
|
|
367
|
+
const nowSecs = () => Math.floor(Date.now() / 1000);
|
|
368
|
+
const TS = String(nowSecs());
|
|
369
|
+
const NONCE = "n0nce";
|
|
370
|
+
const TICKET_XML = "<xml><InfoType>suite_ticket</InfoType><SuiteId>suite_1</SuiteId><SuiteTicket>tk_9</SuiteTicket></xml>";
|
|
371
|
+
|
|
372
|
+
function signedQuery(encrypted: string, overrides: Partial<Record<string, string>> = {}): Record<string, string> {
|
|
373
|
+
const query = { timestamp: TS, nonce: NONCE, ...overrides } as Record<string, string>;
|
|
374
|
+
return { ...query, msg_signature: wecomSignature(TOKEN, query.timestamp, query.nonce, encrypted) };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
it("URL 验证:验签通过 → 以 text/plain 原样回解出的 echostr(不套信封)", async () => {
|
|
378
|
+
const echo = await seal("echo_12345");
|
|
379
|
+
request({ query: { ...signedQuery(echo), echostr: echo } });
|
|
380
|
+
await (await api()).event.get();
|
|
381
|
+
expect(out.status).toBe(200);
|
|
382
|
+
expect(out.contentType).toContain("text/plain");
|
|
383
|
+
expect(out.text).toBe("echo_12345");
|
|
384
|
+
expect(out.body).toBeNull();
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("URL 验证(鸡生蛋解法):suite_id 尚未下发时也能通过——receiveid 放宽,签名仍是唯一闸门", async () => {
|
|
388
|
+
// 引导期:模版还没建、suite_id 为空,但 Tier A 凭据(token/aeskey)已配齐,服务应能响应验证。
|
|
389
|
+
installVars({ wecom_suite_id: "" });
|
|
390
|
+
const echo = await seal("echo_bootstrap");
|
|
391
|
+
request({ query: { ...signedQuery(echo), echostr: echo } });
|
|
392
|
+
await (await api()).event.get();
|
|
393
|
+
expect(out.status).toBe(200);
|
|
394
|
+
expect(out.contentType).toContain("text/plain");
|
|
395
|
+
expect(out.text).toBe("echo_bootstrap");
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("签名不符 → 400(不给探测者任何比对材料)", async () => {
|
|
399
|
+
const echo = await seal("echo_12345");
|
|
400
|
+
request({ query: { timestamp: TS, nonce: NONCE, msg_signature: "bad", echostr: echo } });
|
|
401
|
+
await (await api()).event.get();
|
|
402
|
+
expect(out.status).toBe(400);
|
|
403
|
+
expect(out.body.msg).toMatch(/签名/);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("指令回调:验签+解密 → 落 suite_ticket → 回纯文本 success", async () => {
|
|
407
|
+
const plain = "<xml><InfoType>suite_ticket</InfoType><SuiteId>suite_1</SuiteId><SuiteTicket>tk_9</SuiteTicket></xml>";
|
|
408
|
+
const encrypted = await seal(plain);
|
|
409
|
+
request({ query: signedQuery(encrypted), body: `<xml><Encrypt><![CDATA[${encrypted}]]></Encrypt></xml>` });
|
|
410
|
+
await (await api()).event.post();
|
|
411
|
+
expect(out.status).toBe(200);
|
|
412
|
+
expect(out.text).toBe("success");
|
|
413
|
+
expect(dbo.prepare("SELECT suite_ticket FROM wecom_suite_ticket WHERE suite_id = ?").get(SUITE)).toMatchObject({ suite_ticket: "tk_9" });
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("报文缺 Encrypt → 400", async () => {
|
|
417
|
+
request({ query: signedQuery("x"), body: "<xml></xml>" });
|
|
418
|
+
await (await api()).event.post();
|
|
419
|
+
expect(out.status).toBe(400);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("密文属于别的套件(receiveid 不符)→ 400,不落任何状态", async () => {
|
|
423
|
+
const encrypted = await seal("<xml><InfoType>suite_ticket</InfoType><SuiteTicket>tk_x</SuiteTicket></xml>", "other_suite");
|
|
424
|
+
request({ query: signedQuery(encrypted), body: `<xml><Encrypt>${encrypted}</Encrypt></xml>` });
|
|
425
|
+
await (await api()).event.post();
|
|
426
|
+
expect(out.status).toBe(400);
|
|
427
|
+
expect(dbo.prepare("SELECT COUNT(*) AS n FROM wecom_suite_ticket").get()).toMatchObject({ n: 0 });
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("timestamp 超出 ±5 分钟窗口 → 400(历史回调不得被无限期重放)", async () => {
|
|
431
|
+
const encrypted = await seal(TICKET_XML);
|
|
432
|
+
const stale = String(nowSecs() - 8 * 60);
|
|
433
|
+
request({ query: signedQuery(encrypted, { timestamp: stale }), body: `<xml><Encrypt>${encrypted}</Encrypt></xml>` });
|
|
434
|
+
await (await api()).event.post();
|
|
435
|
+
expect(out.status).toBe(400);
|
|
436
|
+
expect(out.body.msg).toMatch(/超出允许窗口/);
|
|
437
|
+
expect(dbo.prepare("SELECT COUNT(*) AS n FROM wecom_suite_ticket").get()).toMatchObject({ n: 0 });
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("同一条回调连发两次(重放)→ 第二次 400 nonce 重复,票据只落一次", async () => {
|
|
441
|
+
const encrypted = await seal(TICKET_XML);
|
|
442
|
+
const body = `<xml><Encrypt>${encrypted}</Encrypt></xml>`;
|
|
443
|
+
request({ query: signedQuery(encrypted), body });
|
|
444
|
+
await (await api()).event.post();
|
|
445
|
+
expect(out.status).toBe(200);
|
|
446
|
+
|
|
447
|
+
installJson();
|
|
448
|
+
request({ query: signedQuery(encrypted), body });
|
|
449
|
+
await (await api()).event.post();
|
|
450
|
+
expect(out.status).toBe(400);
|
|
451
|
+
expect(out.body.msg).toMatch(/nonce 重复/);
|
|
452
|
+
expect(dbo.prepare("SELECT suite_ticket FROM wecom_suite_ticket WHERE suite_id = ?").get(SUITE)).toMatchObject({ suite_ticket: "tk_9" });
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it("换一个新 nonce 的同内容回调仍能落(重放闸门没把正常重试误伤)", async () => {
|
|
456
|
+
const first = await seal(TICKET_XML);
|
|
457
|
+
request({ query: signedQuery(first), body: `<xml><Encrypt>${first}</Encrypt></xml>` });
|
|
458
|
+
await (await api()).event.post();
|
|
459
|
+
expect(out.status).toBe(200);
|
|
460
|
+
|
|
461
|
+
installJson();
|
|
462
|
+
const again = await seal(TICKET_XML.replace("tk_9", "tk_10"));
|
|
463
|
+
request({ query: signedQuery(again, { nonce: `${NONCE}_2` }), body: `<xml><Encrypt>${again}</Encrypt></xml>` });
|
|
464
|
+
await (await api()).event.post();
|
|
465
|
+
expect(out.status).toBe(200);
|
|
466
|
+
expect(dbo.prepare("SELECT suite_ticket FROM wecom_suite_ticket WHERE suite_id = ?").get(SUITE)).toMatchObject({ suite_ticket: "tk_10" });
|
|
467
|
+
});
|
|
468
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// BDD:企微登录落地的纯逻辑(Phase 2 前端)。
|
|
2
|
+
//
|
|
3
|
+
// 这三处错了都不报错、只表现为「点了没反应 / 登录过期」,故单独锁:
|
|
4
|
+
// · 落地参数解析必须区分「企微回跳」与「普通打开登录页」(后者不能触发换票);
|
|
5
|
+
// · 参数要裁剪、要能容忍少了 tenant(此时沿用本地租户,不是登录失败);
|
|
6
|
+
// · 授权入口即使没填企业代码也要跳(后端会回 400 说明缺什么,比前端静默不动好排查)。
|
|
7
|
+
import { describe, expect, it } from "vitest";
|
|
8
|
+
import { parseWecomLanding, wecomAuthorizePath } from "../src/login/web/wecom";
|
|
9
|
+
|
|
10
|
+
describe("p2-A:回跳落地参数解析", () => {
|
|
11
|
+
it("带齐 ticket 与 tenant → 解析出两者", () => {
|
|
12
|
+
expect(parseWecomLanding("?wecom_ticket=tk_1&tenant=ww_corp_a")).toEqual({ ticket: "tk_1", tenant: "ww_corp_a" });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("只有 ticket(后端没带租户)→ tenant 为空串,仍算企微落地", () => {
|
|
16
|
+
expect(parseWecomLanding("?wecom_ticket=tk_1")).toEqual({ ticket: "tk_1", tenant: "" });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("普通打开登录页(无 ticket)→ null,绝不触发换票", () => {
|
|
20
|
+
expect(parseWecomLanding("")).toBeNull();
|
|
21
|
+
expect(parseWecomLanding("?tenant=ww_corp_a")).toBeNull();
|
|
22
|
+
expect(parseWecomLanding("?wecom_ticket=")).toBeNull();
|
|
23
|
+
expect(parseWecomLanding("?wecom_ticket=%20%20")).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("参数前后空白被裁掉(复制粘贴链接常带空格)", () => {
|
|
27
|
+
expect(parseWecomLanding("?wecom_ticket=%20tk_1%20&tenant=%20ww_corp_a%20")).toEqual({ ticket: "tk_1", tenant: "ww_corp_a" });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("不吞别的参数:redirect 原样留给 getRedirectPath(登录后回原页)", () => {
|
|
31
|
+
const search = "?wecom_ticket=tk_1&tenant=ww_corp_a&redirect=%2Fsystem%2Fusers";
|
|
32
|
+
expect(parseWecomLanding(search)).toEqual({ ticket: "tk_1", tenant: "ww_corp_a" });
|
|
33
|
+
expect(new URLSearchParams(search).get("redirect")).toBe("/system/users");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("p2-B:授权入口拼装", () => {
|
|
38
|
+
it("企业代码 → 站内路径(部署前缀由调用方 withSiteBase 拼)", () => {
|
|
39
|
+
expect(wecomAuthorizePath("ww_corp_a")).toBe("/auth/wecom/login?corp=ww_corp_a");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("同企业两个应用(测试/正式)→ 带 agent 区分", () => {
|
|
43
|
+
expect(wecomAuthorizePath("ww_corp_a", "100002")).toBe("/auth/wecom/login?corp=ww_corp_a&agent=100002");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("企业代码为空/空白 → 仍拼出入口(后端回 400 点明缺 corp,比按钮无反应好排查)", () => {
|
|
47
|
+
expect(wecomAuthorizePath("")).toBe("/auth/wecom/login");
|
|
48
|
+
expect(wecomAuthorizePath(" ")).toBe("/auth/wecom/login");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("值做 URL 编码(企业代码/agent 可能含特殊字符)", () => {
|
|
52
|
+
expect(wecomAuthorizePath("ww a&b", "1 0")).toBe("/auth/wecom/login?corp=ww+a%26b&agent=1+0");
|
|
53
|
+
});
|
|
54
|
+
});
|