@oj-module/cli 0.2.47 → 0.2.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/devkit/SKILL.md +1 -1
- package/devkit/framework-dev.md +2 -2
- package/devkit/manual.md +24 -14
- package/devkit/module-dev-and-cdn-playground.md +2 -2
- package/devkit/wecom-daikaifa.md +237 -14
- package/package.json +2 -2
- package/shell-dist/assets/runtime.js +2 -2
- package/shell-dist/index.html +2 -2
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/templates-tenant-scope.test.ts +7 -2
- package/src/init.ts +1 -1
- package/src/market/__tests__/bootstrap.test.ts +1 -1
- package/src/market/__tests__/setup.test.ts +7 -7
- package/src/market/bootstrap.ts +1 -1
- package/templates/README.md +1 -1
- package/templates/init.include.yaml +12 -0
- package/templates/openapi.json +2863 -549
- package/templates/src/_platform/api/_shared/service/domain/contacts.ts +7 -0
- package/templates/src/_platform/api/_shared/service/domain/org-read.ts +451 -0
- package/templates/src/_platform/api/_shared/service/domain/org.ts +566 -0
- package/templates/src/_platform/api/_shared/service/domain/users.ts +140 -2
- package/templates/src/_platform/api/_shared/service/infra/query.ts +2 -1
- package/templates/src/_platform/api/fixtures/l1-test-user.sql +1 -1
- package/templates/src/_platform/api/manifest.yaml +3 -0
- package/templates/src/_platform/api/migrations/0016__org.mysql.sql +41 -0
- package/templates/src/_platform/api/migrations/0016__org.pg.sql +40 -0
- package/templates/src/_platform/api/migrations/0016__org.sql +62 -0
- package/templates/src/_platform/api/org/departments/api.ts +51 -0
- package/templates/src/_platform/api/org/is-member/api.ts +33 -0
- package/templates/src/_platform/api/org/members/api.ts +55 -0
- package/templates/src/_platform/api/org/picker/api.ts +59 -0
- package/templates/src/_platform/api/org/resolve/api.ts +38 -0
- package/templates/src/_platform/api/org/tree/api.ts +37 -0
- package/templates/src/_platform/api/org/user/api.ts +46 -0
- package/templates/src/_platform/api/schema.yaml +48 -0
- package/templates/src/_platform/api/seed.mysql.sql +10 -5
- package/templates/src/_platform/api/seed.pg.sql +10 -5
- package/templates/src/_platform/api/seed.sql +13 -5
- package/templates/src/_platform/web/__client/api.schemas.ts +126 -0
- package/templates/src/_platform/web/__client/api.ts +170 -1
- package/templates/src/auth/api/_shared/ldap-login.ts +2 -0
- package/templates/src/auth/api/_shared/session.ts +5 -1
- package/templates/src/auth/api/_shared/wecom/admin.ts +3 -1
- package/templates/src/auth/api/_shared/wecom/client.ts +243 -31
- package/templates/src/auth/api/_shared/wecom/config.ts +12 -1
- package/templates/src/auth/api/_shared/wecom/contacts.ts +263 -0
- package/templates/src/auth/api/_shared/wecom/crypt.ts +17 -7
- package/templates/src/auth/api/_shared/wecom/events.ts +111 -26
- package/templates/src/auth/api/_shared/wecom/login.ts +152 -1
- package/templates/src/auth/api/_shared/wecom/payload.ts +116 -0
- package/templates/src/auth/api/_shared/wecom/store.ts +65 -0
- package/templates/src/auth/api/_shared/wecom/sync.ts +487 -0
- package/templates/src/auth/api/manifest.yaml +3 -0
- package/templates/src/auth/api/migrations/0002__org_sync.mysql.sql +28 -0
- package/templates/src/auth/api/migrations/0002__org_sync.pg.sql +27 -0
- package/templates/src/auth/api/migrations/0002__org_sync.sql +41 -0
- package/templates/src/auth/api/schema.yaml +31 -0
- package/templates/src/auth/api/wecom/admin/api.ts +3 -1
- package/templates/src/auth/api/wecom/api.ts +100 -18
- package/templates/src/auth/api/wecom/sync/api.ts +199 -0
- package/templates/src/auth/web/__client/api.schemas.ts +97 -0
- package/templates/src/auth/web/__client/api.ts +73 -1
- package/templates/src/notification/api/seed.sql +1 -1
- package/templates/src/system/api/departments/api.ts +142 -0
- package/templates/src/system/api/departments/detach/api.ts +34 -0
- package/templates/src/system/api/departments/members/api.ts +142 -0
- package/templates/src/system/api/departments/move/api.ts +39 -0
- package/templates/src/system/api/users/api.ts +45 -5
- package/templates/src/system/api/users/profile/api.ts +44 -0
- package/templates/src/system/web/__client/api.schemas.ts +143 -0
- package/templates/src/system/web/__client/api.ts +293 -1
- package/templates/src/system/web/entry.ts +6 -0
- package/templates/src/system/web/locales/en-US.json +112 -6
- package/templates/src/system/web/locales/zh-CN.json +112 -6
- package/templates/src/system/web/pages/departments/index.tsx +346 -0
- package/templates/src/system/web/pages/departments/sync-bar.tsx +257 -0
- package/templates/src/system/web/pages/departments/tree.ts +66 -0
- package/templates/src/system/web/pages/errors.ts +1 -1
- package/templates/src/system/web/pages/users/index.tsx +49 -7
- package/templates/src/system/web/pages/wecom/index.tsx +14 -0
- package/templates/tests/l1-builder.test.ts +1 -1
- package/templates/tests/org-departments.spec.ts +311 -0
- package/templates/tests/org-read.spec.ts +469 -0
- package/templates/tests/rbac-contacts.spec.ts +13 -3
- package/templates/tests/rbac-seed.spec.ts +11 -0
- package/templates/tests/seed-role-accounts.spec.ts +6 -2
- package/templates/tests/wecom-admin.spec.ts +1 -0
- package/templates/tests/wecom-connector.spec.ts +115 -8
- package/templates/tests/wecom-crypt.spec.ts +12 -3
- package/templates/tests/wecom-endpoints.spec.ts +182 -14
- package/templates/tests/wecom-org-sync.spec.ts +598 -0
- package/templates/tests/wecom-sync.spec.ts +286 -0
- package/vendor/host-versions.json +3 -3
- package/templates/src/auth/api/_shared/wecom/xml.ts +0 -36
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// BDD:B2 全量同步(方案 §6.2 / D9 / D13)——分片推进、错码处置、墓碑回收、归属权。
|
|
2
|
+
//
|
|
3
|
+
// 分片是这套设计的核心约束(isolate 无定时器 + 路由超时),所以用例必须钉死:
|
|
4
|
+
// · 一片处理不完就**停**(status 仍 running、游标前进、下次接着推),不是重跑也不是失败;
|
|
5
|
+
// · 权限类错码是「跳过并计数」——一旦有跳过就**不许回收**(否则一次上游抖会被读成「部门被删」);
|
|
6
|
+
// · 限流/可恢复致命错要停在原部门(游标不前进),下一次重试同一部门;
|
|
7
|
+
// · 本地建的部门/成员一根毛都不碰(D13 归属权)。
|
|
8
|
+
import type { WecomClient, WecomMember } from "../src/auth/api/_shared/wecom/client";
|
|
9
|
+
|
|
10
|
+
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
11
|
+
import { WecomApiError } from "../src/auth/api/_shared/wecom/client";
|
|
12
|
+
import { attachProfile, cancelSync, emptyView, startSync, syncStatus } from "../src/auth/api/_shared/wecom/sync";
|
|
13
|
+
import { installDb } from "./fixture";
|
|
14
|
+
|
|
15
|
+
/* eslint-disable test/prefer-lowercase-title -- D/AC 编号是验收锚点,保留大写 */
|
|
16
|
+
|
|
17
|
+
let dbo: ReturnType<typeof installDb>;
|
|
18
|
+
const g = globalThis as any;
|
|
19
|
+
|
|
20
|
+
const TENANT = "ww_corp_a";
|
|
21
|
+
const AGENT = "100001";
|
|
22
|
+
const CTX = { corpId: TENANT, agentId: AGENT, secret: "perm_a", authMode: 0 };
|
|
23
|
+
|
|
24
|
+
interface Dept { id: string, parentId: string, name: string | null, order: number }
|
|
25
|
+
|
|
26
|
+
function member(userid: string, name: string, departments: string[], opts: Partial<WecomMember> = {}): WecomMember {
|
|
27
|
+
return {
|
|
28
|
+
userid,
|
|
29
|
+
name,
|
|
30
|
+
departments,
|
|
31
|
+
orders: departments.map(() => 0),
|
|
32
|
+
leaderInDept: departments.map(() => 0),
|
|
33
|
+
mainDepartment: null,
|
|
34
|
+
position: null,
|
|
35
|
+
status: 1,
|
|
36
|
+
...opts,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 假客户端:部门清单 + 「部门 → 成员」映射;值为 Error 表示该部门拉人失败(用来喂错码)。
|
|
42
|
+
* `calls` 记录每个被拉的部门,重试语义靠它断言。
|
|
43
|
+
*/
|
|
44
|
+
function fakeClient(depts: Dept[], members: Record<string, WecomMember[] | Error>) {
|
|
45
|
+
const calls: string[] = [];
|
|
46
|
+
const client = {
|
|
47
|
+
async departmentChildren() {
|
|
48
|
+
return depts;
|
|
49
|
+
},
|
|
50
|
+
async departmentList() {
|
|
51
|
+
return depts;
|
|
52
|
+
},
|
|
53
|
+
async deptMembersDetail(_c: string, _a: string, _s: string, originId: string) {
|
|
54
|
+
calls.push(originId);
|
|
55
|
+
const hit = members[originId];
|
|
56
|
+
if (hit instanceof Error)
|
|
57
|
+
throw hit;
|
|
58
|
+
return hit ?? [];
|
|
59
|
+
},
|
|
60
|
+
} as unknown as WecomClient;
|
|
61
|
+
return { client, calls };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const deps = (client: WecomClient, budgetMs = 10_000) => ({ client, now: () => Date.now(), budgetMs });
|
|
65
|
+
|
|
66
|
+
function deptRow(originId: string): { id: number, parent_id: number | null, name: string | null, sync_visible: number } | null {
|
|
67
|
+
const row = dbo.prepare("SELECT id, parent_id, name, sync_visible FROM sys_department WHERE origin = 'wecom' AND origin_id = ?").get(originId) as any;
|
|
68
|
+
return row ? { id: Number(row.id), parent_id: row.parent_id == null ? null : Number(row.parent_id), name: row.name, sync_visible: Number(row.sync_visible) } : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function uidOf(userid: string): number | null {
|
|
72
|
+
const row = dbo.prepare("SELECT id FROM users WHERE username = ?").get(`wecom:${userid}`) as { id: number } | undefined;
|
|
73
|
+
return row ? Number(row.id) : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
beforeAll(() => {
|
|
77
|
+
dbo = installDb();
|
|
78
|
+
// 引擎里用到 crypto.* 的地方没有;但 log 是全局的,替身避免噪音
|
|
79
|
+
g.log = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} };
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
g.http = { tenantId: TENANT, user: { id: 1 } };
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
afterEach(() => {
|
|
87
|
+
g.http = {};
|
|
88
|
+
for (const t of ["wecom_sync_job", "wecom_sync_lock", "sys_dept_member", "sys_department", "users", "sys_oplog"])
|
|
89
|
+
dbo.exec(`DELETE FROM ${t}`);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const TREE: Dept[] = [
|
|
93
|
+
{ id: "1", parentId: "", name: "总公司", order: 1 },
|
|
94
|
+
{ id: "2", parentId: "1", name: "研发部", order: 2 },
|
|
95
|
+
// 孤儿:父部门不在可见范围 → 按方案挂根,不报错
|
|
96
|
+
{ id: "3", parentId: "99", name: "外部协作组", order: 0 },
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
describe("B2-A:一轮完整同步", () => {
|
|
100
|
+
it("部门按父子序落库 + 孤儿挂根 + 成员带职务/排序/负责人/主部门", async () => {
|
|
101
|
+
const { client } = fakeClient(TREE, {
|
|
102
|
+
1: [member("zhangsan", "张三", ["1", "2"], { orders: [5, 3], leaderInDept: [1, 0], mainDepartment: 2, position: "总监" })],
|
|
103
|
+
2: [member("lisi", "李四", ["2"])],
|
|
104
|
+
3: [],
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const view = await startSync(CTX, deps(client));
|
|
108
|
+
expect(view.status).toBe("done");
|
|
109
|
+
expect(view.stats).toMatchObject({ departments: 3, members: 2, skipped: 0, reconciled: true });
|
|
110
|
+
expect(view.progress).toEqual({ done: 3, total: 3 });
|
|
111
|
+
|
|
112
|
+
const root = deptRow("1")!;
|
|
113
|
+
const child = deptRow("2")!;
|
|
114
|
+
expect(root.parent_id).toBeNull();
|
|
115
|
+
expect(child.parent_id).toBe(root.id);
|
|
116
|
+
expect(deptRow("3")!.parent_id).toBeNull();
|
|
117
|
+
expect(deptRow("2")!.name).toBe("研发部");
|
|
118
|
+
|
|
119
|
+
const zhang = uidOf("zhangsan")!;
|
|
120
|
+
expect(dbo.prepare("SELECT display_name, position, external_status, activated, source FROM users WHERE id = ?").get(zhang))
|
|
121
|
+
.toMatchObject({ display_name: "张三", position: "总监", external_status: 1, activated: 0, source: "wecom" });
|
|
122
|
+
// 多部门:两行关联,order/leader 与 department[] 逐位对应,主部门唯一
|
|
123
|
+
expect(dbo.prepare("SELECT dept_id, order_no, is_leader, is_main FROM sys_dept_member WHERE user_id = ? ORDER BY dept_id").all(zhang))
|
|
124
|
+
.toEqual([
|
|
125
|
+
{ dept_id: root.id, order_no: 5, is_leader: 1, is_main: 0 },
|
|
126
|
+
{ dept_id: child.id, order_no: 3, is_leader: 0, is_main: 1 },
|
|
127
|
+
]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("分片:预算用完就停(status 仍 running、游标前进),第二次接着推完", async () => {
|
|
131
|
+
const { client, calls } = fakeClient(TREE, { 1: [], 2: [], 3: [] });
|
|
132
|
+
const first = await startSync(CTX, deps(client, 0));
|
|
133
|
+
expect(first.status).toBe("running");
|
|
134
|
+
expect(first.phase).toBe("members");
|
|
135
|
+
expect(first.progress).toEqual({ done: 0, total: 3 });
|
|
136
|
+
// 部门阶段已经完成,成员一个都没拉
|
|
137
|
+
expect(calls).toEqual([]);
|
|
138
|
+
expect(deptRow("1")).not.toBeNull();
|
|
139
|
+
|
|
140
|
+
const second = await startSync(CTX, deps(client));
|
|
141
|
+
expect(second.status).toBe("done");
|
|
142
|
+
expect(calls.sort()).toEqual(["1", "2", "3"]);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("B2-B:错码处置(跳过 / 停片 / 熔断)", () => {
|
|
147
|
+
it("权限类(60011)跳过该部门并计数,且**不回收**(本轮不完整)", async () => {
|
|
148
|
+
// 先跑一轮完整同步,让部门 3 有 seen 记录
|
|
149
|
+
const r1 = fakeClient(TREE, { 1: [], 2: [], 3: [] });
|
|
150
|
+
await startSync(CTX, deps(r1.client));
|
|
151
|
+
|
|
152
|
+
const { client } = fakeClient(TREE, {
|
|
153
|
+
1: [],
|
|
154
|
+
2: new WecomApiError("user/list", 60011, "no privilege"),
|
|
155
|
+
3: [],
|
|
156
|
+
});
|
|
157
|
+
const view = await startSync(CTX, deps(client));
|
|
158
|
+
expect(view.status).toBe("done");
|
|
159
|
+
expect(view.stats).toMatchObject({ skipped: 1, reconciled: false });
|
|
160
|
+
// 有跳过就不敢把「没见过的部门」判成删除
|
|
161
|
+
expect(deptRow("2")!.sync_visible).toBe(1);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("限流类(45009)停在原部门:游标不前进,下一次重试同一个部门", async () => {
|
|
165
|
+
const rate = new WecomApiError("user/list", 45009, "rate limit");
|
|
166
|
+
const { client, calls } = fakeClient(TREE, { 1: rate, 2: [], 3: [] });
|
|
167
|
+
const first = await startSync(CTX, deps(client));
|
|
168
|
+
expect(first.status).toBe("running");
|
|
169
|
+
expect(first.progress.done).toBe(0);
|
|
170
|
+
|
|
171
|
+
const ok = fakeClient(TREE, { 1: [], 2: [], 3: [] });
|
|
172
|
+
const second = await startSync(CTX, deps(ok.client));
|
|
173
|
+
expect(second.status).toBe("done");
|
|
174
|
+
// 第一个部门被重试(不是被跳过)
|
|
175
|
+
expect(ok.calls[0]).toBe("1");
|
|
176
|
+
expect(calls).toEqual(["1"]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("致命类(60020)连续 10 次 → job failed,且错误明细不含异常原文(脱敏)", async () => {
|
|
180
|
+
const boom = new WecomApiError("user/list", 60020, "not allow to access from your ip: https://qyapi.weixin.qq.com?access_token=SECRET");
|
|
181
|
+
const { client } = fakeClient(TREE, { 1: boom, 2: [], 3: [] });
|
|
182
|
+
let view = await startSync(CTX, deps(client));
|
|
183
|
+
for (let i = 0; i < 9; i++) {
|
|
184
|
+
expect(view.status).toBe("running");
|
|
185
|
+
view = await startSync(CTX, deps(client));
|
|
186
|
+
}
|
|
187
|
+
expect(view.status).toBe("failed");
|
|
188
|
+
expect(view.errCode).toBe("60020");
|
|
189
|
+
expect(view.errDetail).toMatchObject({ errcode: 60020, phase: "members", deptId: "1" });
|
|
190
|
+
// 脱敏红线:明细里绝不能出现 access_token / 上游文案
|
|
191
|
+
expect(JSON.stringify(view.errDetail)).not.toMatch(/SECRET|access_token/);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("抢锁:同一企业同时只允许一个同步(租约未过期 → 409)", async () => {
|
|
195
|
+
const { client } = fakeClient(TREE, { 1: [], 2: [], 3: [] });
|
|
196
|
+
dbo.prepare("INSERT INTO wecom_sync_lock (tenant_id, agent_id, owner, taken_at) VALUES (?, ?, 'other', ?)")
|
|
197
|
+
.run(TENANT, AGENT, Math.floor(Date.now() / 1000));
|
|
198
|
+
await expect(startSync(CTX, deps(client))).rejects.toMatchObject({ status: 409 });
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("过期租约可被抢占(请求崩溃不能把企业永久锁死)", async () => {
|
|
202
|
+
const { client } = fakeClient(TREE, { 1: [], 2: [], 3: [] });
|
|
203
|
+
dbo.prepare("INSERT INTO wecom_sync_lock (tenant_id, agent_id, owner, taken_at) VALUES (?, ?, 'dead', ?)")
|
|
204
|
+
.run(TENANT, AGENT, Math.floor(Date.now() / 1000) - 600);
|
|
205
|
+
const view = await startSync(CTX, deps(client));
|
|
206
|
+
expect(view.status).toBe("done");
|
|
207
|
+
// 锁用完即释放
|
|
208
|
+
expect(dbo.prepare("SELECT COUNT(*) c FROM wecom_sync_lock").get()).toMatchObject({ c: 0 });
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe("B2-C:准入与取消", () => {
|
|
213
|
+
it("成员授权模式(auth_mode=1)直接 400,不发起任何上游调用", async () => {
|
|
214
|
+
const { client, calls } = fakeClient(TREE, { 1: [], 2: [], 3: [] });
|
|
215
|
+
await expect(startSync({ ...CTX, authMode: 1 }, deps(client))).rejects.toMatchObject({ status: 400 });
|
|
216
|
+
expect(calls).toEqual([]);
|
|
217
|
+
expect(dbo.prepare("SELECT COUNT(*) c FROM sys_department").get()).toMatchObject({ c: 0 });
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("取消:置 cancelled 并清游标;已写入的组织数据不回滚(重跑即覆盖)", async () => {
|
|
221
|
+
const { client } = fakeClient(TREE, { 1: [], 2: [], 3: [] });
|
|
222
|
+
await startSync(CTX, deps(client, 0));
|
|
223
|
+
const cancelled = await cancelSync(TENANT, AGENT, { now: () => Date.now() });
|
|
224
|
+
expect(cancelled.status).toBe("cancelled");
|
|
225
|
+
expect(cancelled.progress).toEqual({ done: 0, total: 0 });
|
|
226
|
+
expect((await syncStatus(TENANT, AGENT)).status).toBe("cancelled");
|
|
227
|
+
// 部门已落库(同步幂等,取消只清进度)
|
|
228
|
+
expect(deptRow("1")).not.toBeNull();
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe("B2-D:墓碑回收与归属权(D9/D13)", () => {
|
|
233
|
+
it("第二轮看不到某部门 → 置为不可见;再次出现 → 自动恢复(markSeen 拉回 visible)", async () => {
|
|
234
|
+
const full = fakeClient(TREE, { 1: [], 2: [], 3: [] });
|
|
235
|
+
await startSync(CTX, deps(full.client));
|
|
236
|
+
|
|
237
|
+
const shrunk = fakeClient(TREE.slice(0, 1), { 1: [], 2: [], 3: [] });
|
|
238
|
+
const v2 = await startSync(CTX, deps(shrunk.client));
|
|
239
|
+
expect(v2.stats.reconciled).toBe(true);
|
|
240
|
+
expect(deptRow("2")!.sync_visible).toBe(0);
|
|
241
|
+
expect(deptRow("1")!.sync_visible).toBe(1);
|
|
242
|
+
|
|
243
|
+
const back = fakeClient(TREE, { 1: [], 2: [], 3: [] });
|
|
244
|
+
await startSync(CTX, deps(back.client));
|
|
245
|
+
expect(deptRow("2")!.sync_visible).toBe(1);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("本地建的部门/成员不被同步碰(D13):回收只作用于 origin=wecom", async () => {
|
|
249
|
+
dbo.prepare("INSERT INTO sys_department (tenant_id, origin, origin_id, parent_id, name, order_no, sync_visible, deleted, update_time) VALUES (?, 'local', 'local:1', NULL, '手工建的部门', 0, 1, 0, 0)")
|
|
250
|
+
.run(TENANT);
|
|
251
|
+
const { client } = fakeClient(TREE, { 1: [], 2: [], 3: [] });
|
|
252
|
+
await startSync(CTX, deps(client));
|
|
253
|
+
const local = dbo.prepare("SELECT sync_visible, seen_job_id FROM sys_department WHERE origin = 'local'").get() as any;
|
|
254
|
+
expect(Number(local.sync_visible)).toBe(1);
|
|
255
|
+
expect(local.seen_job_id).toBeNull();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("部门名缺失(未授权「部门名」)落 NULL,不编造假名字", async () => {
|
|
259
|
+
const { client } = fakeClient([{ id: "1", parentId: "", name: null, order: 0 }], { 1: [] });
|
|
260
|
+
await startSync(CTX, deps(client));
|
|
261
|
+
expect(deptRow("1")!.name).toBeNull();
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe("B2-E:视图拼装(授权事实与任务状态分开算)", () => {
|
|
266
|
+
it("没授权 → connected=false:页面整条不显示,任务状态仍然是 idle", () => {
|
|
267
|
+
const view = attachProfile(emptyView("idle"), null);
|
|
268
|
+
expect(view).toMatchObject({ connected: false, authMode: 0, profileStatus: null, status: "idle" });
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("已授权 → 带上模式与应用状态(成员授权 / 已取消授权都由它驱动页面卡片)", () => {
|
|
272
|
+
const view = attachProfile(emptyView("done"), { agentId: AGENT, authMode: 1, status: "disabled" });
|
|
273
|
+
expect(view).toMatchObject({ connected: true, authMode: 1, profileStatus: "disabled", status: "done" });
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("从未同步过的企业:idle 空视图,进度与统计都归零(不能把 undefined 画成 NaN)", async () => {
|
|
277
|
+
const view = await syncStatus(TENANT, AGENT);
|
|
278
|
+
expect(view).toMatchObject({
|
|
279
|
+
status: "idle",
|
|
280
|
+
progress: { done: 0, total: 0 },
|
|
281
|
+
stats: { departments: 0, members: 0, skipped: 0, reconciled: false },
|
|
282
|
+
errCode: null,
|
|
283
|
+
errDetail: null,
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
});
|
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
"react-router": "7.18.2",
|
|
9
9
|
"react-router/dom": "7.18.2",
|
|
10
10
|
"@tanstack/react-query": "5.102.8",
|
|
11
|
-
"@oj-module/runtime": "0.2.
|
|
12
|
-
"@oj-module/runtime/contract": "0.2.
|
|
13
|
-
"@oj-module/runtime/contract/errors": "0.2.
|
|
11
|
+
"@oj-module/runtime": "0.2.48",
|
|
12
|
+
"@oj-module/runtime/contract": "0.2.48",
|
|
13
|
+
"@oj-module/runtime/contract/errors": "0.2.48",
|
|
14
14
|
"@plane/kit": "0.1.20",
|
|
15
15
|
"@plane/editor": "0.1.20",
|
|
16
16
|
"antd": "6.6.1",
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
// 企微消息 XML 取字段(只读、白名单式)。
|
|
2
|
-
// 为什么不用通用 XML 解析:明文已由 AES-CBC + msg_signature 双重认证(见 crypt.ts),
|
|
3
|
-
// 这里只从**可信明文**里取几个扁平标签,且标签名由调用方常量给出——不解析结构、不处理命名空间,
|
|
4
|
-
// 用正则换掉一个解析器依赖与它的实体展开攻击面。标签名非纯字母直接抛错(防拼接正则)。
|
|
5
|
-
// 未命中返回 null(「没这个字段」与「字段为空」由调用方区分)。
|
|
6
|
-
|
|
7
|
-
const XML_ENTITIES: Record<string, string> = {
|
|
8
|
-
amp: "&",
|
|
9
|
-
lt: "<",
|
|
10
|
-
gt: ">",
|
|
11
|
-
quot: "\"",
|
|
12
|
-
apos: "'",
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
function decodeEntities(s: string): string {
|
|
16
|
-
return s.replace(/&(amp|lt|gt|quot|apos|#\d+|#x[\da-f]+);/gi, (whole, body: string) => {
|
|
17
|
-
if (body[0] === "#") {
|
|
18
|
-
const code = body[1] === "x" || body[1] === "X"
|
|
19
|
-
? Number.parseInt(body.slice(2), 16)
|
|
20
|
-
: Number.parseInt(body.slice(1), 10);
|
|
21
|
-
return Number.isFinite(code) && code > 0 && code <= 0x10FFFF ? String.fromCodePoint(code) : whole;
|
|
22
|
-
}
|
|
23
|
-
return XML_ENTITIES[body.toLowerCase()] ?? whole;
|
|
24
|
-
});
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** 取 `<Name>值</Name>`(支持 CDATA 与实体);未命中 null */
|
|
28
|
-
export function xmlField(xml: string, name: string): string | null {
|
|
29
|
-
if (!/^[A-Z_]\w*$/i.test(name))
|
|
30
|
-
throw new Error(`xml: illegal field name ${name}`);
|
|
31
|
-
const re = new RegExp(`<${name}>(?:<!\\[CDATA\\[([\\s\\S]*?)\\]\\]>|([\\s\\S]*?))</${name}>`);
|
|
32
|
-
const m = re.exec(xml);
|
|
33
|
-
if (!m)
|
|
34
|
-
return null;
|
|
35
|
-
return decodeEntities((m[1] ?? m[2] ?? "").trim());
|
|
36
|
-
}
|