@oj-module/cli 0.2.43 → 0.2.45
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 +392 -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 +133 -0
- package/templates/src/auth/api/_shared/wecom/crypt.ts +312 -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 +226 -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 +671 -0
- package/templates/tests/wecom-crypt.spec.ts +155 -0
- package/templates/tests/wecom-endpoints.spec.ts +457 -0
- package/templates/tests/wecom-landing.spec.ts +54 -0
- package/vendor/host-versions.json +3 -3
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
// BDD:企微代开发 connector(Phase 1)——映射表/凭证/一次性票据/凭证链/事件分发的行为契约。
|
|
2
|
+
//
|
|
3
|
+
// 覆盖的是**会静默出错的那几处**,不是「函数能跑」:
|
|
4
|
+
// · profile 的三种拒绝(未知企业 / 已停用 / 多应用未指定 agent)必须同码同文案(§5.3 枚举风险);
|
|
5
|
+
// · suite_ticket 过期不得发起 API(拒在本地,不拿过期票打企微);
|
|
6
|
+
// · 登录票据只能兑换一次,且兑换与过期判定之间有原子性(并发双花只可能成功一次);
|
|
7
|
+
// · JIT 建档幂等(同 corp 同 userid 恒同一 uid,唯一约束撞车要回查复用);
|
|
8
|
+
// · 凭证链的缓存与失效重试(40001 用一次重取机会,不能把用户打成 502)。
|
|
9
|
+
//
|
|
10
|
+
// deps 全注入:store 用 `enc:` 前后缀假加密(Node 侧 crypto 只读,设计 §9.1),
|
|
11
|
+
// client 的 http/kv 用内存桩——真实网络与 AES 已由 wecom-crypt.spec / 运行时保证。
|
|
12
|
+
/* eslint-disable test/prefer-lowercase-title -- D/AC 编号是验收锚点,保留大写 */
|
|
13
|
+
import type { WecomCache, WecomHttp } from "../src/auth/api/_shared/wecom/client";
|
|
14
|
+
import type { WecomConfig } from "../src/auth/api/_shared/wecom/config";
|
|
15
|
+
import type { WecomStoreDeps } from "../src/auth/api/_shared/wecom/store";
|
|
16
|
+
|
|
17
|
+
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
18
|
+
import { createWecomClient, runtimeCache } from "../src/auth/api/_shared/wecom/client";
|
|
19
|
+
import {
|
|
20
|
+
assertEncKey,
|
|
21
|
+
eventUrlOf,
|
|
22
|
+
installUrlOf,
|
|
23
|
+
loginRedirectOf,
|
|
24
|
+
readWecomConfig,
|
|
25
|
+
siteRootOf,
|
|
26
|
+
} from "../src/auth/api/_shared/wecom/config";
|
|
27
|
+
import { envOfState, handleSuiteEvent } from "../src/auth/api/_shared/wecom/events";
|
|
28
|
+
import {
|
|
29
|
+
assertUserActive,
|
|
30
|
+
buildAuthorizeUrl,
|
|
31
|
+
ensureWecomUser,
|
|
32
|
+
externalUsername,
|
|
33
|
+
isWecomUa,
|
|
34
|
+
LOGIN_STATE_TTL_SECS,
|
|
35
|
+
LOGIN_TICKET_TTL_SECS,
|
|
36
|
+
loginLandingUrl,
|
|
37
|
+
putLoginState,
|
|
38
|
+
takeLoginState,
|
|
39
|
+
} from "../src/auth/api/_shared/wecom/login";
|
|
40
|
+
import {
|
|
41
|
+
claimLoginTicket,
|
|
42
|
+
ensureTenant,
|
|
43
|
+
findProfile,
|
|
44
|
+
getSuiteTicket,
|
|
45
|
+
issueLoginTicket,
|
|
46
|
+
putSuiteTicket,
|
|
47
|
+
saveProfile,
|
|
48
|
+
setProfileStatus,
|
|
49
|
+
tenantOfCorp,
|
|
50
|
+
} from "../src/auth/api/_shared/wecom/store";
|
|
51
|
+
import { installDb } from "./fixture";
|
|
52
|
+
|
|
53
|
+
let dbo: ReturnType<typeof installDb>;
|
|
54
|
+
const g = globalThis as any;
|
|
55
|
+
|
|
56
|
+
let clock = 1_000_000;
|
|
57
|
+
|
|
58
|
+
function storeDeps(): WecomStoreDeps {
|
|
59
|
+
return {
|
|
60
|
+
// 只验证「落库是密文、读回是明文」,真实 AES-GCM 由运行时提供(Node 侧不可注入)
|
|
61
|
+
encrypt: (p: string) => `enc:${p}`,
|
|
62
|
+
decrypt: (b: string) => b.replace(/^enc:/, ""),
|
|
63
|
+
now: () => clock,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function fakeCache(): WecomCache & { size: () => number, ttlOf: (k: string) => number | null } {
|
|
68
|
+
const map = new Map<string, { value: string, exp: number }>();
|
|
69
|
+
return {
|
|
70
|
+
async get(key) {
|
|
71
|
+
const hit = map.get(key);
|
|
72
|
+
if (!hit || hit.exp <= clock)
|
|
73
|
+
return null;
|
|
74
|
+
return hit.value;
|
|
75
|
+
},
|
|
76
|
+
async set(key, value, ttl) {
|
|
77
|
+
map.set(key, { value, exp: clock + ttl });
|
|
78
|
+
},
|
|
79
|
+
async del(key) {
|
|
80
|
+
map.delete(key);
|
|
81
|
+
},
|
|
82
|
+
size: () => map.size,
|
|
83
|
+
ttlOf: (key) => {
|
|
84
|
+
const hit = map.get(key);
|
|
85
|
+
return hit ? hit.exp - clock : null;
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const CFG: WecomConfig = {
|
|
91
|
+
suiteId: "suite_1",
|
|
92
|
+
suiteSecret: "secret_1",
|
|
93
|
+
token: "tok",
|
|
94
|
+
encodingAesKey: "a".repeat(43),
|
|
95
|
+
encKey: "k",
|
|
96
|
+
baseUrl: "https://site.example.com/api/auth/wecom",
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
function profile(suiteCorpAgent: { corp?: string, agent?: string, env?: string, code?: string } = {}) {
|
|
100
|
+
return {
|
|
101
|
+
suiteId: CFG.suiteId,
|
|
102
|
+
corpId: suiteCorpAgent.corp ?? "ww_corp_a",
|
|
103
|
+
authCorpId: suiteCorpAgent.corp ?? "ww_corp_a",
|
|
104
|
+
agentId: suiteCorpAgent.agent ?? "100001",
|
|
105
|
+
env: suiteCorpAgent.env ?? "prod",
|
|
106
|
+
corpName: "测试企业",
|
|
107
|
+
permanentCode: suiteCorpAgent.code ?? "perm_a",
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
beforeAll(() => {
|
|
112
|
+
dbo = installDb();
|
|
113
|
+
// 夹具没有租户守卫(见 fixture.ts 头注释的已知边界),这里补 asTenant 的**可观测**替身:
|
|
114
|
+
// 已带头再设 → 抛错(与 oj 契约一致),否则把头补上。ensureTenant 的两种分支都靠它验证。
|
|
115
|
+
g.db.asTenant = (id: string) => {
|
|
116
|
+
if (g.http?.tenantId)
|
|
117
|
+
throw new Error("tenant already set");
|
|
118
|
+
g.http = { ...(g.http ?? {}), tenantId: id };
|
|
119
|
+
};
|
|
120
|
+
g.db.asSystem = () => {};
|
|
121
|
+
g.log = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} };
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
afterEach(() => {
|
|
125
|
+
g.http = {};
|
|
126
|
+
clock = 1_000_000;
|
|
127
|
+
for (const t of ["wecom_app_profile", "wecom_suite_ticket", "wecom_login_ticket", "sys_refresh_token", "sys_oplog"])
|
|
128
|
+
dbo.exec(`DELETE FROM ${t}`);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("P1-A:corp→租户映射表(wecom_app_profile)", () => {
|
|
132
|
+
it("单应用企业:不带 agent 也能解析,读回的是解密后的 permanent_code", async () => {
|
|
133
|
+
await saveProfile(profile({ env: "test" }), storeDeps());
|
|
134
|
+
const p = await findProfile("ww_corp_a", null, storeDeps());
|
|
135
|
+
expect(p).toMatchObject({ corpId: "ww_corp_a", agentId: "100001", env: "test", permanentCode: "perm_a" });
|
|
136
|
+
// 落库必须是密文(permanent_code 具备全 corp 数据权限,§7.5)
|
|
137
|
+
const raw = dbo.prepare("SELECT permanent_code_enc FROM wecom_app_profile").get() as { permanent_code_enc: string };
|
|
138
|
+
expect(raw.permanent_code_enc).toBe("enc:perm_a");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("同企业两个应用(测试/正式):不带 agent 拒绝,带 agent 各自命中", async () => {
|
|
142
|
+
await saveProfile(profile({ agent: "100001", env: "prod" }), storeDeps());
|
|
143
|
+
await saveProfile(profile({ agent: "100002", env: "test", code: "perm_b" }), storeDeps());
|
|
144
|
+
expect((await findProfile("ww_corp_a", "100002", storeDeps())).permanentCode).toBe("perm_b");
|
|
145
|
+
expect(dbo.prepare("SELECT COUNT(*) AS n FROM wecom_app_profile").get()).toMatchObject({ n: 2 });
|
|
146
|
+
await expect(findProfile("ww_corp_a", null, storeDeps())).rejects.toThrow(/多个应用/);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("upsert 幂等:同 (suite,corp,agent) 再授权只更新、不新增行(secret 轮换场景)", async () => {
|
|
150
|
+
await saveProfile(profile({ code: "perm_old" }), storeDeps());
|
|
151
|
+
clock += 60;
|
|
152
|
+
await saveProfile(profile({ code: "perm_new" }), storeDeps());
|
|
153
|
+
expect(dbo.prepare("SELECT COUNT(*) AS n FROM wecom_app_profile").get()).toMatchObject({ n: 1 });
|
|
154
|
+
expect((await findProfile("ww_corp_a", null, storeDeps())).permanentCode).toBe("perm_new");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("未授权企业与已停用企业返回同一文案(不泄露哪些企业装过)", async () => {
|
|
158
|
+
await saveProfile(profile(), storeDeps());
|
|
159
|
+
await setProfileStatus("ww_corp_a", "disabled", storeDeps());
|
|
160
|
+
const disabled = await findProfile("ww_corp_a", null, storeDeps()).catch(e => (e as Error).message);
|
|
161
|
+
const unknown = await findProfile("ww_corp_z", null, storeDeps()).catch(e => (e as Error).message);
|
|
162
|
+
expect(disabled).toBe(unknown);
|
|
163
|
+
expect(disabled).toMatch(/无效或应用未开通/);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("AuthCorpId(可能是 open_corpid)也能反查到同一行", async () => {
|
|
167
|
+
await saveProfile({ ...profile(), authCorpId: "open_corp_a" }, storeDeps());
|
|
168
|
+
expect((await findProfile("open_corp_a", null, storeDeps())).corpId).toBe("ww_corp_a");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("并发首次授权(企微重推):撞车的一方转为更新,不抛错也不双写", async () => {
|
|
172
|
+
// 企微 5 秒拿不到 success 就重推,两条 create_auth 同时进 handleSuiteEvent 是常态;
|
|
173
|
+
// 两条都先看不到行 → 都走 insert → 后到的必须吞掉唯一约束冲突改写为更新,
|
|
174
|
+
// 否则整条事件以 500 收场,这个企业的授权可能就停在「重推次数用完」那一刻。
|
|
175
|
+
await Promise.all([saveProfile(profile(), storeDeps()), saveProfile(profile(), storeDeps())]);
|
|
176
|
+
expect(dbo.prepare("SELECT COUNT(*) AS n FROM wecom_app_profile").get()).toMatchObject({ n: 1 });
|
|
177
|
+
expect((await findProfile("ww_corp_a", null, storeDeps())).permanentCode).toBe("perm_a");
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("P1-A2:token 缓存必须带上有效期(kv 两步写的兜底)", () => {
|
|
182
|
+
// 本段接管全局 kv/log(runtimeCache 直接读全局),退出时必须还原——同文件后续用例依赖它们
|
|
183
|
+
let prevKv: unknown;
|
|
184
|
+
let prevLog: unknown;
|
|
185
|
+
|
|
186
|
+
beforeEach(() => {
|
|
187
|
+
prevKv = g.kv;
|
|
188
|
+
prevLog = g.log;
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
afterEach(() => {
|
|
192
|
+
g.kv = prevKv;
|
|
193
|
+
g.log = prevLog;
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("expire 生效:写进去的值带 TTL(expires_in - 300)", async () => {
|
|
197
|
+
const store = new Map<string, { value: string, exp: number }>();
|
|
198
|
+
const ttlClock = 1_000_000;
|
|
199
|
+
g.log = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} };
|
|
200
|
+
g.kv = {
|
|
201
|
+
get: async (k: string) => {
|
|
202
|
+
const hit = store.get(k);
|
|
203
|
+
return hit && hit.exp > ttlClock ? hit.value : null;
|
|
204
|
+
},
|
|
205
|
+
set: async (k: string, v: string) => {
|
|
206
|
+
store.set(k, { value: v, exp: Number.POSITIVE_INFINITY });
|
|
207
|
+
},
|
|
208
|
+
expire: async (k: string, ttl: number) => {
|
|
209
|
+
const hit = store.get(k);
|
|
210
|
+
if (!hit)
|
|
211
|
+
return false;
|
|
212
|
+
hit.exp = ttlClock + ttl;
|
|
213
|
+
return true;
|
|
214
|
+
},
|
|
215
|
+
del: async (k: string) => {
|
|
216
|
+
store.delete(k);
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
const cache = runtimeCache();
|
|
220
|
+
await cache.set("wecom:suite_token:s", "sat_1", 6900);
|
|
221
|
+
expect(await cache.get("wecom:suite_token:s")).toBe("sat_1");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("expire 没设上(false/0)→ 宁可不缓存也不留永不过期的 token", async () => {
|
|
225
|
+
const store = new Map<string, string>();
|
|
226
|
+
const seen: string[] = [];
|
|
227
|
+
g.log = { debug: () => {}, info: () => {}, warn: (m: string) => seen.push(m), error: () => {} };
|
|
228
|
+
g.kv = {
|
|
229
|
+
get: async (k: string) => store.get(k) ?? null,
|
|
230
|
+
set: async (k: string, v: string) => {
|
|
231
|
+
store.set(k, v);
|
|
232
|
+
},
|
|
233
|
+
expire: async () => 0,
|
|
234
|
+
del: async (k: string) => {
|
|
235
|
+
store.delete(k);
|
|
236
|
+
seen.push(`del:${k}`);
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
const cache = runtimeCache();
|
|
240
|
+
await cache.set("wecom:corp_token:c:1", "corp_tk", 6900);
|
|
241
|
+
// 没有 TTL 的值必须被撤回:留着它,corp secret 轮换后登录会长期停在 502
|
|
242
|
+
expect(await cache.get("wecom:corp_token:c:1")).toBeNull();
|
|
243
|
+
expect(seen.join("|")).toMatch(/del:wecom:corp_token:c:1/);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe("P1-B:套件票据(wecom_suite_ticket)", () => {
|
|
248
|
+
it("未收到 → 503;收到可读;过期即 503(宁可拒一次也不拿过期票打企微)", async () => {
|
|
249
|
+
await expect(getSuiteTicket(CFG.suiteId, storeDeps())).rejects.toThrow(/尚未收到/);
|
|
250
|
+
await putSuiteTicket(CFG.suiteId, "ticket_v1", clock + 1800, storeDeps());
|
|
251
|
+
expect((await getSuiteTicket(CFG.suiteId, storeDeps())).ticket).toBe("ticket_v1");
|
|
252
|
+
clock += 1801;
|
|
253
|
+
await expect(getSuiteTicket(CFG.suiteId, storeDeps())).rejects.toThrow(/已过期/);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("覆盖写:新票据替换旧票据且只保留一行", async () => {
|
|
257
|
+
await putSuiteTicket(CFG.suiteId, "ticket_v1", clock + 1800, storeDeps());
|
|
258
|
+
await putSuiteTicket(CFG.suiteId, "ticket_v2", clock + 1800, storeDeps());
|
|
259
|
+
expect((await getSuiteTicket(CFG.suiteId, storeDeps())).ticket).toBe("ticket_v2");
|
|
260
|
+
expect(dbo.prepare("SELECT COUNT(*) AS n FROM wecom_suite_ticket").get()).toMatchObject({ n: 1 });
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe("P1-C:一次性登录票据(wecom_login_ticket)", () => {
|
|
265
|
+
it("只能兑换一次:第二次 401(重放防线)", async () => {
|
|
266
|
+
await issueLoginTicket("h1", 7, "ww_corp_a", LOGIN_TICKET_TTL_SECS, storeDeps());
|
|
267
|
+
expect(await claimLoginTicket("h1", storeDeps())).toEqual({ uid: 7, ownerTenant: "ww_corp_a" });
|
|
268
|
+
await expect(claimLoginTicket("h1", storeDeps())).rejects.toThrow(/无效或已使用/);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("过期票据 401(且票面已标记 used,不可复活)", async () => {
|
|
272
|
+
await issueLoginTicket("h2", 7, "ww_corp_a", 10, storeDeps());
|
|
273
|
+
clock += 11;
|
|
274
|
+
await expect(claimLoginTicket("h2", storeDeps())).rejects.toThrow(/已过期/);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("签发时顺手清掉过期票(长期运行不堆垃圾)", async () => {
|
|
278
|
+
await issueLoginTicket("old", 1, "t", 10, storeDeps());
|
|
279
|
+
clock += 20;
|
|
280
|
+
await issueLoginTicket("new", 1, "t", 10, storeDeps());
|
|
281
|
+
expect(dbo.prepare("SELECT ticket_hash FROM wecom_login_ticket").all()).toEqual([{ ticket_hash: "new" }]);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("伪造票据 401(不存在的 hash)", async () => {
|
|
285
|
+
await expect(claimLoginTicket("nope", storeDeps())).rejects.toThrow(/无效或已使用/);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
describe("P1-D:租户绑定(ensureTenant / L2 第四层)", () => {
|
|
290
|
+
it("无头 → db.asTenant(推导值) 补身份", () => {
|
|
291
|
+
g.http = {};
|
|
292
|
+
ensureTenant(tenantOfCorp("ww_corp_a"));
|
|
293
|
+
expect(g.http.tenantId).toBe("ww_corp_a");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("头与推导值不一致 → 403(伪造 X-TENANT-ID 的第一道拦)", () => {
|
|
297
|
+
g.http = { tenantId: "ww_corp_b" };
|
|
298
|
+
expect(() => ensureTenant("ww_corp_a")).toThrow(/does not match/);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe("P1-E:JIT 建档(不另注册)", () => {
|
|
303
|
+
it("同 corp 同 userid 两次 → 同一 uid,且用户名走 wecom: 命名空间、corp_id 落行", async () => {
|
|
304
|
+
const a = await ensureWecomUser("ww_corp_a", "zhangsan");
|
|
305
|
+
const b = await ensureWecomUser("ww_corp_a", "zhangsan");
|
|
306
|
+
expect(a).toEqual({ uid: b.uid, forbidden: false });
|
|
307
|
+
const row = dbo.prepare("SELECT username, source, corp_id, is_forbidden FROM users WHERE id = ?").get(a.uid);
|
|
308
|
+
expect(row).toMatchObject({ username: externalUsername("zhangsan"), source: "wecom", corp_id: "ww_corp_a", is_forbidden: 0 });
|
|
309
|
+
expect(dbo.prepare("SELECT COUNT(*) AS n FROM users WHERE username = ?").get(externalUsername("zhangsan"))).toMatchObject({ n: 1 });
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("预查与插入之间的竞态:唯一约束撞车后回查复用,不抛错", async () => {
|
|
313
|
+
// 先手动插一行(模拟另一个请求已抢先建号),再让 ensureWecomUser 走「查到即复用」
|
|
314
|
+
dbo.prepare("INSERT INTO users (username, password_hash, source, corp_id) VALUES (?, '!wecom', 'wecom', 'ww_corp_a')").run(externalUsername("lisi"));
|
|
315
|
+
const r = await ensureWecomUser("ww_corp_a", "lisi");
|
|
316
|
+
expect(r.forbidden).toBe(false);
|
|
317
|
+
expect(dbo.prepare("SELECT COUNT(*) AS n FROM users WHERE username = ?").get(externalUsername("lisi"))).toMatchObject({ n: 1 });
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("本地已停用的外部号:建档返回 forbidden,兑换前 assertUserActive 再拦一次(403)", async () => {
|
|
321
|
+
const { uid } = await ensureWecomUser("ww_corp_a", "wangwu");
|
|
322
|
+
dbo.prepare("UPDATE users SET is_forbidden = 1 WHERE id = ?").run(uid);
|
|
323
|
+
expect((await ensureWecomUser("ww_corp_a", "wangwu")).forbidden).toBe(true);
|
|
324
|
+
await expect(assertUserActive(uid)).rejects.toThrow(/已被停用/);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("名字只在首次建档取一次:写 display_name,老用户不再调 resolver(不给每次登录加一次上游)", async () => {
|
|
328
|
+
let calls = 0;
|
|
329
|
+
const first = await ensureWecomUser("ww_corp_a", "zhaoliu", async () => {
|
|
330
|
+
calls++;
|
|
331
|
+
return "赵六";
|
|
332
|
+
});
|
|
333
|
+
expect(dbo.prepare("SELECT display_name FROM users WHERE id = ?").get(first.uid)).toMatchObject({ display_name: "赵六" });
|
|
334
|
+
|
|
335
|
+
await ensureWecomUser("ww_corp_a", "zhaoliu", async () => {
|
|
336
|
+
calls++;
|
|
337
|
+
return "不该被用到";
|
|
338
|
+
});
|
|
339
|
+
expect(calls).toBe(1);
|
|
340
|
+
expect(dbo.prepare("SELECT display_name FROM users WHERE id = ?").get(first.uid)).toMatchObject({ display_name: "赵六" });
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("取不到名字(无通讯录权限 / 上游抛错)→ 用工号兜底,建档不失败", async () => {
|
|
344
|
+
const noName = await ensureWecomUser("ww_corp_a", "sunqi", async () => null);
|
|
345
|
+
expect(dbo.prepare("SELECT display_name FROM users WHERE id = ?").get(noName.uid)).toMatchObject({ display_name: "sunqi" });
|
|
346
|
+
|
|
347
|
+
const thrown = await ensureWecomUser("ww_corp_a", "zhouba", async () => {
|
|
348
|
+
throw new Error("boom");
|
|
349
|
+
});
|
|
350
|
+
expect(dbo.prepare("SELECT display_name FROM users WHERE id = ?").get(thrown.uid)).toMatchObject({ display_name: "zhouba" });
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
describe("P1-F:凭证链(client:缓存 + 失效重试 + 错误两态)", () => {
|
|
355
|
+
interface Stub extends WecomHttp { calls: string[] }
|
|
356
|
+
|
|
357
|
+
function stubHttp(replies: Array<Record<string, unknown>>): Stub {
|
|
358
|
+
const calls: string[] = [];
|
|
359
|
+
const next = () => {
|
|
360
|
+
const r = replies.shift();
|
|
361
|
+
if (!r)
|
|
362
|
+
throw new Error("stub http: 无更多应答");
|
|
363
|
+
return r;
|
|
364
|
+
};
|
|
365
|
+
return {
|
|
366
|
+
calls,
|
|
367
|
+
async getJson(url) {
|
|
368
|
+
calls.push(url);
|
|
369
|
+
return next();
|
|
370
|
+
},
|
|
371
|
+
async postJson(url) {
|
|
372
|
+
calls.push(url);
|
|
373
|
+
return next();
|
|
374
|
+
},
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
it("suite_access_token 进缓存:第二次调用不再打企微,TTL = expires_in - 300", async () => {
|
|
379
|
+
const http = stubHttp([{ suite_access_token: "sat_1", expires_in: 7200 }]);
|
|
380
|
+
const cache = fakeCache();
|
|
381
|
+
const client = createWecomClient({ http, cache });
|
|
382
|
+
expect(await client.suiteAccessToken("suite_1", "sec", "ticket")).toBe("sat_1");
|
|
383
|
+
expect(await client.suiteAccessToken("suite_1", "sec", "ticket")).toBe("sat_1");
|
|
384
|
+
expect(http.calls).toHaveLength(1);
|
|
385
|
+
expect(cache.ttlOf("wecom:suite_token:suite_1")).toBe(6900);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("40001(token 过期)→ 失效缓存重取一次后成功,用户无感", async () => {
|
|
389
|
+
const http = stubHttp([
|
|
390
|
+
{ access_token: "corp_token_old", expires_in: 7200 },
|
|
391
|
+
{ errcode: 40001, errmsg: "invalid credential" },
|
|
392
|
+
{ access_token: "corp_token_new", expires_in: 7200 },
|
|
393
|
+
{ errcode: 0, userid: "zhangsan" },
|
|
394
|
+
]);
|
|
395
|
+
const client = createWecomClient({ http, cache: fakeCache() });
|
|
396
|
+
expect(await client.userIdByCode("ww_corp_a", "100001", "perm", "CODE")).toBe("zhangsan");
|
|
397
|
+
expect(http.calls).toHaveLength(4);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("code 无效 / 非企业成员(只有 openid)→ 统一 401,不泄露具体原因", async () => {
|
|
401
|
+
const invalid = createWecomClient({
|
|
402
|
+
http: stubHttp([{ access_token: "t", expires_in: 7200 }, { errcode: 40029, errmsg: "invalid code" }]),
|
|
403
|
+
cache: fakeCache(),
|
|
404
|
+
});
|
|
405
|
+
await expect(invalid.userIdByCode("c", "a", "p", "BAD")).rejects.toMatchObject({ status: 401 });
|
|
406
|
+
const outsider = createWecomClient({
|
|
407
|
+
http: stubHttp([{ access_token: "t", expires_in: 7200 }, { errcode: 0, openid: "o_1" }]),
|
|
408
|
+
cache: fakeCache(),
|
|
409
|
+
});
|
|
410
|
+
await expect(outsider.userIdByCode("c", "a", "p", "CODE")).rejects.toMatchObject({ status: 401 });
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it("上游故障 → 502(可重试语义),不能伪装成 401", async () => {
|
|
414
|
+
const client = createWecomClient({
|
|
415
|
+
http: {
|
|
416
|
+
async getJson() {
|
|
417
|
+
throw Object.assign(new Error("企微接口不可达"), { status: 502 });
|
|
418
|
+
},
|
|
419
|
+
async postJson() {
|
|
420
|
+
throw Object.assign(new Error("企微接口不可达"), { status: 502 });
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
cache: fakeCache(),
|
|
424
|
+
});
|
|
425
|
+
await expect(client.userIdByCode("c", "a", "p", "CODE")).rejects.toMatchObject({ status: 502 });
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it("get_permanent_code 缺 corpid / agent 列表为空 → 502(不静默落一行空 profile)", async () => {
|
|
429
|
+
const noAgent = createWecomClient({
|
|
430
|
+
http: stubHttp([
|
|
431
|
+
{ suite_access_token: "sat", expires_in: 7200 },
|
|
432
|
+
{ permanent_code: "perm", auth_corp_info: { corpid: "ww_x" }, auth_info: { agent: [] } },
|
|
433
|
+
]),
|
|
434
|
+
cache: fakeCache(),
|
|
435
|
+
});
|
|
436
|
+
await expect(noAgent.permanentCodeByAuthCode("sat", "AUTH")).rejects.toMatchObject({ status: 502 });
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("userNameById 拿到真名(去首尾空白)", async () => {
|
|
440
|
+
const http = stubHttp([{ access_token: "t", expires_in: 7200 }, { errcode: 0, name: " 张三 " }]);
|
|
441
|
+
const client = createWecomClient({ http, cache: fakeCache() });
|
|
442
|
+
expect(await client.userNameById("ww_corp_a", "100001", "perm", "zhangsan")).toBe("张三");
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("userNameById 权限不足(60011)→ null 而不是抛错:补名失败不能把登录打成 5xx", async () => {
|
|
446
|
+
const client = createWecomClient({
|
|
447
|
+
http: stubHttp([{ access_token: "t", expires_in: 7200 }, { errcode: 60011, errmsg: "no privilege to access" }]),
|
|
448
|
+
cache: fakeCache(),
|
|
449
|
+
});
|
|
450
|
+
await expect(client.userNameById("c", "a", "p", "u")).resolves.toBeNull();
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it("userNameById 遇 40001 → 失效缓存重试一次", async () => {
|
|
454
|
+
const http = stubHttp([
|
|
455
|
+
{ access_token: "t", expires_in: 7200 },
|
|
456
|
+
{ errcode: 40001 },
|
|
457
|
+
{ access_token: "t2", expires_in: 7200 },
|
|
458
|
+
{ errcode: 0, name: "李四" },
|
|
459
|
+
]);
|
|
460
|
+
const client = createWecomClient({ http, cache: fakeCache() });
|
|
461
|
+
expect(await client.userNameById("c", "a", "p", "u")).toBe("李四");
|
|
462
|
+
expect(http.calls).toHaveLength(4);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("userNameById 网络故障 / name 为空 → null(不抛)", async () => {
|
|
466
|
+
const broken = createWecomClient({
|
|
467
|
+
http: {
|
|
468
|
+
async getJson() {
|
|
469
|
+
throw new Error("unreachable");
|
|
470
|
+
},
|
|
471
|
+
async postJson() {
|
|
472
|
+
throw new Error("unreachable");
|
|
473
|
+
},
|
|
474
|
+
},
|
|
475
|
+
cache: fakeCache(),
|
|
476
|
+
});
|
|
477
|
+
await expect(broken.userNameById("c", "a", "p", "u")).resolves.toBeNull();
|
|
478
|
+
|
|
479
|
+
const noName = createWecomClient({
|
|
480
|
+
http: stubHttp([{ access_token: "t", expires_in: 7200 }, { errcode: 0, name: " " }]),
|
|
481
|
+
cache: fakeCache(),
|
|
482
|
+
});
|
|
483
|
+
await expect(noName.userNameById("c", "a", "p", "u")).resolves.toBeNull();
|
|
484
|
+
});
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
describe("P1-G:事件分发(suite_ticket / 授权 / 解绑)", () => {
|
|
488
|
+
function fakeClient(overrides: Partial<Record<string, unknown>> = {}) {
|
|
489
|
+
const invalidated: string[] = [];
|
|
490
|
+
return {
|
|
491
|
+
invalidated,
|
|
492
|
+
async suiteAccessToken() {
|
|
493
|
+
return "sat";
|
|
494
|
+
},
|
|
495
|
+
async permanentCodeByAuthCode() {
|
|
496
|
+
return { permanentCode: "perm_new", corpId: "ww_corp_a", corpName: "测试企业", agentIds: ["100001", "100002"], state: null, ...overrides };
|
|
497
|
+
},
|
|
498
|
+
async corpAccessToken() {
|
|
499
|
+
return "ct";
|
|
500
|
+
},
|
|
501
|
+
async invalidateCorpToken(corp: string, agent: string) {
|
|
502
|
+
invalidated.push(`${corp}:${agent}`);
|
|
503
|
+
},
|
|
504
|
+
async userIdByCode() {
|
|
505
|
+
return "u";
|
|
506
|
+
},
|
|
507
|
+
} as never;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const deps = (client: unknown) => ({ cfg: CFG, client: client as never, store: storeDeps() });
|
|
511
|
+
|
|
512
|
+
it("suite_ticket:落库并给 30 分钟有效期(企微 10 分钟推一次,过期即拒发 API)", async () => {
|
|
513
|
+
await handleSuiteEvent("<xml><InfoType>suite_ticket</InfoType><SuiteId>suite_1</SuiteId><SuiteTicket>tk_1</SuiteTicket></xml>", deps(fakeClient()));
|
|
514
|
+
expect(await getSuiteTicket(CFG.suiteId, storeDeps())).toEqual({ ticket: "tk_1", expiresAt: clock + 1800 });
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it("create_auth:AuthCode 换永久码 → 每个 agent 一行 profile,并作废旧 corp token", async () => {
|
|
518
|
+
await putSuiteTicket(CFG.suiteId, "tk_1", clock + 1800, storeDeps());
|
|
519
|
+
const client = fakeClient();
|
|
520
|
+
await handleSuiteEvent("<xml><InfoType>create_auth</InfoType><SuiteId>suite_1</SuiteId><AuthCode>AC_1</AuthCode><State>env=test</State></xml>", deps(client));
|
|
521
|
+
const rows = dbo.prepare("SELECT agent_id, env, status FROM wecom_app_profile ORDER BY agent_id").all();
|
|
522
|
+
expect(rows).toEqual([
|
|
523
|
+
{ agent_id: "100001", env: "test", status: "active" },
|
|
524
|
+
{ agent_id: "100002", env: "test", status: "active" },
|
|
525
|
+
]);
|
|
526
|
+
expect((client as unknown as { invalidated: string[] }).invalidated).toEqual(["ww_corp_a:100001", "ww_corp_a:100002"]);
|
|
527
|
+
// 审计留痕(谁在什么时候绑了哪个 corp)
|
|
528
|
+
expect(dbo.prepare("SELECT action FROM sys_oplog WHERE action = 'wecom.auth.apply'").all()).toHaveLength(1);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it("cancel_auth:profile 全部停用 + 该租户 refresh 串清空(解绑后旧会话不得续命)", async () => {
|
|
532
|
+
await saveProfile(profile({ agent: "100001" }), storeDeps());
|
|
533
|
+
await saveProfile(profile({ agent: "100002" }), storeDeps());
|
|
534
|
+
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
|
+
|
|
536
|
+
await handleSuiteEvent("<xml><InfoType>cancel_auth</InfoType><SuiteId>suite_1</SuiteId><AuthCorpId>ww_corp_a</AuthCorpId></xml>", deps(fakeClient()));
|
|
537
|
+
|
|
538
|
+
expect(dbo.prepare("SELECT DISTINCT status FROM wecom_app_profile").all()).toEqual([{ status: "disabled" }]);
|
|
539
|
+
expect(dbo.prepare("SELECT COUNT(*) AS n FROM sys_refresh_token").get()).toMatchObject({ n: 0 });
|
|
540
|
+
expect(dbo.prepare("SELECT level FROM sys_oplog WHERE action = 'wecom.auth.cancel'").get()).toMatchObject({ level: 4 });
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it("SuiteId 与本部署不一致 → 400(不同套件的回调不得互相污染)", async () => {
|
|
544
|
+
await expect(handleSuiteEvent("<xml><InfoType>suite_ticket</InfoType><SuiteId>other</SuiteId><SuiteTicket>t</SuiteTicket></xml>", deps(fakeClient())))
|
|
545
|
+
.rejects
|
|
546
|
+
.toMatchObject({ status: 400 });
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
it("不消费的事件(通讯录变更等)回 success 语义的 InfoType,不抛错(否则企微重推三次)", async () => {
|
|
550
|
+
expect(await handleSuiteEvent("<xml><InfoType>change_contact</InfoType><SuiteId>suite_1</SuiteId></xml>", deps(fakeClient()))).toBe("change_contact");
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it("create_auth 缺 AuthCode → 400(宁可拒也不落半行 profile)", async () => {
|
|
554
|
+
await putSuiteTicket(CFG.suiteId, "tk_1", clock + 1800, storeDeps());
|
|
555
|
+
await expect(handleSuiteEvent("<xml><InfoType>create_auth</InfoType><SuiteId>suite_1</SuiteId></xml>", deps(fakeClient())))
|
|
556
|
+
.rejects
|
|
557
|
+
.toMatchObject({ status: 400 });
|
|
558
|
+
});
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
describe("P1-H:授权链接与 state(一次性)", () => {
|
|
562
|
+
it("企微内用 oauth2(snsapi_base + agentid + #wechat_redirect);浏览器用 qrConnect", () => {
|
|
563
|
+
const base = { corpId: "ww1", agentId: "100001", redirectUri: "https://s.example.com/cb?a=1&b=2", state: "st" };
|
|
564
|
+
const inApp = buildAuthorizeUrl({ ...base, inWecom: true });
|
|
565
|
+
expect(inApp).toContain("connect/oauth2/authorize");
|
|
566
|
+
expect(inApp).toContain("scope=snsapi_base");
|
|
567
|
+
expect(inApp).toContain("agentid=100001");
|
|
568
|
+
expect(inApp.endsWith("#wechat_redirect")).toBe(true);
|
|
569
|
+
// redirect_uri 必须整串编码,否则企微把我们的参数当自己的解析
|
|
570
|
+
expect(inApp).toContain(encodeURIComponent(base.redirectUri));
|
|
571
|
+
|
|
572
|
+
const qr = buildAuthorizeUrl({ ...base, inWecom: false });
|
|
573
|
+
expect(qr).toContain("wwopen/sso/qrConnect");
|
|
574
|
+
expect(qr).toContain("appid=ww1");
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it("state 一次性:取即删,第二次 400;过期同样 400", async () => {
|
|
578
|
+
const cache = fakeCache();
|
|
579
|
+
await putLoginState("n1", { corpId: "ww_corp_a", agentId: "100001", env: "prod" }, cache);
|
|
580
|
+
expect(await takeLoginState("n1", cache)).toEqual({ corpId: "ww_corp_a", agentId: "100001", env: "prod" });
|
|
581
|
+
await expect(takeLoginState("n1", cache)).rejects.toMatchObject({ status: 400 });
|
|
582
|
+
|
|
583
|
+
await putLoginState("n2", { corpId: "ww_corp_a", agentId: "100001", env: "prod" }, cache);
|
|
584
|
+
clock += LOGIN_STATE_TTL_SECS + 1;
|
|
585
|
+
await expect(takeLoginState("n2", cache)).rejects.toMatchObject({ status: 400 });
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it("落地地址同时带票据与租户(缺租户 → 换票会被 ensureTenant 判 403)", () => {
|
|
589
|
+
const url = loginLandingUrl({ origin: "https://site.example.com", ticket: "tk", tenant: "ww_corp_a" });
|
|
590
|
+
expect(url).toBe("https://site.example.com/login?wecom_ticket=tk&tenant=ww_corp_a");
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it("envOfState 只认 env=xxx 或裸环境名,其余回落 prod(防止 State 被当自由文本)", () => {
|
|
594
|
+
expect(envOfState("env=test")).toBe("test");
|
|
595
|
+
expect(envOfState("staging")).toBe("staging");
|
|
596
|
+
expect(envOfState("随便写的")).toBe("prod");
|
|
597
|
+
expect(envOfState(null)).toBe("prod");
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it("UA 识别与外部号命名空间", () => {
|
|
601
|
+
expect(isWecomUa("Mozilla/5.0 (iPhone) wxwork/4.1.0")).toBe(true);
|
|
602
|
+
expect(isWecomUa("Mozilla/5.0 (Macintosh) Chrome/120")).toBe(false);
|
|
603
|
+
expect(externalUsername("zhangsan")).toBe("wecom:zhangsan");
|
|
604
|
+
});
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
describe("P1-J:部署常量读取(缺项一次报全 / 密钥格式 fail-fast / 地址派生)", () => {
|
|
608
|
+
function installVars(overrides: Record<string, string> = {}): void {
|
|
609
|
+
const base: Record<string, string> = {
|
|
610
|
+
wecom_suite_id: "suite_1",
|
|
611
|
+
wecom_suite_secret: "secret_1",
|
|
612
|
+
wecom_callback_token: "tok",
|
|
613
|
+
wecom_encoding_aes_key: "a".repeat(43),
|
|
614
|
+
wecom_enc_key: "0123456789abcdef0123456789abcdef",
|
|
615
|
+
wecom_base_url: "https://site.example.com/api/auth/wecom/",
|
|
616
|
+
...overrides,
|
|
617
|
+
};
|
|
618
|
+
g.vars = { get: (name: string) => base[name] ?? null };
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
it("缺项一次报全(不是报第一个就返回,省掉逐轮补配置的往返)", () => {
|
|
622
|
+
installVars({ wecom_suite_id: "", wecom_callback_token: "", wecom_enc_key: "" });
|
|
623
|
+
expect(() => readWecomConfig()).toThrow(/wecom_suite_id.*wecom_callback_token.*wecom_enc_key/);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it("baseUrl 去尾斜杠后拼出两个企微侧地址(回调 URL 要原样填进后台)", () => {
|
|
627
|
+
installVars();
|
|
628
|
+
const cfg = readWecomConfig();
|
|
629
|
+
expect(cfg.baseUrl).toBe("https://site.example.com/api/auth/wecom");
|
|
630
|
+
expect(eventUrlOf(cfg)).toBe("https://site.example.com/api/auth/wecom/event");
|
|
631
|
+
expect(loginRedirectOf(cfg)).toBe("https://site.example.com/api/auth/wecom/callback");
|
|
632
|
+
expect(siteRootOf(cfg)).toBe("https://site.example.com");
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
it("子路径部署:站点根保留子路径(302 回跳必须落 /oj/login,不是 /login)", () => {
|
|
636
|
+
installVars({ wecom_base_url: "https://site.example.com/oj/api/auth/wecom" });
|
|
637
|
+
const cfg = readWecomConfig();
|
|
638
|
+
expect(siteRootOf(cfg)).toBe("https://site.example.com/oj");
|
|
639
|
+
expect(loginLandingUrl({ origin: siteRootOf(cfg), ticket: "tk", tenant: "ww_corp_a" }))
|
|
640
|
+
.toBe("https://site.example.com/oj/login?wecom_ticket=tk&tenant=ww_corp_a");
|
|
641
|
+
// 开通入口的 redirect_uri 同样要带子路径
|
|
642
|
+
expect(installUrlOf(cfg, "PAC1", "env=prod")).toContain(`redirect_uri=${encodeURIComponent("https://site.example.com/oj/login?wecom_installed=1")}`);
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
it("baseUrl 后缀不对 → 400(宁可拒,也不猜站点根拼出错误回跳)", () => {
|
|
646
|
+
installVars({ wecom_base_url: "https://site.example.com/api/wecom" });
|
|
647
|
+
expect(() => siteRootOf(readWecomConfig())).toThrow(/必须以 \/api\/auth\/wecom 结尾/);
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
it("baseUrl 非绝对地址 → 400(相对路径会让 redirect_uri 与回调 URL 双双失效)", () => {
|
|
651
|
+
installVars({ wecom_base_url: "/api/auth/wecom" });
|
|
652
|
+
expect(() => readWecomConfig()).toThrow(/绝对地址/);
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
it("wecom_enc_key 只收 16/32 字节的 hex 或 base64(AES-192 会在加密时才炸,故在读配置处拦住)", () => {
|
|
656
|
+
for (const key of [
|
|
657
|
+
"0123456789abcdef0123456789abcdef", // 32 hex = 16 字节
|
|
658
|
+
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", // 64 hex = 32 字节
|
|
659
|
+
"AAECAwQFBgcICQoLDA0ODw==", // base64 16 字节
|
|
660
|
+
"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", // base64 32 字节
|
|
661
|
+
])
|
|
662
|
+
expect(() => assertEncKey(key), key).not.toThrow();
|
|
663
|
+
|
|
664
|
+
for (const key of [
|
|
665
|
+
"enc-key", // 既非 hex 也非合法 base64
|
|
666
|
+
"0123456789abcdef0123456789abcdef01234567", // 40 hex = 20 字节
|
|
667
|
+
"AAECAwQFBgcICQoLDA0ODxAREhMUFRYX", // base64 → 24 字节(AES-192,oj 明确不支持)
|
|
668
|
+
])
|
|
669
|
+
expect(() => assertEncKey(key), key).toThrow(/16 或 32 字节/);
|
|
670
|
+
});
|
|
671
|
+
});
|