@oj-module/cli 0.2.46 → 0.2.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/devkit/SKILL.md +1 -1
- package/devkit/framework-dev.md +2 -2
- package/devkit/manual.md +24 -14
- package/devkit/module-dev-and-cdn-playground.md +2 -2
- package/devkit/wecom-daikaifa.md +282 -27
- package/package.json +2 -2
- package/shell-dist/assets/runtime.js +2 -2
- package/shell-dist/index.html +2 -2
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/templates-tenant-scope.test.ts +7 -2
- package/src/init.ts +1 -1
- package/src/market/__tests__/bootstrap.test.ts +1 -1
- package/src/market/__tests__/setup.test.ts +7 -7
- package/src/market/bootstrap.ts +1 -1
- package/templates/README.md +1 -1
- package/templates/config.yaml +26 -7
- package/templates/init.include.yaml +12 -0
- package/templates/openapi.json +2863 -549
- package/templates/src/_platform/api/_shared/service/domain/contacts.ts +7 -0
- package/templates/src/_platform/api/_shared/service/domain/org-read.ts +451 -0
- package/templates/src/_platform/api/_shared/service/domain/org.ts +566 -0
- package/templates/src/_platform/api/_shared/service/domain/users.ts +140 -2
- package/templates/src/_platform/api/_shared/service/infra/query.ts +2 -1
- package/templates/src/_platform/api/fixtures/l1-test-user.sql +1 -1
- package/templates/src/_platform/api/manifest.yaml +3 -0
- package/templates/src/_platform/api/migrations/0016__org.mysql.sql +41 -0
- package/templates/src/_platform/api/migrations/0016__org.pg.sql +40 -0
- package/templates/src/_platform/api/migrations/0016__org.sql +62 -0
- package/templates/src/_platform/api/org/departments/api.ts +51 -0
- package/templates/src/_platform/api/org/is-member/api.ts +33 -0
- package/templates/src/_platform/api/org/members/api.ts +55 -0
- package/templates/src/_platform/api/org/picker/api.ts +59 -0
- package/templates/src/_platform/api/org/resolve/api.ts +38 -0
- package/templates/src/_platform/api/org/tree/api.ts +37 -0
- package/templates/src/_platform/api/org/user/api.ts +46 -0
- package/templates/src/_platform/api/schema.yaml +48 -0
- package/templates/src/_platform/api/seed.mysql.sql +10 -5
- package/templates/src/_platform/api/seed.pg.sql +10 -5
- package/templates/src/_platform/api/seed.sql +13 -5
- package/templates/src/_platform/web/__client/api.schemas.ts +126 -0
- package/templates/src/_platform/web/__client/api.ts +170 -1
- package/templates/src/auth/api/_shared/ldap-login.ts +2 -0
- package/templates/src/auth/api/_shared/session.ts +5 -1
- package/templates/src/auth/api/_shared/wecom/admin.ts +17 -10
- package/templates/src/auth/api/_shared/wecom/client.ts +311 -40
- package/templates/src/auth/api/_shared/wecom/config.ts +40 -17
- package/templates/src/auth/api/_shared/wecom/contacts.ts +263 -0
- package/templates/src/auth/api/_shared/wecom/crypt.ts +17 -7
- package/templates/src/auth/api/_shared/wecom/events.ts +111 -26
- package/templates/src/auth/api/_shared/wecom/login.ts +152 -1
- package/templates/src/auth/api/_shared/wecom/payload.ts +116 -0
- package/templates/src/auth/api/_shared/wecom/store.ts +65 -0
- package/templates/src/auth/api/_shared/wecom/sync.ts +487 -0
- package/templates/src/auth/api/manifest.yaml +3 -0
- package/templates/src/auth/api/migrations/0002__org_sync.mysql.sql +28 -0
- package/templates/src/auth/api/migrations/0002__org_sync.pg.sql +27 -0
- package/templates/src/auth/api/migrations/0002__org_sync.sql +41 -0
- package/templates/src/auth/api/schema.yaml +31 -0
- package/templates/src/auth/api/wecom/admin/api.ts +7 -5
- package/templates/src/auth/api/wecom/api.ts +100 -18
- package/templates/src/auth/api/wecom/sync/api.ts +199 -0
- package/templates/src/auth/web/__client/api.schemas.ts +97 -0
- package/templates/src/auth/web/__client/api.ts +73 -1
- package/templates/src/login/web/auth-provider.ts +9 -1
- package/templates/src/notification/api/seed.sql +1 -1
- package/templates/src/system/api/departments/api.ts +142 -0
- package/templates/src/system/api/departments/detach/api.ts +34 -0
- package/templates/src/system/api/departments/members/api.ts +142 -0
- package/templates/src/system/api/departments/move/api.ts +39 -0
- package/templates/src/system/api/users/api.ts +45 -5
- package/templates/src/system/api/users/profile/api.ts +44 -0
- package/templates/src/system/web/__client/api.schemas.ts +143 -0
- package/templates/src/system/web/__client/api.ts +293 -1
- package/templates/src/system/web/entry.ts +6 -0
- package/templates/src/system/web/locales/en-US.json +113 -7
- package/templates/src/system/web/locales/zh-CN.json +113 -7
- package/templates/src/system/web/pages/departments/index.tsx +346 -0
- package/templates/src/system/web/pages/departments/sync-bar.tsx +257 -0
- package/templates/src/system/web/pages/departments/tree.ts +66 -0
- package/templates/src/system/web/pages/errors.ts +1 -1
- package/templates/src/system/web/pages/users/index.tsx +49 -7
- package/templates/src/system/web/pages/wecom/index.tsx +16 -1
- package/templates/tests/l1-builder.test.ts +1 -1
- package/templates/tests/org-departments.spec.ts +311 -0
- package/templates/tests/org-read.spec.ts +469 -0
- package/templates/tests/rbac-contacts.spec.ts +13 -3
- package/templates/tests/rbac-seed.spec.ts +11 -0
- package/templates/tests/seed-role-accounts.spec.ts +6 -2
- package/templates/tests/wecom-admin.spec.ts +64 -42
- package/templates/tests/wecom-client-timeout.spec.ts +99 -0
- package/templates/tests/wecom-connector.spec.ts +115 -11
- package/templates/tests/wecom-crypt.spec.ts +12 -3
- package/templates/tests/wecom-endpoints.spec.ts +182 -14
- package/templates/tests/wecom-org-sync.spec.ts +598 -0
- package/templates/tests/wecom-sync.spec.ts +286 -0
- package/vendor/host-versions.json +3 -3
- package/templates/src/auth/api/_shared/wecom/xml.ts +0 -36
|
@@ -15,7 +15,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
|
15
15
|
import { assertSuperAdmin, invalidateAllUsers } from "../src/_platform/api/_shared/service/infra/access";
|
|
16
16
|
import { listApps, setAppStatus } from "../src/auth/api/_shared/wecom/admin";
|
|
17
17
|
import { createWecomClient, runtimeCache, runtimeHttp } from "../src/auth/api/_shared/wecom/client";
|
|
18
|
-
import {
|
|
18
|
+
import { readWecomConfig } from "../src/auth/api/_shared/wecom/config";
|
|
19
19
|
import { installDb } from "./fixture";
|
|
20
20
|
|
|
21
21
|
const g = globalThis as any;
|
|
@@ -51,6 +51,8 @@ function installVars(overrides: Record<string, string> = {}): void {
|
|
|
51
51
|
const base: Record<string, string> = {
|
|
52
52
|
wecom_suite_id: SUITE,
|
|
53
53
|
wecom_suite_secret: "suite_secret",
|
|
54
|
+
wecom_provider_id: "provider_1",
|
|
55
|
+
wecom_provider_secret: "provider_secret",
|
|
54
56
|
wecom_callback_token: "cb_token",
|
|
55
57
|
wecom_encoding_aes_key: AES_KEY_B64.replace(/=$/, ""),
|
|
56
58
|
wecom_enc_key: "0123456789abcdef0123456789abcdef",
|
|
@@ -97,10 +99,6 @@ function seedCorp(corpId = "ww_corp_a", agentId = "100001", env = "prod", status
|
|
|
97
99
|
.run(SUITE, corpId, agentId, authCorpId, env, `企业 ${corpId}`, status, `gcm(perm_${corpId}_${agentId})`, NOW, NOW);
|
|
98
100
|
}
|
|
99
101
|
|
|
100
|
-
function seedTicket(ticket = "tk_9", expiresAt = NOW + 1800): void {
|
|
101
|
-
dbo.prepare("INSERT INTO wecom_suite_ticket (suite_id, suite_ticket, expires_at, update_time) VALUES (?, ?, ?, ?)").run(SUITE, ticket, expiresAt, NOW);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
102
|
function mkUser(username: string, roles: string[] = []): number {
|
|
105
103
|
dbo.prepare("INSERT INTO users (username, password_hash) VALUES (?, 'x')").run(username);
|
|
106
104
|
const id = (dbo.prepare("SELECT id FROM users WHERE username = ?").get(username) as { id: number }).id;
|
|
@@ -124,10 +122,10 @@ function fakeClient(): { client: any, invalidated: string[] } {
|
|
|
124
122
|
|
|
125
123
|
const deps = { store: { encrypt: (p: string) => `gcm(${p})`, decrypt: (b: string) => b, now: () => NOW } };
|
|
126
124
|
|
|
127
|
-
/** createInstall
|
|
125
|
+
/** createInstall 的薄包装:省掉动态 import 的样板 */
|
|
128
126
|
async function createInstallForTest(cfg: ReturnType<typeof readWecomConfig>, client: ReturnType<typeof createWecomClient>, state: string) {
|
|
129
127
|
const { createInstall } = await import("../src/auth/api/_shared/wecom/admin");
|
|
130
|
-
return createInstall(cfg, client,
|
|
128
|
+
return createInstall(cfg, client, state);
|
|
131
129
|
}
|
|
132
130
|
|
|
133
131
|
const api = async () => (await import("../src/auth/api/wecom/admin/api")).default;
|
|
@@ -251,70 +249,94 @@ describe("P3-B:启停(setAppStatus)", () => {
|
|
|
251
249
|
});
|
|
252
250
|
});
|
|
253
251
|
|
|
254
|
-
describe("P3-C:开通入口(createInstall)", () => {
|
|
255
|
-
|
|
252
|
+
describe("P3-C:开通入口(createInstall,代开发 get_customized_auth_url)", () => {
|
|
253
|
+
// 代开发**不支持** get_pre_auth_code(48002):本段钉死调用链走 provider_access_token +
|
|
254
|
+
// get_customized_auth_url,绝不回退到第三方应用的预授权码接口。
|
|
255
|
+
function stubUpstream(qrcodeUrl = "https://open.work.weixin.qq.com/3rdservice/QR_1", expiresIn = 7200): { calls: string[], bodies: any[] } {
|
|
256
256
|
const calls: string[] = [];
|
|
257
|
-
|
|
257
|
+
const bodies: any[] = [];
|
|
258
|
+
g.fetch = async (url: string, init?: any) => {
|
|
258
259
|
calls.push(url);
|
|
260
|
+
bodies.push(init?.body ? JSON.parse(init.body) : null);
|
|
259
261
|
return {
|
|
260
262
|
ok: true,
|
|
261
263
|
json: async () => {
|
|
262
|
-
if (url.includes("
|
|
263
|
-
return {
|
|
264
|
-
if (url.includes("
|
|
265
|
-
return {
|
|
264
|
+
if (url.includes("get_provider_token"))
|
|
265
|
+
return { provider_access_token: "prov_tk", expires_in: 7200 };
|
|
266
|
+
if (url.includes("get_customized_auth_url"))
|
|
267
|
+
return { qrcode_url: qrcodeUrl, expires_in: expiresIn };
|
|
266
268
|
throw new Error(`stub fetch 未预期:${url}`);
|
|
267
269
|
},
|
|
268
270
|
};
|
|
269
271
|
};
|
|
270
|
-
return calls;
|
|
272
|
+
return { calls, bodies };
|
|
271
273
|
}
|
|
272
274
|
|
|
273
|
-
it("
|
|
274
|
-
|
|
275
|
-
const calls = stubUpstream();
|
|
275
|
+
it("provider_token → get_customized_auth_url → qrcode_url(即开通链接),且绝不调 get_pre_auth_code", async () => {
|
|
276
|
+
const { calls, bodies } = stubUpstream();
|
|
276
277
|
const cfg = readWecomConfig();
|
|
277
278
|
const client = createWecomClient({ http: runtimeHttp(), cache: runtimeCache() });
|
|
278
279
|
|
|
279
280
|
const r = await createInstallForTest(cfg, client, "st_1");
|
|
280
|
-
expect(calls.some(u => u.includes("
|
|
281
|
-
expect(calls.some(u => u.includes("
|
|
281
|
+
expect(calls.some(u => u.includes("get_provider_token"))).toBe(true);
|
|
282
|
+
expect(calls.some(u => u.includes("get_customized_auth_url"))).toBe(true);
|
|
283
|
+
// 代开发 48002 的根因就是错用了 get_pre_auth_code——这条断言防止回归
|
|
284
|
+
expect(calls.some(u => u.includes("get_pre_auth_code"))).toBe(false);
|
|
285
|
+
|
|
286
|
+
const body = bodies.find(b => b && b.templateid_list) as any;
|
|
287
|
+
expect(body).toBeTruthy();
|
|
288
|
+
// get_customized_auth_url 要求 state 必填(errcode 40058 state missing):本系统必传、非空
|
|
289
|
+
expect(body.state).toBe("st_1");
|
|
290
|
+
expect(body.templateid_list).toEqual([SUITE]);
|
|
291
|
+
|
|
292
|
+
expect(r.installUrl).toBe("https://open.work.weixin.qq.com/3rdservice/QR_1");
|
|
293
|
+
// 下发给管理员的就是文档里的地址(不带 callback_format=json,依据见 config.ts::eventUrlOf)
|
|
282
294
|
expect(r.callbackUrl).toBe("https://site.example.com/api/auth/wecom/event");
|
|
283
|
-
expect(r.expiresIn).toBe(
|
|
284
|
-
|
|
285
|
-
const url = new URL(r.installUrl);
|
|
286
|
-
expect(`${url.origin}${url.pathname}`).toBe("https://open.work.weixin.qq.com/3rdapp/install");
|
|
287
|
-
expect(url.searchParams.get("suite_id")).toBe(SUITE);
|
|
288
|
-
expect(url.searchParams.get("pre_auth_code")).toBe("PRE_1");
|
|
289
|
-
expect(url.searchParams.get("redirect_uri")).toBe("https://site.example.com/login?wecom_installed=1");
|
|
290
|
-
expect(url.searchParams.get("state")).toBe("st_1");
|
|
295
|
+
expect(r.expiresIn).toBe(7200);
|
|
291
296
|
});
|
|
292
297
|
|
|
293
|
-
it("
|
|
294
|
-
|
|
298
|
+
it("调用方没传 state 时仍发默认 state(避免 40058 state missing)", async () => {
|
|
299
|
+
const { bodies } = stubUpstream();
|
|
295
300
|
const cfg = readWecomConfig();
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
expect(
|
|
301
|
+
const client = createWecomClient({ http: runtimeHttp(), cache: runtimeCache() });
|
|
302
|
+
await createInstallForTest(cfg, client, "");
|
|
303
|
+
const body = bodies.find(b => b && b.templateid_list) as any;
|
|
304
|
+
expect(body.state).toBeTruthy();
|
|
305
|
+
expect(body.state.length).toBeLessThanOrEqual(32);
|
|
301
306
|
});
|
|
302
307
|
|
|
303
|
-
it("缺
|
|
304
|
-
|
|
308
|
+
it("缺 provider 凭证(wecom_provider_id / wecom_provider_secret)→ 400 并点名缺哪两个 vars", async () => {
|
|
309
|
+
installVars({ wecom_provider_id: "", wecom_provider_secret: "" });
|
|
305
310
|
const cfg = readWecomConfig();
|
|
306
311
|
const client = createWecomClient({ http: runtimeHttp(), cache: runtimeCache() });
|
|
307
|
-
await expect(createInstallForTest(cfg, client, "")).rejects.toMatchObject({ status:
|
|
308
|
-
expect(calls).toEqual([]);
|
|
312
|
+
await expect(createInstallForTest(cfg, client, "")).rejects.toMatchObject({ status: 400 });
|
|
309
313
|
});
|
|
310
314
|
|
|
311
|
-
it("上游 errcode
|
|
312
|
-
|
|
313
|
-
g.fetch = async () => ({ ok: true, json: async () => ({ errcode: 40001, errmsg: "invalid credential" }) });
|
|
315
|
+
it("上游 errcode(如 provider_secret 错)→ 502(可重试语义,不是 500)", async () => {
|
|
316
|
+
g.fetch = async () => ({ ok: true, json: async () => ({ errcode: 40001, errmsg: "invalid provider secret" }) });
|
|
314
317
|
const cfg = readWecomConfig();
|
|
315
318
|
const client = createWecomClient({ http: runtimeHttp(), cache: runtimeCache() });
|
|
316
319
|
await expect(createInstallForTest(cfg, client, "")).rejects.toMatchObject({ status: 502 });
|
|
317
320
|
});
|
|
321
|
+
|
|
322
|
+
it("get_customized_auth_url 返回 842002(模板未上架/上线)→ 502 且提示去后台提交上线(运营动作,非代码缺陷)", async () => {
|
|
323
|
+
g.fetch = async (url: string) => ({
|
|
324
|
+
ok: true,
|
|
325
|
+
json: async () => {
|
|
326
|
+
if (url.includes("get_provider_token"))
|
|
327
|
+
return { provider_access_token: "prov_tk", expires_in: 7200 };
|
|
328
|
+
if (url.includes("get_customized_auth_url"))
|
|
329
|
+
return { errcode: 842002, errmsg: "template not on shelves" };
|
|
330
|
+
throw new Error(`stub fetch 未预期:${url}`);
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
const cfg = readWecomConfig();
|
|
334
|
+
const client = createWecomClient({ http: runtimeHttp(), cache: runtimeCache() });
|
|
335
|
+
await expect(createInstallForTest(cfg, client, "st_1")).rejects.toMatchObject({
|
|
336
|
+
status: 502,
|
|
337
|
+
message: expect.stringContaining("代开发模板尚未上架/上线"),
|
|
338
|
+
});
|
|
339
|
+
});
|
|
318
340
|
});
|
|
319
341
|
|
|
320
342
|
describe("P3-D:端点接线(/auth/wecom/admin/*)", () => {
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// 回归:企微 API 客户端在「服务端 isolate」下的超时与崩溃防护。
|
|
2
|
+
//
|
|
3
|
+
// 真实 oj 服务端 isolate 没有 `AbortSignal` 全局,也没有 `setTimeout`
|
|
4
|
+
// (oj-api-dev global.d.ts:「本运行时无 timer 全局,setTimeout 不可用」)。
|
|
5
|
+
// 旧实现 `fetch(url, { signal: AbortSignal.timeout(ms) })` 会直接 ReferenceError
|
|
6
|
+
// → 所有企微调用(换 token / 生成开通链接 / 登录)一律 502。
|
|
7
|
+
// 这里在测试里**模拟**该 isolate(删掉 AbortSignal、tasks 视情况),断言:
|
|
8
|
+
// 1. 无 AbortSignal 时调用照常工作(不抛 `AbortSignal is not defined`);
|
|
9
|
+
// 2. 不再把 `signal` 传给 fetch(旧 bug 的根);
|
|
10
|
+
// 3. tasks.sleep 存在 → 慢请求被软超时拦截并抛「超时」文案;
|
|
11
|
+
// 4. tasks 缺失 → 安全降级为「不超时」,快速请求正常返回。
|
|
12
|
+
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
14
|
+
import { runtimeHttp } from "../src/auth/api/_shared/wecom/client";
|
|
15
|
+
|
|
16
|
+
const g = globalThis as any;
|
|
17
|
+
|
|
18
|
+
let savedAbortSignal: unknown;
|
|
19
|
+
let savedTasks: unknown;
|
|
20
|
+
let savedFetch: unknown;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
savedAbortSignal = (globalThis as { AbortSignal?: unknown }).AbortSignal;
|
|
24
|
+
savedTasks = g.tasks;
|
|
25
|
+
savedFetch = g.fetch;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
Object.defineProperty(globalThis, "AbortSignal", { value: savedAbortSignal, configurable: true, writable: true });
|
|
30
|
+
g.tasks = savedTasks;
|
|
31
|
+
g.fetch = savedFetch;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("wecom client:无 AbortSignal 的 isolate 不崩(回归)", () => {
|
|
35
|
+
it("abortSignal 被删掉时,getJson/postJson 仍能调用企微接口", async () => {
|
|
36
|
+
// 模拟服务端 isolate:把 AbortSignal 强制为 undefined(兼覆盖「整类不存在」与「.timeout 缺失」两种形态)
|
|
37
|
+
Object.defineProperty(globalThis, "AbortSignal", { value: undefined, configurable: true, writable: true });
|
|
38
|
+
g.tasks = undefined;
|
|
39
|
+
|
|
40
|
+
const fetchMock = vi.fn(async () => ({
|
|
41
|
+
ok: true,
|
|
42
|
+
json: async () => ({ errcode: 0, suite_access_token: "TOK", expires_in: 7200 }),
|
|
43
|
+
}));
|
|
44
|
+
g.fetch = fetchMock;
|
|
45
|
+
|
|
46
|
+
const http = runtimeHttp();
|
|
47
|
+
const data = await http.postJson("https://qyapi.weixin.qq.com/cgi-bin/service/get_suite_token", { a: 1 });
|
|
48
|
+
expect(data.errcode).toBe(0);
|
|
49
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("不再把 signal 传给 fetch(旧实现传 AbortSignal.timeout 的结果)", async () => {
|
|
53
|
+
Object.defineProperty(globalThis, "AbortSignal", { value: undefined, configurable: true, writable: true });
|
|
54
|
+
g.tasks = undefined;
|
|
55
|
+
|
|
56
|
+
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ errcode: 0 }) }));
|
|
57
|
+
g.fetch = fetchMock;
|
|
58
|
+
|
|
59
|
+
await runtimeHttp().getJson("https://qyapi.weixin.qq.com/x");
|
|
60
|
+
const [, init] = fetchMock.mock.calls[0];
|
|
61
|
+
expect(init.signal).toBeUndefined();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("abortSignal.timeout 绝不被调用(防止旧 bug 回潮)", async () => {
|
|
65
|
+
const spy = vi.spyOn(globalThis.AbortSignal as { timeout: (ms: number) => unknown }, "timeout");
|
|
66
|
+
g.tasks = undefined;
|
|
67
|
+
g.fetch = vi.fn(async () => ({ ok: true, json: async () => ({ errcode: 0 }) }));
|
|
68
|
+
|
|
69
|
+
await runtimeHttp().getJson("https://qyapi.weixin.qq.com/x");
|
|
70
|
+
expect(spy).not.toHaveBeenCalled();
|
|
71
|
+
spy.mockRestore();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("wecom client:软超时(tasks.sleep)", () => {
|
|
76
|
+
it("tasks.sleep 存在时,慢请求被软超时拦截并抛出「超时」文案", async () => {
|
|
77
|
+
// 用立即 resolve 的 sleep 模拟(不真的等 TIMEOUT_MS,测试秒级完成)
|
|
78
|
+
g.tasks = { sleep: () => Promise.resolve() };
|
|
79
|
+
let resolveFetch: (v: unknown) => void = () => {};
|
|
80
|
+
const fetchMock = vi.fn(() => new Promise<unknown>((resolve) => {
|
|
81
|
+
resolveFetch = resolve;
|
|
82
|
+
}));
|
|
83
|
+
g.fetch = fetchMock;
|
|
84
|
+
|
|
85
|
+
const p = runtimeHttp().getJson("https://qyapi.weixin.qq.com/x");
|
|
86
|
+
await expect(p).rejects.toThrow(/超时/);
|
|
87
|
+
// 收尾挂起的 fetch,避免 open handle
|
|
88
|
+
resolveFetch({ ok: true, json: async () => ({ errcode: 0 }) });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("tasks 缺失时降级为不超时:快速请求正常返回", async () => {
|
|
92
|
+
g.tasks = undefined;
|
|
93
|
+
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ errcode: 0 }) }));
|
|
94
|
+
g.fetch = fetchMock;
|
|
95
|
+
|
|
96
|
+
const data = await runtimeHttp().getJson("https://qyapi.weixin.qq.com/x");
|
|
97
|
+
expect(data.errcode).toBe(0);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -19,7 +19,6 @@ import { createWecomClient, runtimeCache } from "../src/auth/api/_shared/wecom/c
|
|
|
19
19
|
import {
|
|
20
20
|
assertEncKey,
|
|
21
21
|
eventUrlOf,
|
|
22
|
-
installUrlOf,
|
|
23
22
|
loginRedirectOf,
|
|
24
23
|
readCallbackConfig,
|
|
25
24
|
readWecomConfig,
|
|
@@ -38,6 +37,7 @@ import {
|
|
|
38
37
|
putLoginState,
|
|
39
38
|
takeLoginState,
|
|
40
39
|
} from "../src/auth/api/_shared/wecom/login";
|
|
40
|
+
import { callbackFields, field } from "../src/auth/api/_shared/wecom/payload";
|
|
41
41
|
import {
|
|
42
42
|
claimLoginTicket,
|
|
43
43
|
ensureTenant,
|
|
@@ -494,7 +494,7 @@ describe("P1-G:事件分发(suite_ticket / 授权 / 解绑)", () => {
|
|
|
494
494
|
return "sat";
|
|
495
495
|
},
|
|
496
496
|
async permanentCodeByAuthCode() {
|
|
497
|
-
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 };
|
|
498
498
|
},
|
|
499
499
|
async corpAccessToken() {
|
|
500
500
|
return "ct";
|
|
@@ -511,14 +511,39 @@ describe("P1-G:事件分发(suite_ticket / 授权 / 解绑)", () => {
|
|
|
511
511
|
const deps = (client: unknown) => ({ cfg: CFG, client: client as never, store: storeDeps() });
|
|
512
512
|
|
|
513
513
|
it("suite_ticket:落库并给 30 分钟有效期(企微 10 分钟推一次,过期即拒发 API)", async () => {
|
|
514
|
-
await handleSuiteEvent("
|
|
514
|
+
await handleSuiteEvent({ SuiteId: "suite_1", InfoType: "suite_ticket", SuiteTicket: "tk_1" }, deps(fakeClient()));
|
|
515
515
|
expect(await getSuiteTicket(CFG.suiteId, storeDeps())).toEqual({ ticket: "tk_1", expiresAt: clock + 1800 });
|
|
516
516
|
});
|
|
517
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
|
+
|
|
518
543
|
it("create_auth:AuthCode 换永久码 → 每个 agent 一行 profile,并作废旧 corp token", async () => {
|
|
519
544
|
await putSuiteTicket(CFG.suiteId, "tk_1", clock + 1800, storeDeps());
|
|
520
545
|
const client = fakeClient();
|
|
521
|
-
await handleSuiteEvent("
|
|
546
|
+
await handleSuiteEvent({ SuiteId: "suite_1", InfoType: "create_auth", AuthCode: "AC_1", State: "env=test" }, deps(client));
|
|
522
547
|
const rows = dbo.prepare("SELECT agent_id, env, status FROM wecom_app_profile ORDER BY agent_id").all();
|
|
523
548
|
expect(rows).toEqual([
|
|
524
549
|
{ agent_id: "100001", env: "test", status: "active" },
|
|
@@ -529,31 +554,111 @@ describe("P1-G:事件分发(suite_ticket / 授权 / 解绑)", () => {
|
|
|
529
554
|
expect(dbo.prepare("SELECT action FROM sys_oplog WHERE action = 'wecom.auth.apply'").all()).toHaveLength(1);
|
|
530
555
|
});
|
|
531
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
|
+
|
|
532
588
|
it("cancel_auth:profile 全部停用 + 该租户 refresh 串清空(解绑后旧会话不得续命)", async () => {
|
|
533
589
|
await saveProfile(profile({ agent: "100001" }), storeDeps());
|
|
534
590
|
await saveProfile(profile({ agent: "100002" }), storeDeps());
|
|
535
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);
|
|
536
592
|
|
|
537
|
-
|
|
593
|
+
g.http = {};
|
|
594
|
+
await handleSuiteEvent({ SuiteId: "suite_1", InfoType: "cancel_auth", AuthCorpId: "ww_corp_a" }, deps(fakeClient()));
|
|
538
595
|
|
|
539
596
|
expect(dbo.prepare("SELECT DISTINCT status FROM wecom_app_profile").all()).toEqual([{ status: "disabled" }]);
|
|
540
597
|
expect(dbo.prepare("SELECT COUNT(*) AS n FROM sys_refresh_token").get()).toMatchObject({ n: 0 });
|
|
541
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");
|
|
542
601
|
});
|
|
543
602
|
|
|
544
603
|
it("SuiteId 与本部署不一致 → 400(不同套件的回调不得互相污染)", async () => {
|
|
545
|
-
await expect(handleSuiteEvent("
|
|
604
|
+
await expect(handleSuiteEvent({ SuiteId: "other", InfoType: "suite_ticket", SuiteTicket: "t" }, deps(fakeClient())))
|
|
546
605
|
.rejects
|
|
547
606
|
.toMatchObject({ status: 400 });
|
|
548
607
|
});
|
|
549
608
|
|
|
550
|
-
it("
|
|
551
|
-
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");
|
|
552
657
|
});
|
|
553
658
|
|
|
554
659
|
it("create_auth 缺 AuthCode → 400(宁可拒也不落半行 profile)", async () => {
|
|
555
660
|
await putSuiteTicket(CFG.suiteId, "tk_1", clock + 1800, storeDeps());
|
|
556
|
-
await expect(handleSuiteEvent("
|
|
661
|
+
await expect(handleSuiteEvent({ SuiteId: "suite_1", InfoType: "create_auth" }, deps(fakeClient())))
|
|
557
662
|
.rejects
|
|
558
663
|
.toMatchObject({ status: 400 });
|
|
559
664
|
});
|
|
@@ -657,6 +762,7 @@ describe("P1-J:部署常量读取(缺项一次报全 / 密钥格式 fail-fas
|
|
|
657
762
|
installVars();
|
|
658
763
|
const cfg = readWecomConfig();
|
|
659
764
|
expect(cfg.baseUrl).toBe("https://site.example.com/api/auth/wecom");
|
|
765
|
+
// 不带 callback_format=json:官方文档只给「对外收款」写了这个参数,代开发的通用回调配置只有 XML
|
|
660
766
|
expect(eventUrlOf(cfg)).toBe("https://site.example.com/api/auth/wecom/event");
|
|
661
767
|
expect(loginRedirectOf(cfg)).toBe("https://site.example.com/api/auth/wecom/callback");
|
|
662
768
|
expect(siteRootOf(cfg)).toBe("https://site.example.com");
|
|
@@ -668,8 +774,6 @@ describe("P1-J:部署常量读取(缺项一次报全 / 密钥格式 fail-fas
|
|
|
668
774
|
expect(siteRootOf(cfg)).toBe("https://site.example.com/oj");
|
|
669
775
|
expect(loginLandingUrl({ origin: siteRootOf(cfg), ticket: "tk", tenant: "ww_corp_a" }))
|
|
670
776
|
.toBe("https://site.example.com/oj/login?wecom_ticket=tk&tenant=ww_corp_a");
|
|
671
|
-
// 开通入口的 redirect_uri 同样要带子路径
|
|
672
|
-
expect(installUrlOf(cfg, "PAC1", "env=prod")).toContain(`redirect_uri=${encodeURIComponent("https://site.example.com/oj/login?wecom_installed=1")}`);
|
|
673
777
|
});
|
|
674
778
|
|
|
675
779
|
it("baseUrl 后缀不对 → 400(宁可拒,也不猜站点根拼出错误回跳)", () => {
|
|
@@ -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", () => {
|