@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
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
// BDD:B1 企微适配器——错码分流 / 授权模式 / 登录自更新(方案 §6.1、§9.1、D10、D13)。
|
|
2
|
+
//
|
|
3
|
+
// 这一段钉的是「同步器与登录路径靠什么做判断」,四类会静默出错的地方:
|
|
4
|
+
// · **错码分类**:60011/48002(无权限)必须能跳过并计数,45009/45033(限流)必须退避重试,
|
|
5
|
+
// 其余立即失败——三者混成一个 502 的话,同步要么一直白跑、要么把限流当权限错误静默丢部门。
|
|
6
|
+
// · **auth_mode**:成员授权下部门接口 100% 60011,落库错了同步会永远 0 结果且没人知道为什么;
|
|
7
|
+
// 企业管理员在后台切换授权模式时只有 `change_auth` 会告诉我们(该通知不带 AuthCode,
|
|
8
|
+
// 只能拿已存永久码去 get_auth_info 反查)。
|
|
9
|
+
// · **登录自更新节流**:上万员工每天登录,不节流就是把 user/get 打成常驻流量;
|
|
10
|
+
// 但 `user/get` 拿不到(无权限/不在可见范围)时**不能**写节流键,否则这一整天都补不上资料。
|
|
11
|
+
// · **D10 不建占位部门**:登录路径若能顺手建部门,任何成员都能凭空造出组织节点。
|
|
12
|
+
import type { WecomCache, WecomHttp } from "../src/auth/api/_shared/wecom/client";
|
|
13
|
+
import type { WecomStoreDeps } from "../src/auth/api/_shared/wecom/store";
|
|
14
|
+
|
|
15
|
+
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
16
|
+
import { upsertDepartment } from "../src/_platform/api/_shared/service/domain/org";
|
|
17
|
+
import { createWecomClient, WecomApiError } from "../src/auth/api/_shared/wecom/client";
|
|
18
|
+
import { handleSuiteEvent } from "../src/auth/api/_shared/wecom/events";
|
|
19
|
+
import { SELF_UPDATE_TTL_SECS, selfUpdate } from "../src/auth/api/_shared/wecom/login";
|
|
20
|
+
import { findProfile, probeProfile, putSuiteTicket, saveProfile, setProfileStatus } from "../src/auth/api/_shared/wecom/store";
|
|
21
|
+
import { installDb } from "./fixture";
|
|
22
|
+
|
|
23
|
+
/* eslint-disable test/prefer-lowercase-title -- D/AC 编号是验收锚点,保留大写 */
|
|
24
|
+
|
|
25
|
+
let dbo: ReturnType<typeof installDb>;
|
|
26
|
+
const g = globalThis as any;
|
|
27
|
+
|
|
28
|
+
let clock = 1_000_000;
|
|
29
|
+
|
|
30
|
+
const CFG = {
|
|
31
|
+
suiteId: "suite_1",
|
|
32
|
+
suiteSecret: "secret_1",
|
|
33
|
+
token: "tok",
|
|
34
|
+
encodingAesKey: "a".repeat(43),
|
|
35
|
+
encKey: "k",
|
|
36
|
+
baseUrl: "https://site.example.com/api/auth/wecom",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function storeDeps(): WecomStoreDeps {
|
|
40
|
+
return {
|
|
41
|
+
encrypt: (p: string) => `enc:${p}`,
|
|
42
|
+
decrypt: (b: string) => b.replace(/^enc:/, ""),
|
|
43
|
+
now: () => clock,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function memCache(): WecomCache & { keys: () => string[] } {
|
|
48
|
+
const map = new Map<string, { value: string, exp: number }>();
|
|
49
|
+
return {
|
|
50
|
+
async get(key) {
|
|
51
|
+
const hit = map.get(key);
|
|
52
|
+
return hit && hit.exp > clock ? hit.value : null;
|
|
53
|
+
},
|
|
54
|
+
async set(key, value, ttl) {
|
|
55
|
+
map.set(key, { value, exp: clock + ttl });
|
|
56
|
+
},
|
|
57
|
+
async del(key) {
|
|
58
|
+
map.delete(key);
|
|
59
|
+
},
|
|
60
|
+
keys: () => [...map.keys()],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 预置 corp token 缓存:让被测路径只剩「目标接口」一次出网,调用次数才好断言 */
|
|
65
|
+
async function cacheWithToken(): Promise<WecomCache> {
|
|
66
|
+
const cache = memCache();
|
|
67
|
+
await cache.set("wecom:corp_token:ww_corp_a:100001", "ct", 7200);
|
|
68
|
+
return cache;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 只回答通讯录接口的桩:按 path 命中 errcode,`calls` 用来断言重试次数 */
|
|
72
|
+
function stubHttp(reply: { errcode: number, errmsg?: string, payload?: Record<string, unknown> }): WecomHttp & { calls: string[] } {
|
|
73
|
+
const calls: string[] = [];
|
|
74
|
+
const data = { errcode: reply.errcode, errmsg: reply.errmsg ?? "", ...(reply.payload ?? {}) };
|
|
75
|
+
return {
|
|
76
|
+
calls,
|
|
77
|
+
async getJson(url) {
|
|
78
|
+
calls.push(url);
|
|
79
|
+
return data;
|
|
80
|
+
},
|
|
81
|
+
async postJson(url) {
|
|
82
|
+
calls.push(url);
|
|
83
|
+
return data;
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function clientWith(reply: { errcode: number, errmsg?: string, payload?: Record<string, unknown> }) {
|
|
89
|
+
const http = stubHttp(reply);
|
|
90
|
+
const cache = await cacheWithToken();
|
|
91
|
+
return { http, cache, client: createWecomClient({ http, cache }) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
beforeAll(() => {
|
|
95
|
+
dbo = installDb();
|
|
96
|
+
g.db.asTenant = () => {};
|
|
97
|
+
g.log = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} };
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
beforeEach(() => {
|
|
101
|
+
g.http = {};
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
afterEach(() => {
|
|
105
|
+
g.http = {};
|
|
106
|
+
clock = 1_000_000;
|
|
107
|
+
for (const t of ["wecom_app_profile", "wecom_suite_ticket", "sys_dept_member", "sys_department", "users", "sys_oplog", "sys_refresh_token"])
|
|
108
|
+
dbo.exec(`DELETE FROM ${t}`);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("B1-A:企微错码三分(同步器靠 kind 决定跳过/退避/熔断)", () => {
|
|
112
|
+
it("60011 / 48002 → permission:立即抛出,不重试(无权限重试一万次也还是无权限)", async () => {
|
|
113
|
+
for (const code of [60011, 48002]) {
|
|
114
|
+
const { http, client } = await clientWith({ errcode: code, errmsg: "no privilege" });
|
|
115
|
+
const err = await client.departmentList("ww_corp_a", "100001", "perm", undefined).catch(e => e);
|
|
116
|
+
expect(err).toBeInstanceOf(WecomApiError);
|
|
117
|
+
expect(err.kind).toBe("permission");
|
|
118
|
+
expect(err.errcode).toBe(code);
|
|
119
|
+
expect(http.calls).toHaveLength(1);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("45009 / 45033 → ratelimit:退避重试三次,仍失败才抛(错码必须带出去)", async () => {
|
|
124
|
+
for (const code of [45009, 45033]) {
|
|
125
|
+
const { http, client } = await clientWith({ errcode: code, errmsg: "rate limit" });
|
|
126
|
+
const err = await client.departmentList("ww_corp_a", "100001", "perm", undefined).catch(e => e);
|
|
127
|
+
expect(err).toBeInstanceOf(WecomApiError);
|
|
128
|
+
expect(err.kind).toBe("ratelimit");
|
|
129
|
+
expect(err.errcode).toBe(code);
|
|
130
|
+
// 首次 + 两次退避重试
|
|
131
|
+
expect(http.calls).toHaveLength(3);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("其余错码 → fatal:一次就抛(60020 是 IP 白名单,重试没有意义)", async () => {
|
|
136
|
+
const { http, client } = await clientWith({ errcode: 60020, errmsg: "not allow to access from your ip" });
|
|
137
|
+
const err = await client.departmentList("ww_corp_a", "100001", "perm", undefined).catch(e => e);
|
|
138
|
+
expect(err.kind).toBe("fatal");
|
|
139
|
+
expect(http.calls).toHaveLength(1);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("token 失效(40001)先换票重试一次,不把用户打成失败", async () => {
|
|
143
|
+
const cache = await cacheWithToken();
|
|
144
|
+
let first = true;
|
|
145
|
+
const http: WecomHttp = {
|
|
146
|
+
async getJson(url) {
|
|
147
|
+
if (url.includes("/cgi-bin/gettoken"))
|
|
148
|
+
return { errcode: 0, access_token: "ct2", expires_in: 7200 };
|
|
149
|
+
if (first) {
|
|
150
|
+
first = false;
|
|
151
|
+
return { errcode: 40001, errmsg: "invalid credential" };
|
|
152
|
+
}
|
|
153
|
+
return { errcode: 0, department: [{ id: 1, name: "总公司" }] };
|
|
154
|
+
},
|
|
155
|
+
async postJson() {
|
|
156
|
+
return { errcode: 0 };
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
const client = createWecomClient({ http, cache });
|
|
160
|
+
const list = await client.departmentList("ww_corp_a", "100001", "perm", undefined);
|
|
161
|
+
expect(list).toHaveLength(1);
|
|
162
|
+
// 旧 token 已作废:缓存里换成了新票
|
|
163
|
+
expect(await cache.get("wecom:corp_token:ww_corp_a:100001")).toBe("ct2");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("userDetail 是 best-effort:任何错码都返回 null,绝不抛出(登录不能因此 5xx)", async () => {
|
|
167
|
+
const { client } = await clientWith({ errcode: 60011, errmsg: "no privilege" });
|
|
168
|
+
await expect(client.userDetail("ww_corp_a", "100001", "perm", "zhangsan")).resolves.toBeNull();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("userDetail 回包缺 userid(精简回包)也用入参补,姓名照常拿到", async () => {
|
|
172
|
+
const { client } = await clientWith({ errcode: 0, payload: { name: "张三", department: [2], order: [10] } });
|
|
173
|
+
const detail = await client.userDetail("ww_corp_a", "100001", "perm", "zhangsan");
|
|
174
|
+
expect(detail).toMatchObject({ userid: "zhangsan", name: "张三", departments: ["2"] });
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("B1-B:授权模式(auth_mode)", () => {
|
|
179
|
+
it("落库:saveProfile 带 authMode → 读回同一个值", async () => {
|
|
180
|
+
await saveProfile({
|
|
181
|
+
suiteId: CFG.suiteId,
|
|
182
|
+
corpId: "ww_corp_a",
|
|
183
|
+
authCorpId: "ww_corp_a",
|
|
184
|
+
agentId: "100001",
|
|
185
|
+
env: "prod",
|
|
186
|
+
corpName: "测试企业",
|
|
187
|
+
permanentCode: "perm_a",
|
|
188
|
+
authMode: 1,
|
|
189
|
+
}, storeDeps());
|
|
190
|
+
expect((await findProfile("ww_corp_a", "100001", storeDeps())).authMode).toBe(1);
|
|
191
|
+
expect(dbo.prepare("SELECT auth_mode FROM wecom_app_profile").get()).toMatchObject({ auth_mode: 1 });
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("缺省按 0(管理员授权)——成员授权会让同步 100% 60011,但也不能反过来猜成 1", async () => {
|
|
195
|
+
await saveProfile({
|
|
196
|
+
suiteId: CFG.suiteId,
|
|
197
|
+
corpId: "ww_corp_a",
|
|
198
|
+
authCorpId: "ww_corp_a",
|
|
199
|
+
agentId: "100001",
|
|
200
|
+
env: "prod",
|
|
201
|
+
corpName: null,
|
|
202
|
+
permanentCode: "perm_a",
|
|
203
|
+
} as never, storeDeps());
|
|
204
|
+
expect((await findProfile("ww_corp_a", "100001", storeDeps())).authMode).toBe(0);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("change_auth:用已存永久码反查 get_auth_info 并刷新 auth_mode(不带 AuthCode 的唯一路径)", async () => {
|
|
208
|
+
await saveProfile({
|
|
209
|
+
suiteId: CFG.suiteId,
|
|
210
|
+
corpId: "ww_corp_a",
|
|
211
|
+
authCorpId: "ww_corp_a",
|
|
212
|
+
agentId: "100001",
|
|
213
|
+
env: "prod",
|
|
214
|
+
corpName: "测试企业",
|
|
215
|
+
permanentCode: "perm_a",
|
|
216
|
+
authMode: 0,
|
|
217
|
+
}, storeDeps());
|
|
218
|
+
await putSuiteTicket(CFG.suiteId, "tk_1", clock + 1800, storeDeps());
|
|
219
|
+
|
|
220
|
+
const seen: string[] = [];
|
|
221
|
+
const client = {
|
|
222
|
+
async suiteAccessToken() {
|
|
223
|
+
return "sat";
|
|
224
|
+
},
|
|
225
|
+
async authModesByPermanentCode(suiteToken: string, corpId: string, permanentCode: string) {
|
|
226
|
+
seen.push(`${suiteToken}/${corpId}/${permanentCode}`);
|
|
227
|
+
return { 100001: 1 };
|
|
228
|
+
},
|
|
229
|
+
} as never;
|
|
230
|
+
|
|
231
|
+
await handleSuiteEvent({ InfoType: "change_auth", SuiteId: "suite_1", AuthCorpId: "ww_corp_a" }, { cfg: CFG, client, store: storeDeps() });
|
|
232
|
+
expect(seen).toEqual(["sat/ww_corp_a/perm_a"]);
|
|
233
|
+
expect((await findProfile("ww_corp_a", "100001", storeDeps())).authMode).toBe(1);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("change_auth 复核失败只告警:回调仍回 success(窗口 1 秒,重推补不回来)", async () => {
|
|
237
|
+
await saveProfile({
|
|
238
|
+
suiteId: CFG.suiteId,
|
|
239
|
+
corpId: "ww_corp_a",
|
|
240
|
+
authCorpId: "ww_corp_a",
|
|
241
|
+
agentId: "100001",
|
|
242
|
+
env: "prod",
|
|
243
|
+
corpName: "测试企业",
|
|
244
|
+
permanentCode: "perm_a",
|
|
245
|
+
authMode: 0,
|
|
246
|
+
}, storeDeps());
|
|
247
|
+
await putSuiteTicket(CFG.suiteId, "tk_1", clock + 1800, storeDeps());
|
|
248
|
+
const client = {
|
|
249
|
+
async suiteAccessToken() {
|
|
250
|
+
throw new Error("企微 get_suite_token 失败:errcode=40001");
|
|
251
|
+
},
|
|
252
|
+
} as never;
|
|
253
|
+
await expect(handleSuiteEvent({ InfoType: "change_auth", SuiteId: "suite_1", AuthCorpId: "ww_corp_a" }, { cfg: CFG, client, store: storeDeps() }))
|
|
254
|
+
.resolves
|
|
255
|
+
.toBe("change_auth");
|
|
256
|
+
// 旧值保留,同步闸门那侧还有 60011 兜底
|
|
257
|
+
expect((await findProfile("ww_corp_a", "100001", storeDeps())).authMode).toBe(0);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe("B1-D:授权探针(同步入口的准入事实)", () => {
|
|
262
|
+
const profile = (agentId: string, authMode = 0) => saveProfile({
|
|
263
|
+
suiteId: CFG.suiteId,
|
|
264
|
+
corpId: "ww_corp_a",
|
|
265
|
+
authCorpId: "ww_corp_a",
|
|
266
|
+
agentId,
|
|
267
|
+
env: "prod",
|
|
268
|
+
corpName: null,
|
|
269
|
+
permanentCode: `perm_${agentId}`,
|
|
270
|
+
authMode,
|
|
271
|
+
}, storeDeps());
|
|
272
|
+
|
|
273
|
+
it("没装企微 → null:页面据此整条不显示,而不是画红叉", async () => {
|
|
274
|
+
expect(await probeProfile("ww_unknown", null)).toBeNull();
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("装了 → 拿得到 agent + 授权模式;带 agent 可收窄,指错应用 → null", async () => {
|
|
278
|
+
await profile("100001", 1);
|
|
279
|
+
expect(await probeProfile("ww_corp_a", null)).toEqual({ agentId: "100001", authMode: 1, status: "active" });
|
|
280
|
+
expect(await probeProfile("ww_corp_a", "100001")).toMatchObject({ authMode: 1 });
|
|
281
|
+
expect(await probeProfile("ww_corp_a", "200002")).toBeNull();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("同企业多个启用应用且没带 agent → 400(真歧义,不能瞎挑一个去同步)", async () => {
|
|
285
|
+
await profile("100001");
|
|
286
|
+
await profile("200002");
|
|
287
|
+
await expect(probeProfile("ww_corp_a", null)).rejects.toMatchObject({ status: 400 });
|
|
288
|
+
expect(await probeProfile("ww_corp_a", "200002")).toMatchObject({ agentId: "200002" });
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("企业被停用/取消授权 → 仍探得到(status=disabled),同步面据此画冻结黄条而不是「没装」", async () => {
|
|
292
|
+
await profile("100001");
|
|
293
|
+
await setProfileStatus("ww_corp_a", "disabled", storeDeps());
|
|
294
|
+
expect(await probeProfile("ww_corp_a", null)).toMatchObject({ status: "disabled" });
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("停用后再装一个新应用:优先返回启用中的那个(别把活的应用报成冻结)", async () => {
|
|
298
|
+
await profile("100001");
|
|
299
|
+
await profile("200002");
|
|
300
|
+
await db.table("wecom_app_profile").update({ status: "disabled" }).where({ field: "agent_id", op: "eq", value: "100001" }).run();
|
|
301
|
+
expect(await probeProfile("ww_corp_a", null)).toMatchObject({ agentId: "200002", status: "active" });
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
describe("B1-C:登录自更新(轨一)", () => {
|
|
306
|
+
function mkUser(username: string, extra: Record<string, unknown> = {}): number {
|
|
307
|
+
dbo.prepare("INSERT INTO users (username, password_hash, display_name, source) VALUES (?, '!wecom', ?, ?)")
|
|
308
|
+
.run(username, extra.displayName ?? username, extra.source ?? "wecom");
|
|
309
|
+
return (dbo.prepare("SELECT id FROM users WHERE username = ?").get(username) as { id: number }).id;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function wecomDept(originId: string, name = `部门${originId}`): Promise<number> {
|
|
313
|
+
return upsertDepartment({ origin: "wecom", originId, parentId: null, name }, { now: () => clock });
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function userRow(username: string): Record<string, unknown> {
|
|
317
|
+
return dbo.prepare("SELECT display_name, position, external_status, activated, source FROM users WHERE username = ?").get(username) as Record<string, unknown>;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** 只实现 userDetail 的桩:`detail` 为 null 表示拿不到(无权限/不在可见范围) */
|
|
321
|
+
function clientOf(detail: unknown, counter?: { n: number }) {
|
|
322
|
+
return {
|
|
323
|
+
async userDetail() {
|
|
324
|
+
if (counter)
|
|
325
|
+
counter.n++;
|
|
326
|
+
if (detail instanceof Error)
|
|
327
|
+
throw detail;
|
|
328
|
+
return detail;
|
|
329
|
+
},
|
|
330
|
+
} as never;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const ctx = { uid: 0, userid: "zhangsan", corpId: "ww_corp_a", agentId: "100001", permanentCode: "perm_a" };
|
|
334
|
+
|
|
335
|
+
it("首次登录:姓名/职务/在职状态回写 + 部门关联 + 主部门,并写 24h 节流键", async () => {
|
|
336
|
+
const uid = mkUser("wecom:zhangsan");
|
|
337
|
+
const deptId = await wecomDept("2");
|
|
338
|
+
const cache = memCache();
|
|
339
|
+
const res = await selfUpdate({ ...ctx, uid }, {
|
|
340
|
+
client: clientOf({ userid: "zhangsan", name: "张三", position: "工程师", status: 1, departments: ["2"], orders: [10], leaderInDept: [1], mainDepartment: null }),
|
|
341
|
+
cache,
|
|
342
|
+
store: storeDeps(),
|
|
343
|
+
});
|
|
344
|
+
expect(res.blocked).toBe(false);
|
|
345
|
+
expect(userRow("wecom:zhangsan")).toMatchObject({ display_name: "张三", position: "工程师", external_status: 1, activated: 1, source: "wecom" });
|
|
346
|
+
expect(dbo.prepare("SELECT dept_id, order_no, is_leader, is_main FROM sys_dept_member WHERE user_id = ?").get(uid))
|
|
347
|
+
.toMatchObject({ dept_id: deptId, order_no: 10, is_leader: 1, is_main: 1 });
|
|
348
|
+
expect(await cache.get("wecom:self:ww_corp_a:zhangsan")).toBe("1");
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("节流内再次登录不再打上游(一次 user/get 顶一天)", async () => {
|
|
352
|
+
const uid = mkUser("wecom:zhangsan");
|
|
353
|
+
const cache = memCache();
|
|
354
|
+
const counter = { n: 0 };
|
|
355
|
+
const detail = { userid: "zhangsan", name: "张三", position: null, status: 1, departments: [], orders: [], leaderInDept: [], mainDepartment: null };
|
|
356
|
+
await selfUpdate({ ...ctx, uid }, { client: clientOf(detail, counter), cache, store: storeDeps() });
|
|
357
|
+
clock += SELF_UPDATE_TTL_SECS - 1;
|
|
358
|
+
await selfUpdate({ ...ctx, uid }, { client: clientOf(detail, counter), cache, store: storeDeps() });
|
|
359
|
+
expect(counter.n).toBe(1);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("userDetail 拿不到(null):不写节流键,下次登录还能补(否则这天都补不上资料)", async () => {
|
|
363
|
+
const uid = mkUser("wecom:zhangsan");
|
|
364
|
+
const cache = memCache();
|
|
365
|
+
const res = await selfUpdate({ ...ctx, uid }, { client: clientOf(null), cache, store: storeDeps() });
|
|
366
|
+
expect(res.blocked).toBe(false);
|
|
367
|
+
expect(await cache.get("wecom:self:ww_corp_a:zhangsan")).toBeNull();
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("状态 2(已禁用)/ 5(退出企业)→ blocked=true,登录侧据此 403", async () => {
|
|
371
|
+
const uid = mkUser("wecom:zhangsan");
|
|
372
|
+
for (const status of [2, 5]) {
|
|
373
|
+
const res = await selfUpdate({ ...ctx, uid }, {
|
|
374
|
+
client: clientOf({ userid: "zhangsan", name: "张三", position: null, status, departments: [], orders: [], leaderInDept: [], mainDepartment: null }),
|
|
375
|
+
cache: memCache(),
|
|
376
|
+
store: storeDeps(),
|
|
377
|
+
});
|
|
378
|
+
expect(res.blocked).toBe(true);
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("节流命中时仍读本地 external_status 判禁用(不能因为跳过上游就放行离职的人)", async () => {
|
|
383
|
+
const uid = mkUser("wecom:zhangsan");
|
|
384
|
+
dbo.prepare("UPDATE users SET external_status = 5 WHERE id = ?").run(uid);
|
|
385
|
+
const cache = memCache();
|
|
386
|
+
await cache.set("wecom:self:ww_corp_a:zhangsan", "1", SELF_UPDATE_TTL_SECS);
|
|
387
|
+
const counter = { n: 0 };
|
|
388
|
+
const res = await selfUpdate({ ...ctx, uid }, { client: clientOf(null, counter), cache, store: storeDeps() });
|
|
389
|
+
expect(res.blocked).toBe(true);
|
|
390
|
+
expect(counter.n).toBe(0);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("未知部门一律跳过、不建占位部门(D10:登录成员不得凭空造组织节点)", async () => {
|
|
394
|
+
const uid = mkUser("wecom:zhangsan");
|
|
395
|
+
await selfUpdate({ ...ctx, uid }, {
|
|
396
|
+
client: clientOf({ userid: "zhangsan", name: "张三", position: null, status: 1, departments: ["999"], orders: [1], leaderInDept: [0], mainDepartment: null }),
|
|
397
|
+
cache: memCache(),
|
|
398
|
+
store: storeDeps(),
|
|
399
|
+
});
|
|
400
|
+
expect(dbo.prepare("SELECT COUNT(*) c FROM sys_department").get()).toMatchObject({ c: 0 });
|
|
401
|
+
expect(dbo.prepare("SELECT COUNT(*) c FROM sys_dept_member").get()).toMatchObject({ c: 0 });
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("只写企微拥有的行(D13):管理员已「转为本地维护」的号不被覆盖", async () => {
|
|
405
|
+
const uid = mkUser("wecom:zhangsan", { displayName: "本地维护名", source: "local" });
|
|
406
|
+
await selfUpdate({ ...ctx, uid }, {
|
|
407
|
+
client: clientOf({ userid: "zhangsan", name: "企微名", position: "工程师", status: 1, departments: [], orders: [], leaderInDept: [], mainDepartment: null }),
|
|
408
|
+
cache: memCache(),
|
|
409
|
+
store: storeDeps(),
|
|
410
|
+
});
|
|
411
|
+
expect(userRow("wecom:zhangsan")).toMatchObject({ display_name: "本地维护名", source: "local", position: null });
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("上游/写库异常不影响登录:永不抛出,只 blocked=false", async () => {
|
|
415
|
+
const uid = mkUser("wecom:zhangsan");
|
|
416
|
+
const res = await selfUpdate({ ...ctx, uid }, { client: clientOf(new Error("上游炸了")), cache: memCache(), store: storeDeps() });
|
|
417
|
+
expect(res.blocked).toBe(false);
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
// 轨三:事件增量。全程**只有一条纪律**要钉住——事件是加速器,不是权威:
|
|
422
|
+
// 写对了就立刻可见(不必等下一次全量),写错了也绝不抛(抛了企微重推三次,而重推补不回来)。
|
|
423
|
+
describe("C1:通讯录事件增量(change_contact)", () => {
|
|
424
|
+
/** 应用侧数据回调(90970):企业身份在 ToUserName,没有 InfoType */
|
|
425
|
+
const appEvent = (extra: Record<string, unknown>) => ({
|
|
426
|
+
ToUserName: "ww_corp_a",
|
|
427
|
+
FromUserName: "sys",
|
|
428
|
+
CreateTime: 1_700_000_000,
|
|
429
|
+
MsgType: "event",
|
|
430
|
+
Event: "change_contact",
|
|
431
|
+
...extra,
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
/** 代开发指令回调(91134):企业身份在 AuthCorpId,事件名在 InfoType,字段往往只有一个 id */
|
|
435
|
+
const suiteEvent = (extra: Record<string, unknown>) => ({
|
|
436
|
+
SuiteId: "suite_1",
|
|
437
|
+
AuthCorpId: "ww_corp_a",
|
|
438
|
+
InfoType: "change_contact",
|
|
439
|
+
...extra,
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
async function authorized(): Promise<void> {
|
|
443
|
+
await saveProfile({
|
|
444
|
+
suiteId: CFG.suiteId,
|
|
445
|
+
corpId: "ww_corp_a",
|
|
446
|
+
authCorpId: "ww_corp_a",
|
|
447
|
+
agentId: "100001",
|
|
448
|
+
env: "prod",
|
|
449
|
+
corpName: "测试企业",
|
|
450
|
+
permanentCode: "perm_a",
|
|
451
|
+
authMode: 0,
|
|
452
|
+
}, storeDeps());
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** 只实现两个回源接口的桩:member/dept 缺省 = 拿不到(无权限 / 不在可见范围) */
|
|
456
|
+
function clientOf(opts: { member?: unknown, dept?: unknown } = {}) {
|
|
457
|
+
const calls = { member: 0, dept: 0 };
|
|
458
|
+
return {
|
|
459
|
+
calls,
|
|
460
|
+
async userDetail() {
|
|
461
|
+
calls.member++;
|
|
462
|
+
return opts.member ?? null;
|
|
463
|
+
},
|
|
464
|
+
async departmentDetail() {
|
|
465
|
+
calls.dept++;
|
|
466
|
+
if (!opts.dept)
|
|
467
|
+
throw new Error("企微 department/get 失败:errcode=60011");
|
|
468
|
+
return opts.dept;
|
|
469
|
+
},
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const deps = (client: unknown) => ({ cfg: CFG, client: client as never, store: storeDeps() });
|
|
474
|
+
|
|
475
|
+
async function wecomDept(originId: string, name = `部门${originId}`, parentId: number | null = null): Promise<number> {
|
|
476
|
+
return upsertDepartment({ origin: "wecom", originId, parentId, name }, { now: () => clock });
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function mkUser(username: string, extra: Record<string, unknown> = {}): number {
|
|
480
|
+
dbo.prepare("INSERT INTO users (username, password_hash, display_name, source, activated) VALUES (?, '!wecom', ?, 'wecom', ?)")
|
|
481
|
+
.run(username, extra.displayName ?? username, extra.activated ?? 0);
|
|
482
|
+
return (dbo.prepare("SELECT id FROM users WHERE username = ?").get(username) as { id: number }).id;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const uidOf = (username: string) => (dbo.prepare("SELECT id FROM users WHERE username = ?").get(username) as { id: number } | undefined)?.id;
|
|
486
|
+
|
|
487
|
+
it("create_user(应用侧):建档但不置 activated,姓名/部门/主部门照事件落库", async () => {
|
|
488
|
+
await authorized();
|
|
489
|
+
const d1 = await wecomDept("1");
|
|
490
|
+
const d2 = await wecomDept("2");
|
|
491
|
+
const client = clientOf();
|
|
492
|
+
await handleSuiteEvent(appEvent({
|
|
493
|
+
ChangeType: "create_user",
|
|
494
|
+
UserID: "zhangsan",
|
|
495
|
+
Name: "张三",
|
|
496
|
+
Department: "1|2",
|
|
497
|
+
MainDepartment: "2",
|
|
498
|
+
IsLeaderInDept: "1|0",
|
|
499
|
+
}), deps(client));
|
|
500
|
+
|
|
501
|
+
// 回源拿不到(无通讯录权限)→ 退回事件自带字段,而不是什么都不写
|
|
502
|
+
expect(client.calls.member).toBe(1);
|
|
503
|
+
expect(dbo.prepare("SELECT display_name, source, activated FROM users WHERE username = 'wecom:zhangsan'").get())
|
|
504
|
+
.toMatchObject({ display_name: "张三", source: "wecom", activated: 0 });
|
|
505
|
+
const uid = uidOf("wecom:zhangsan")!;
|
|
506
|
+
expect(dbo.prepare("SELECT dept_id, is_leader, is_main FROM sys_dept_member WHERE user_id = ? ORDER BY dept_id").all(uid))
|
|
507
|
+
.toEqual([{ dept_id: d1, is_leader: 1, is_main: 0 }, { dept_id: d2, is_leader: 0, is_main: 1 }]);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it("create_user(代开发形态只有 UserID):回源拿全量快照,职务/排序/在职状态都补上", async () => {
|
|
511
|
+
await authorized();
|
|
512
|
+
const d1 = await wecomDept("1");
|
|
513
|
+
await handleSuiteEvent(suiteEvent({ ChangeType: "create_user", UserID: "lisi" }), deps(clientOf({
|
|
514
|
+
member: { userid: "lisi", name: "李四", position: "经理", status: 1, departments: ["1"], orders: [5], leaderInDept: [1], mainDepartment: 1 },
|
|
515
|
+
})));
|
|
516
|
+
|
|
517
|
+
expect(dbo.prepare("SELECT display_name, position, external_status FROM users WHERE username = 'wecom:lisi'").get())
|
|
518
|
+
.toMatchObject({ display_name: "李四", position: "经理", external_status: 1 });
|
|
519
|
+
expect(dbo.prepare("SELECT dept_id, order_no, is_leader, is_main FROM sys_dept_member WHERE user_id = ?").get(uidOf("wecom:lisi")))
|
|
520
|
+
.toMatchObject({ dept_id: d1, order_no: 5, is_leader: 1, is_main: 1 });
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it("update_user + NewUserID:同一行改名(不新建)+ 吊销旧会话", async () => {
|
|
524
|
+
await authorized();
|
|
525
|
+
const uid = mkUser("wecom:zhangsan", { activated: 1 });
|
|
526
|
+
dbo.prepare("INSERT INTO sys_refresh_token (token_hash, user_id, expires_at, revoked, created_at) VALUES ('h1', ?, 0, 0, 0)").run(uid);
|
|
527
|
+
|
|
528
|
+
await handleSuiteEvent(appEvent({ ChangeType: "update_user", UserID: "zhangsan", NewUserID: "zhangsan2", Name: "张三" }), deps(clientOf()));
|
|
529
|
+
|
|
530
|
+
expect(uidOf("wecom:zhangsan")).toBeUndefined();
|
|
531
|
+
// 同一行:id 不变、activated 保持(登录过就是登录过)
|
|
532
|
+
expect(dbo.prepare("SELECT id, display_name, activated FROM users WHERE username = 'wecom:zhangsan2'").get())
|
|
533
|
+
.toMatchObject({ id: uid, display_name: "张三", activated: 1 });
|
|
534
|
+
expect(dbo.prepare("SELECT COUNT(*) AS n FROM sys_refresh_token WHERE user_id = ?").get(uid)).toMatchObject({ n: 0 });
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it("delete_user:置 external_status=5 并摘掉部门关联,但**不删账号**", async () => {
|
|
538
|
+
await authorized();
|
|
539
|
+
const d1 = await wecomDept("1");
|
|
540
|
+
const uid = mkUser("wecom:zhangsan", { displayName: "张三", activated: 1 });
|
|
541
|
+
dbo.prepare("INSERT INTO sys_dept_member (tenant_id, user_id, dept_id, order_no, is_leader, is_main, update_time) VALUES ('default', ?, ?, 0, 0, 1, 0)").run(uid, d1);
|
|
542
|
+
dbo.prepare("INSERT INTO sys_refresh_token (token_hash, user_id, expires_at, revoked, created_at) VALUES ('h2', ?, 0, 0, 0)").run(uid);
|
|
543
|
+
|
|
544
|
+
await handleSuiteEvent(appEvent({ ChangeType: "delete_user", UserID: "zhangsan" }), deps(clientOf()));
|
|
545
|
+
|
|
546
|
+
// 账号留着(角色、历史操作、文件归属都挂在 users.id 上);只摘组织关系与外部状态
|
|
547
|
+
expect(dbo.prepare("SELECT display_name, external_status, is_forbidden FROM users WHERE id = ?").get(uid))
|
|
548
|
+
.toMatchObject({ display_name: "张三", external_status: 5, is_forbidden: 0 });
|
|
549
|
+
expect(dbo.prepare("SELECT COUNT(*) AS n FROM sys_dept_member WHERE user_id = ?").get(uid)).toMatchObject({ n: 0 });
|
|
550
|
+
expect(dbo.prepare("SELECT COUNT(*) AS n FROM sys_refresh_token WHERE user_id = ?").get(uid)).toMatchObject({ n: 0 });
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it("create_party / update_party:落库并解析出本地父 id(事件给的是企微部门 id)", async () => {
|
|
554
|
+
await authorized();
|
|
555
|
+
const parent = await wecomDept("1");
|
|
556
|
+
await handleSuiteEvent(appEvent({ ChangeType: "create_party", Id: 2, Name: "研发部", ParentId: "1" }), deps(clientOf()));
|
|
557
|
+
|
|
558
|
+
expect(dbo.prepare("SELECT origin, origin_id, name, parent_id, deleted FROM sys_department WHERE origin_id = '2'").get())
|
|
559
|
+
.toMatchObject({ origin: "wecom", origin_id: "2", name: "研发部", parent_id: parent, deleted: 0 });
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it("delete_party:deleted=1 并**级联到整棵子树**(企微只报被删的那一个 id)", async () => {
|
|
563
|
+
await authorized();
|
|
564
|
+
const root = await wecomDept("1");
|
|
565
|
+
const mid = await wecomDept("2", "子部门", root);
|
|
566
|
+
const leaf = await wecomDept("3", "孙部门", mid);
|
|
567
|
+
|
|
568
|
+
await handleSuiteEvent(appEvent({ ChangeType: "delete_party", Id: 1 }), deps(clientOf()));
|
|
569
|
+
|
|
570
|
+
// 子树一并标记:否则它们会挂在一个已删父节点下,列表过滤后变成孤儿
|
|
571
|
+
for (const id of [root, mid, leaf])
|
|
572
|
+
expect(dbo.prepare("SELECT deleted, sync_visible FROM sys_department WHERE id = ?").get(id)).toMatchObject({ deleted: 1, sync_visible: 0 });
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it("回源与事件都没有名字:不建无名部门,也不抛(事件是加速器,失败等全量兜底)", async () => {
|
|
576
|
+
await authorized();
|
|
577
|
+
await expect(handleSuiteEvent(suiteEvent({ ChangeType: "create_party", Id: 9 }), deps(clientOf())))
|
|
578
|
+
.resolves
|
|
579
|
+
.toBe("create_party");
|
|
580
|
+
expect(dbo.prepare("SELECT COUNT(*) AS n FROM sys_department").get()).toMatchObject({ n: 0 });
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
it("不认识的 ChangeType(标签变更等):不写任何东西,也不报错", async () => {
|
|
584
|
+
await authorized();
|
|
585
|
+
await expect(handleSuiteEvent(appEvent({ ChangeType: "update_tag", TagId: 4 }), deps(clientOf())))
|
|
586
|
+
.resolves
|
|
587
|
+
.toBe("update_tag");
|
|
588
|
+
expect(dbo.prepare("SELECT COUNT(*) AS n FROM users").get()).toMatchObject({ n: 0 });
|
|
589
|
+
expect(dbo.prepare("SELECT COUNT(*) AS n FROM sys_department").get()).toMatchObject({ n: 0 });
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
it("未授权企业的通讯录事件:照旧回事件名,不抛、不写(重推三次也补不回来)", async () => {
|
|
593
|
+
await expect(handleSuiteEvent(appEvent({ ChangeType: "create_user", UserID: "zhangsan", Name: "张三" }), deps(clientOf())))
|
|
594
|
+
.resolves
|
|
595
|
+
.toBe("create_user");
|
|
596
|
+
expect(dbo.prepare("SELECT COUNT(*) AS n FROM users").get()).toMatchObject({ n: 0 });
|
|
597
|
+
});
|
|
598
|
+
});
|