@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,133 @@
|
|
|
1
|
+
// BDD:多租户地基的运行时语义(计划 §5.7 第 2/3/4/6 项)。
|
|
2
|
+
//
|
|
3
|
+
// 这一层此前只能在真 oj 上验证:夹具的构造器垫片不模拟租户注入。夹具补上之后(见
|
|
4
|
+
// tests/fixture.ts 头注释),四件事才能在 L2 钉住:
|
|
5
|
+
// 3 构造器 insert 自动落当前租户(业务代码不该自己写 tenant_id)
|
|
6
|
+
// 6 读写自动按当前租户过滤(跨租户 update/delete 落 0 行,refresh 查不到别家行)
|
|
7
|
+
// 4 同一 username 在不同租户各一行(UNIQUE(tenant_id, username))
|
|
8
|
+
// 2 裸 SQL 触碰租户表却不写 tenant_id → 拒(`sql_guard: deny` 的近似;样例在 guard.spec.ts)
|
|
9
|
+
//
|
|
10
|
+
// 为什么不测业务 handler:那在 tenant-forgery.spec.ts(伪造头的端点级拦截)。本文件只钉地基——
|
|
11
|
+
// 地基错了,上层所有「读不到别家数据」的断言都可能是假绿。
|
|
12
|
+
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
13
|
+
|
|
14
|
+
import { DEFAULT_TENANT, installDb } from "./fixture";
|
|
15
|
+
|
|
16
|
+
const g = globalThis as any;
|
|
17
|
+
let dbo: ReturnType<typeof installDb>;
|
|
18
|
+
|
|
19
|
+
/** 模拟一次请求带 `X-TENANT-ID`(oj 侧由构造器读同一个值) */
|
|
20
|
+
function asTenant(tenant?: string): void {
|
|
21
|
+
g.http = tenant ? { tenantId: tenant } : {};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function rows(sql: string, params: unknown[] = []): Array<Record<string, any>> {
|
|
25
|
+
return dbo.prepare(sql).all(...params) as Array<Record<string, any>>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
beforeAll(() => {
|
|
29
|
+
dbo = installDb();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
asTenant();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("§5.7-3 insert 自动落当前租户", () => {
|
|
37
|
+
it("构造器不写 tenant_id 也落到当前租户", async () => {
|
|
38
|
+
asTenant("acme");
|
|
39
|
+
await db.table("users").insert({ username: "iso-alice", password_hash: "x" }).run();
|
|
40
|
+
expect(rows("SELECT tenant_id FROM users WHERE username = 'iso-alice'")[0]!.tenant_id).toBe("acme");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("无请求上下文时落部署默认租户(运维脚本/迁移的形态)", async () => {
|
|
44
|
+
asTenant();
|
|
45
|
+
await db.table("users").insert({ username: "iso-bob", password_hash: "x" }).run();
|
|
46
|
+
expect(rows("SELECT tenant_id FROM users WHERE username = 'iso-bob'")[0]!.tenant_id).toBe(DEFAULT_TENANT);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("显式给了 tenant_id 就按给的落,不被当前租户覆盖", async () => {
|
|
50
|
+
asTenant("acme");
|
|
51
|
+
await db.table("users").insert({ username: "iso-carol", password_hash: "x", tenant_id: "globex" }).run();
|
|
52
|
+
expect(rows("SELECT tenant_id FROM users WHERE username = 'iso-carol'")[0]!.tenant_id).toBe("globex");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("§5.7-6 读写自动按当前租户过滤", () => {
|
|
57
|
+
it("select 只看得到本租户的行", async () => {
|
|
58
|
+
asTenant("globex");
|
|
59
|
+
const names = (await db.table("users").all() as Array<Record<string, unknown>>).map(u => String(u.username));
|
|
60
|
+
expect(names).not.toContain("iso-alice");
|
|
61
|
+
expect(names).toContain("iso-carol"); // 前一个用例显式落在 globex 的那行
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("update 跨租户落 0 行,目标行不被改", async () => {
|
|
65
|
+
const changed = await db.table("users")
|
|
66
|
+
.where({ field: "username", op: "eq", value: "iso-alice" })
|
|
67
|
+
.update({ email: "hijacked@evil" })
|
|
68
|
+
.run();
|
|
69
|
+
expect(changed).toBe(0);
|
|
70
|
+
expect(rows("SELECT email FROM users WHERE username = 'iso-alice'")[0]!.email).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("delete 跨租户落 0 行,目标行仍在", async () => {
|
|
74
|
+
const deleted = await db.table("users")
|
|
75
|
+
.where({ field: "username", op: "eq", value: "iso-alice" })
|
|
76
|
+
.delete()
|
|
77
|
+
.run();
|
|
78
|
+
expect(deleted).toBe(0);
|
|
79
|
+
expect(rows("SELECT id FROM users WHERE username = 'iso-alice'")).toHaveLength(1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("truncate 只清本租户(A 组表)", async () => {
|
|
83
|
+
asTenant("acme");
|
|
84
|
+
await db.table("todos").insert({ title: "acme-todo" }).run();
|
|
85
|
+
asTenant("globex");
|
|
86
|
+
await db.table("todos").insert({ title: "globex-todo" }).run();
|
|
87
|
+
|
|
88
|
+
asTenant("acme");
|
|
89
|
+
await db.table("todos").truncate().run();
|
|
90
|
+
expect(rows("SELECT title FROM todos")).toEqual([{ title: "globex-todo" }]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("refresh token 换票:别家的 token_hash 在本租户查不到", async () => {
|
|
94
|
+
asTenant("acme");
|
|
95
|
+
await db.table("sys_refresh_token").insert({
|
|
96
|
+
token_hash: "iso-h1",
|
|
97
|
+
user_id: 1,
|
|
98
|
+
expires_at: 9_999_999_999,
|
|
99
|
+
created_at: 0,
|
|
100
|
+
}).run();
|
|
101
|
+
|
|
102
|
+
asTenant("globex");
|
|
103
|
+
const other = await db.table("sys_refresh_token")
|
|
104
|
+
.where({ field: "token_hash", op: "eq", value: "iso-h1" })
|
|
105
|
+
.all();
|
|
106
|
+
expect(other).toHaveLength(0);
|
|
107
|
+
|
|
108
|
+
asTenant("acme");
|
|
109
|
+
const own = await db.table("sys_refresh_token")
|
|
110
|
+
.where({ field: "token_hash", op: "eq", value: "iso-h1" })
|
|
111
|
+
.all();
|
|
112
|
+
expect(own).toHaveLength(1);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("§5.7-4 同一 username 在不同租户各一行", () => {
|
|
117
|
+
it("唯一约束收敛为 (tenant_id, username):同工号两家企业互不冲突", async () => {
|
|
118
|
+
asTenant("acme");
|
|
119
|
+
await db.table("users").insert({ username: "wecom:zhangsan", password_hash: "x" }).run();
|
|
120
|
+
asTenant("globex");
|
|
121
|
+
await db.table("users").insert({ username: "wecom:zhangsan", password_hash: "x" }).run();
|
|
122
|
+
|
|
123
|
+
expect(rows("SELECT tenant_id FROM users WHERE username = 'wecom:zhangsan' ORDER BY tenant_id"))
|
|
124
|
+
.toEqual([{ tenant_id: "acme" }, { tenant_id: "globex" }]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("同租户内重复用户名仍被唯一约束挡住", async () => {
|
|
128
|
+
asTenant("acme");
|
|
129
|
+
await expect(
|
|
130
|
+
db.table("users").insert({ username: "wecom:zhangsan", password_hash: "x" }).run(),
|
|
131
|
+
).rejects.toThrow(/UNIQUE/i);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// BDD:外来号命名空间抢占(B6 红线)在**改名路径**上的延伸。
|
|
2
|
+
//
|
|
3
|
+
// 背景:外部身份(ldap/sso/wecom)的本地用户名是 `ldap:<uid>` / `sso:<sub>` / `wecom:<userid>`
|
|
4
|
+
// 这种带前缀的形式,JIT 建档时按用户名反查——查到就复用,查不到才新建。于是「用户名」本身
|
|
5
|
+
// 就是外部身份的认领凭据。
|
|
6
|
+
//
|
|
7
|
+
// 攻击:我从本地建号起家(比如 `mario`),自助改名为 `wecom:boss`。此时企微侧那位
|
|
8
|
+
// `boss` 还没登录过,库里没有真行,于是我的行成了唯一的匹配者——他第一次扫码登录时
|
|
9
|
+
// `ensureWecomUser` 查到的就是**我的 uid**,服务端给真正的 boss 签发了我的会话,
|
|
10
|
+
// 我的角色、我的数据、我的操作日志,全部对他敞开。
|
|
11
|
+
//
|
|
12
|
+
// 建号侧早挡了这条红线(rbac-users.spec.ts「B6:建号加固」),但改名侧漏了——同一条红线
|
|
13
|
+
// 必须钉在所有写 `users.username` 的入口上,本文件就是把「漏了会怎样」钉死:
|
|
14
|
+
// · 自助改名拒 `wecom:` 前缀(401/403 之后的第三道:不给你改);
|
|
15
|
+
// · 对照组:普通改名照旧放行(红线不能误伤正常用户);
|
|
16
|
+
// · 端到端:改名被拒之后,真正的外部成员 JIT 登录拿到的是**新建**的那一行。
|
|
17
|
+
|
|
18
|
+
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
19
|
+
import { invalidateAllUsers } from "../src/_platform/api/_shared/service/infra/access";
|
|
20
|
+
import { ensureWecomUser, externalUsername } from "../src/auth/api/_shared/wecom/login";
|
|
21
|
+
import profile from "../src/personal-center/api/profile/api";
|
|
22
|
+
import { installDb } from "./fixture";
|
|
23
|
+
|
|
24
|
+
const g = globalThis as any;
|
|
25
|
+
let dbo: ReturnType<typeof installDb>;
|
|
26
|
+
|
|
27
|
+
interface Captured { status: number | null, body: any }
|
|
28
|
+
|
|
29
|
+
let out: Captured;
|
|
30
|
+
|
|
31
|
+
function installJson(): void {
|
|
32
|
+
out = { status: null, body: null };
|
|
33
|
+
g.json = {
|
|
34
|
+
ok: (data?: unknown) => {
|
|
35
|
+
out.status = 200;
|
|
36
|
+
out.body = data;
|
|
37
|
+
},
|
|
38
|
+
fail: (code: number, msg: string) => {
|
|
39
|
+
out.status = code;
|
|
40
|
+
out.body = { code, msg };
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function mkUser(username: string): number {
|
|
46
|
+
dbo.prepare("INSERT INTO users (username, password_hash) VALUES (?, 'x')").run(username);
|
|
47
|
+
return (dbo.prepare("SELECT id FROM users WHERE username = ?").get(username) as { id: number }).id;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function usernameOf(uid: number): string {
|
|
51
|
+
const row = dbo.prepare("SELECT username FROM users WHERE id = ?").get(uid) as { username: string } | undefined;
|
|
52
|
+
return row ? String(row.username) : "";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
beforeAll(() => {
|
|
56
|
+
dbo = installDb();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
installJson();
|
|
61
|
+
invalidateAllUsers();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
g.http = {};
|
|
66
|
+
dbo.exec("DELETE FROM sys_user_role");
|
|
67
|
+
dbo.exec("DELETE FROM users WHERE username LIKE 'u_%' OR username LIKE 'wecom:%' OR username LIKE 'mario%'");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("自助改名不得抢占外部身份命名空间", () => {
|
|
71
|
+
it("改名为 wecom:boss → 400,且自己的用户名原样不动", async () => {
|
|
72
|
+
const uid = mkUser("u_mario");
|
|
73
|
+
g.http = { user: { id: uid }, body: { username: "wecom:boss" } };
|
|
74
|
+
|
|
75
|
+
await profile.post();
|
|
76
|
+
|
|
77
|
+
expect(out.status).toBe(400);
|
|
78
|
+
expect(String(out.body.msg)).toMatch(/reserved/i);
|
|
79
|
+
expect(usernameOf(uid)).toBe("u_mario");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("三个前缀全覆盖(ldap:/sso:/wecom:),大小写不敏感", async () => {
|
|
83
|
+
const uid = mkUser("u_every");
|
|
84
|
+
for (const name of ["ldap:alice", "sso:bob", "wecom:carol", "WeCom:Dave"]) {
|
|
85
|
+
g.http = { user: { id: uid }, body: { username: name } };
|
|
86
|
+
await profile.post();
|
|
87
|
+
expect(out.status, name).toBe(400);
|
|
88
|
+
}
|
|
89
|
+
expect(usernameOf(uid)).toBe("u_every");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("对照组:普通改名照旧放行", async () => {
|
|
93
|
+
const uid = mkUser("u_plain");
|
|
94
|
+
g.http = { user: { id: uid }, body: { username: "u_renamed" } };
|
|
95
|
+
|
|
96
|
+
await profile.post();
|
|
97
|
+
|
|
98
|
+
expect(out.status).toBe(200);
|
|
99
|
+
expect(usernameOf(uid)).toBe("u_renamed");
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("端到端:抢占不成,真正的外部成员拿到的是自己那一行", () => {
|
|
104
|
+
it("wecom:boss 首次 JIT 登录 → 新建独立 uid,不是抢名者的 uid", async () => {
|
|
105
|
+
const attackerUid = mkUser("u_squatter");
|
|
106
|
+
g.http = { user: { id: attackerUid }, body: { username: externalUsername("boss") } };
|
|
107
|
+
await profile.post();
|
|
108
|
+
expect(out.status).toBe(400); // 抢占被拒是前提
|
|
109
|
+
|
|
110
|
+
g.http = {};
|
|
111
|
+
const { uid } = await ensureWecomUser("ww_corp", "boss");
|
|
112
|
+
|
|
113
|
+
expect(uid).not.toBe(attackerUid);
|
|
114
|
+
expect(usernameOf(uid)).toBe("wecom:boss");
|
|
115
|
+
// 幂等:boss 再登一次仍是同一行
|
|
116
|
+
expect((await ensureWecomUser("ww_corp", "boss")).uid).toBe(uid);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
// BDD:代开发后台(服务商运营面)——清单 / 启停 / 开通入口 + 三个端点的接线。
|
|
2
|
+
//
|
|
3
|
+
// 这里钉的是「运营动作的正确边界」,不是企微协议(协议在 wecom-connector.spec.ts):
|
|
4
|
+
// 1. 清单**不带** permanent_code(secret 不出库:连 superadmin 也不给看);
|
|
5
|
+
// 2. 停用**一个应用 ≠ 停用一家企业**(同企业测试/正式两应用,停测试不得把正式用户踢下线);
|
|
6
|
+
// 3. 停用到该企业最后一个启用应用时才吊销会话,且只吊销**该租户**的;
|
|
7
|
+
// 4. 判权缓存随吊销立即失效(撤销即强退,D10)——用「删角色后 stale 仍放行 → 停用后立刻 403」反证;
|
|
8
|
+
// 5. 端点仅 superadmin(admin 属于某个租户,不该看别家 corp_id)。
|
|
9
|
+
//
|
|
10
|
+
// 夹具即真实库(真迁移 + 真 sqlite),全局 json/http/vars/kv/crypto/fetch 按 oj 同名同面桩掉。
|
|
11
|
+
/* eslint-disable test/prefer-lowercase-title -- AC 编号是验收锚点,保留大写 */
|
|
12
|
+
import { Buffer } from "node:buffer";
|
|
13
|
+
import { webcrypto } from "node:crypto";
|
|
14
|
+
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
15
|
+
import { assertSuperAdmin, invalidateAllUsers } from "../src/_platform/api/_shared/service/infra/access";
|
|
16
|
+
import { listApps, setAppStatus } from "../src/auth/api/_shared/wecom/admin";
|
|
17
|
+
import { createWecomClient, runtimeCache, runtimeHttp } from "../src/auth/api/_shared/wecom/client";
|
|
18
|
+
import { installUrlOf, readWecomConfig, siteRootOf } from "../src/auth/api/_shared/wecom/config";
|
|
19
|
+
import { installDb } from "./fixture";
|
|
20
|
+
|
|
21
|
+
const g = globalThis as any;
|
|
22
|
+
let dbo: ReturnType<typeof installDb>;
|
|
23
|
+
|
|
24
|
+
const SUITE = "suite_1";
|
|
25
|
+
const AES_KEY_B64 = Buffer.from(webcrypto.getRandomValues(new Uint8Array(32))).toString("base64");
|
|
26
|
+
const NOW = 1_700_000_000;
|
|
27
|
+
|
|
28
|
+
interface Captured { status: number | null, body: any }
|
|
29
|
+
|
|
30
|
+
let out: Captured;
|
|
31
|
+
|
|
32
|
+
function installJson(): void {
|
|
33
|
+
out = { status: null, body: null };
|
|
34
|
+
g.json = {
|
|
35
|
+
ok: (data?: unknown) => {
|
|
36
|
+
out.status = 200;
|
|
37
|
+
out.body = data;
|
|
38
|
+
},
|
|
39
|
+
fail: (code: number, msg: string, data?: unknown) => {
|
|
40
|
+
out.status = code;
|
|
41
|
+
out.body = data === undefined ? { code, msg } : { code, msg, data };
|
|
42
|
+
},
|
|
43
|
+
header: () => {},
|
|
44
|
+
raw: () => {},
|
|
45
|
+
redirect: Object.assign(() => {}, { found: () => {}, seeOther: () => {} }),
|
|
46
|
+
stream: () => ({ write: () => {}, end: () => {} }),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function installVars(overrides: Record<string, string> = {}): void {
|
|
51
|
+
const base: Record<string, string> = {
|
|
52
|
+
wecom_suite_id: SUITE,
|
|
53
|
+
wecom_suite_secret: "suite_secret",
|
|
54
|
+
wecom_callback_token: "cb_token",
|
|
55
|
+
wecom_encoding_aes_key: AES_KEY_B64.replace(/=$/, ""),
|
|
56
|
+
wecom_enc_key: "0123456789abcdef0123456789abcdef",
|
|
57
|
+
wecom_base_url: "https://site.example.com/api/auth/wecom",
|
|
58
|
+
...overrides,
|
|
59
|
+
};
|
|
60
|
+
g.vars = { get: (name: string) => base[name] ?? null };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 每次换新 Map:suite_token 有 kv 缓存,跨用例复用会把「该打的上游」吞掉 */
|
|
64
|
+
function installKv(): void {
|
|
65
|
+
const store = new Map<string, string>();
|
|
66
|
+
g.kv = {
|
|
67
|
+
get: async (k: string) => store.get(k) ?? null,
|
|
68
|
+
set: async (k: string, v: string) => {
|
|
69
|
+
store.set(k, v);
|
|
70
|
+
},
|
|
71
|
+
del: async (k: string) => {
|
|
72
|
+
store.delete(k);
|
|
73
|
+
},
|
|
74
|
+
expire: async () => 1,
|
|
75
|
+
incr: async () => 1,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function installCrypto(): void {
|
|
80
|
+
Object.defineProperty(globalThis, "crypto", {
|
|
81
|
+
value: {
|
|
82
|
+
randomHex: (n = 32) => `r${"a".repeat(n * 2)}`,
|
|
83
|
+
sha256Hex: (s: string) => `sha(${s})`,
|
|
84
|
+
aesGcmEncrypt: (plain: string) => `gcm(${plain})`,
|
|
85
|
+
aesGcmDecrypt: (blob: string) => blob.replace(/^gcm\((.*)\)$/, "$1"),
|
|
86
|
+
},
|
|
87
|
+
configurable: true,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function request(opts: { query?: Record<string, string>, body?: unknown, user?: { id: number } } = {}): void {
|
|
92
|
+
g.http = { method: "GET", query: opts.query ?? {}, body: opts.body ?? null, headers: {}, ...(opts.user ? { user: opts.user } : {}) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function seedCorp(corpId = "ww_corp_a", agentId = "100001", env = "prod", status = "active", authCorpId = corpId): void {
|
|
96
|
+
dbo.prepare("INSERT INTO wecom_app_profile (suite_id, corp_id, agent_id, auth_corp_id, env, corp_name, status, permanent_code_enc, installed_at, update_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
|
97
|
+
.run(SUITE, corpId, agentId, authCorpId, env, `企业 ${corpId}`, status, `gcm(perm_${corpId}_${agentId})`, NOW, NOW);
|
|
98
|
+
}
|
|
99
|
+
|
|
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
|
+
function mkUser(username: string, roles: string[] = []): number {
|
|
105
|
+
dbo.prepare("INSERT INTO users (username, password_hash) VALUES (?, 'x')").run(username);
|
|
106
|
+
const id = (dbo.prepare("SELECT id FROM users WHERE username = ?").get(username) as { id: number }).id;
|
|
107
|
+
for (const r of roles)
|
|
108
|
+
dbo.prepare("INSERT OR IGNORE INTO sys_user_role (user_id, role_name) VALUES (?, ?)").run(id, r);
|
|
109
|
+
return id;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 只关心 invalidateCorpToken 是否被调(其余成员由类型收窄成 unknown) */
|
|
113
|
+
function fakeClient(): { client: any, invalidated: string[] } {
|
|
114
|
+
const invalidated: string[] = [];
|
|
115
|
+
return {
|
|
116
|
+
client: {
|
|
117
|
+
invalidateCorpToken: async (corpId: string, agentId: string) => {
|
|
118
|
+
invalidated.push(`${corpId}:${agentId}`);
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
invalidated,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const deps = { store: { encrypt: (p: string) => `gcm(${p})`, decrypt: (b: string) => b, now: () => NOW } };
|
|
126
|
+
|
|
127
|
+
/** createInstall 的薄包装:store.now 固定,避免用例依赖真实时钟 */
|
|
128
|
+
async function createInstallForTest(cfg: ReturnType<typeof readWecomConfig>, client: ReturnType<typeof createWecomClient>, state: string) {
|
|
129
|
+
const { createInstall } = await import("../src/auth/api/_shared/wecom/admin");
|
|
130
|
+
return createInstall(cfg, client, deps.store, state);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const api = async () => (await import("../src/auth/api/wecom/admin/api")).default;
|
|
134
|
+
|
|
135
|
+
beforeAll(() => {
|
|
136
|
+
dbo = installDb();
|
|
137
|
+
g.log = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} };
|
|
138
|
+
g.db.asTenant = () => {
|
|
139
|
+
throw new Error("管理面不应调用 asTenant(请求已带租户头)");
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
beforeEach(() => {
|
|
144
|
+
installJson();
|
|
145
|
+
installVars();
|
|
146
|
+
installKv();
|
|
147
|
+
installCrypto();
|
|
148
|
+
g.fetch = async () => ({ ok: true, json: async () => ({}) });
|
|
149
|
+
for (const t of ["wecom_app_profile", "wecom_suite_ticket", "sys_refresh_token", "sys_oplog", "sys_user_role"])
|
|
150
|
+
dbo.exec(`DELETE FROM ${t}`);
|
|
151
|
+
invalidateAllUsers();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
afterEach(() => {
|
|
155
|
+
g.http = {};
|
|
156
|
+
invalidateAllUsers();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("P3-A:清单(listApps)", () => {
|
|
160
|
+
it("不返回 permanent_code(secret 不出库,superadmin 也不给看)", async () => {
|
|
161
|
+
seedCorp();
|
|
162
|
+
const rows = await listApps();
|
|
163
|
+
expect(rows).toHaveLength(1);
|
|
164
|
+
expect(Object.keys(rows[0]!)).not.toContain("permanent_code_enc");
|
|
165
|
+
expect(JSON.stringify(rows)).not.toContain("perm_ww_corp_a");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("稳定序:先企业再应用,同企业测试/正式两行相邻", async () => {
|
|
169
|
+
seedCorp("ww_b", "100002");
|
|
170
|
+
seedCorp("ww_a", "100009", "prod");
|
|
171
|
+
seedCorp("ww_a", "100001", "test");
|
|
172
|
+
const rows = await listApps();
|
|
173
|
+
expect(rows.map(r => `${r.corp_id}:${r.agent_id}`)).toEqual(["ww_a:100001", "ww_a:100009", "ww_b:100002"]);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("空表 → [](不是报错)", async () => {
|
|
177
|
+
await expect(listApps()).resolves.toEqual([]);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("P3-B:启停(setAppStatus)", () => {
|
|
182
|
+
it("企业代码/应用不匹配 → 404,且不改任何行", async () => {
|
|
183
|
+
seedCorp("ww_a", "100001");
|
|
184
|
+
const { client } = fakeClient();
|
|
185
|
+
await expect(setAppStatus("ww_a", "999999", "disabled", { client, ...deps }))
|
|
186
|
+
.rejects
|
|
187
|
+
.toMatchObject({ status: 404 });
|
|
188
|
+
expect(dbo.prepare("SELECT status FROM wecom_app_profile").get()).toMatchObject({ status: "active" });
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("停用同企业两个应用中的一个 → 不吊销会话(否则停测试应用会把正式用户踢下线)", async () => {
|
|
192
|
+
seedCorp("ww_a", "100001", "test");
|
|
193
|
+
seedCorp("ww_a", "100002", "prod");
|
|
194
|
+
dbo.prepare("INSERT INTO sys_refresh_token (token_hash, tenant_id, user_id, expires_at, revoked, created_at) VALUES ('h1', 'ww_a', 1, ?, 0, ?)").run(NOW + 100, NOW);
|
|
195
|
+
const { client, invalidated } = fakeClient();
|
|
196
|
+
|
|
197
|
+
const r = await setAppStatus("ww_a", "100001", "disabled", { client, ...deps });
|
|
198
|
+
expect(r).toEqual({ updated: 1, revokedSessions: 0 });
|
|
199
|
+
expect(dbo.prepare("SELECT COUNT(*) AS n FROM sys_refresh_token WHERE tenant_id = 'ww_a'").get()).toMatchObject({ n: 1 });
|
|
200
|
+
// secret 仍可能在另一状态被缓存:无论启停都先让企业 token 失效
|
|
201
|
+
expect(invalidated).toEqual(["ww_a:100001"]);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("停用该企业最后一个启用应用 → 只吊销该租户的会话", async () => {
|
|
205
|
+
seedCorp("ww_a", "100001");
|
|
206
|
+
seedCorp("ww_b", "100002");
|
|
207
|
+
dbo.prepare("INSERT INTO sys_refresh_token (token_hash, tenant_id, user_id, expires_at, revoked, created_at) VALUES ('h1', 'ww_a', 1, ?, 0, ?)").run(NOW + 100, NOW);
|
|
208
|
+
dbo.prepare("INSERT INTO sys_refresh_token (token_hash, tenant_id, user_id, expires_at, revoked, created_at) VALUES ('h2', 'ww_a', 2, ?, 0, ?)").run(NOW + 100, NOW);
|
|
209
|
+
dbo.prepare("INSERT INTO sys_refresh_token (token_hash, tenant_id, user_id, expires_at, revoked, created_at) VALUES ('h3', 'ww_b', 3, ?, 0, ?)").run(NOW + 100, NOW);
|
|
210
|
+
const { client } = fakeClient();
|
|
211
|
+
|
|
212
|
+
const r = await setAppStatus("ww_a", "100001", "disabled", { client, ...deps });
|
|
213
|
+
expect(r).toEqual({ updated: 1, revokedSessions: 2 });
|
|
214
|
+
expect(dbo.prepare("SELECT COUNT(*) AS n FROM sys_refresh_token").get()).toMatchObject({ n: 1 });
|
|
215
|
+
expect(dbo.prepare("SELECT tenant_id FROM sys_refresh_token").get()).toMatchObject({ tenant_id: "ww_b" });
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("吊销会同时清判权缓存(撤销即强退):stale 快照在停用后立刻失效", async () => {
|
|
219
|
+
const uid = mkUser("u_super", ["superadmin"]);
|
|
220
|
+
request({ user: { id: uid } });
|
|
221
|
+
await expect(assertSuperAdmin()).resolves.toBeUndefined();
|
|
222
|
+
|
|
223
|
+
// 直接删角色绕过写路径 → 判权缓存此时仍是 stale(10s TTL 内的正常残留)
|
|
224
|
+
dbo.exec("DELETE FROM sys_user_role");
|
|
225
|
+
await expect(assertSuperAdmin()).resolves.toBeUndefined();
|
|
226
|
+
|
|
227
|
+
seedCorp("ww_a", "100001");
|
|
228
|
+
const { client } = fakeClient();
|
|
229
|
+
await setAppStatus("ww_a", "100001", "disabled", { client, ...deps });
|
|
230
|
+
|
|
231
|
+
await expect(assertSuperAdmin()).rejects.toMatchObject({ status: 403 });
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("启用不动会话,但仍让企业 token 缓存失效", async () => {
|
|
235
|
+
seedCorp("ww_a", "100001", "prod", "disabled");
|
|
236
|
+
dbo.prepare("INSERT INTO sys_refresh_token (token_hash, tenant_id, user_id, expires_at, revoked, created_at) VALUES ('h1', 'ww_a', 1, ?, 0, ?)").run(NOW + 100, NOW);
|
|
237
|
+
const { client, invalidated } = fakeClient();
|
|
238
|
+
|
|
239
|
+
const r = await setAppStatus("ww_a", "100001", "active", { client, ...deps });
|
|
240
|
+
expect(r).toEqual({ updated: 1, revokedSessions: 0 });
|
|
241
|
+
expect(invalidated).toEqual(["ww_a:100001"]);
|
|
242
|
+
expect(dbo.prepare("SELECT status FROM wecom_app_profile").get()).toMatchObject({ status: "active" });
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("按 auth_corp_id(open_corpid)也能定位同一个应用", async () => {
|
|
246
|
+
seedCorp("ww_real", "100001", "prod", "active", "open_corp_x");
|
|
247
|
+
const { client } = fakeClient();
|
|
248
|
+
await expect(setAppStatus("open_corp_x", "100001", "disabled", { client, ...deps }))
|
|
249
|
+
.resolves
|
|
250
|
+
.toMatchObject({ updated: 1 });
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe("P3-C:开通入口(createInstall)", () => {
|
|
255
|
+
function stubUpstream(preAuth = "PRE_1", expiresIn = 600): string[] {
|
|
256
|
+
const calls: string[] = [];
|
|
257
|
+
g.fetch = async (url: string) => {
|
|
258
|
+
calls.push(url);
|
|
259
|
+
return {
|
|
260
|
+
ok: true,
|
|
261
|
+
json: async () => {
|
|
262
|
+
if (url.includes("get_suite_token"))
|
|
263
|
+
return { suite_access_token: "suite_tk", expires_in: 7200 };
|
|
264
|
+
if (url.includes("get_pre_auth_code"))
|
|
265
|
+
return { pre_auth_code: preAuth, expires_in: expiresIn };
|
|
266
|
+
throw new Error(`stub fetch 未预期:${url}`);
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
};
|
|
270
|
+
return calls;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
it("suite_ticket → suite_token → 预授权码 → install URL(redirect_uri 落站点根登录页)", async () => {
|
|
274
|
+
seedTicket();
|
|
275
|
+
const calls = stubUpstream();
|
|
276
|
+
const cfg = readWecomConfig();
|
|
277
|
+
const client = createWecomClient({ http: runtimeHttp(), cache: runtimeCache() });
|
|
278
|
+
|
|
279
|
+
const r = await createInstallForTest(cfg, client, "st_1");
|
|
280
|
+
expect(calls.some(u => u.includes("get_suite_token"))).toBe(true);
|
|
281
|
+
expect(calls.some(u => u.includes("get_pre_auth_code"))).toBe(true);
|
|
282
|
+
expect(r.callbackUrl).toBe("https://site.example.com/api/auth/wecom/event");
|
|
283
|
+
expect(r.expiresIn).toBe(600);
|
|
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");
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("子路径部署:redirect_uri 保留 /oj(回跳不落到宿主机根)", () => {
|
|
294
|
+
installVars({ wecom_base_url: "https://site.example.com/oj/api/auth/wecom" });
|
|
295
|
+
const cfg = readWecomConfig();
|
|
296
|
+
expect(siteRootOf(cfg)).toBe("https://site.example.com/oj");
|
|
297
|
+
const url = new URL(installUrlOf(cfg, "PRE_1", ""));
|
|
298
|
+
expect(url.searchParams.get("redirect_uri")).toBe("https://site.example.com/oj/login?wecom_installed=1");
|
|
299
|
+
// state 为空时不出现在 URL 里(不塞空串进去骗企微后台)
|
|
300
|
+
expect(url.searchParams.has("state")).toBe(false);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("缺 suite_ticket(回调未打通/已过期)→ 503,不白跑一趟上游", async () => {
|
|
304
|
+
const calls = stubUpstream();
|
|
305
|
+
const cfg = readWecomConfig();
|
|
306
|
+
const client = createWecomClient({ http: runtimeHttp(), cache: runtimeCache() });
|
|
307
|
+
await expect(createInstallForTest(cfg, client, "")).rejects.toMatchObject({ status: 503 });
|
|
308
|
+
expect(calls).toEqual([]);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("上游 errcode → 502(可重试语义,不是 500)", async () => {
|
|
312
|
+
seedTicket();
|
|
313
|
+
g.fetch = async () => ({ ok: true, json: async () => ({ errcode: 40001, errmsg: "invalid credential" }) });
|
|
314
|
+
const cfg = readWecomConfig();
|
|
315
|
+
const client = createWecomClient({ http: runtimeHttp(), cache: runtimeCache() });
|
|
316
|
+
await expect(createInstallForTest(cfg, client, "")).rejects.toMatchObject({ status: 502 });
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
describe("P3-D:端点接线(/auth/wecom/admin/*)", () => {
|
|
321
|
+
it("superadmin 才能进:admin 与未登录都 403", async () => {
|
|
322
|
+
const adminId = mkUser("u_admin", ["admin"]);
|
|
323
|
+
request({ user: { id: adminId } });
|
|
324
|
+
await (await api()).apps.get();
|
|
325
|
+
expect(out.status).toBe(403);
|
|
326
|
+
|
|
327
|
+
installJson();
|
|
328
|
+
request({});
|
|
329
|
+
await (await api()).apps.get();
|
|
330
|
+
expect(out.status).toBe(403);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("apps.get → 200 + 数组(不含 permanent_code)", async () => {
|
|
334
|
+
seedCorp();
|
|
335
|
+
const uid = mkUser("u_super2", ["superadmin"]);
|
|
336
|
+
request({ user: { id: uid } });
|
|
337
|
+
await (await api()).apps.get();
|
|
338
|
+
expect(out.status).toBe(200);
|
|
339
|
+
expect(out.body).toHaveLength(1);
|
|
340
|
+
expect(JSON.stringify(out.body)).not.toContain("perm_");
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("status.post 缺字段 / status 非法 → 400(不猜默认值)", async () => {
|
|
344
|
+
const uid = mkUser("u_super3", ["superadmin"]);
|
|
345
|
+
request({ user: { id: uid }, body: { corpId: "ww_a" } });
|
|
346
|
+
await (await api()).status.post();
|
|
347
|
+
expect(out.status).toBe(400);
|
|
348
|
+
|
|
349
|
+
installJson();
|
|
350
|
+
request({ user: { id: uid }, body: { corpId: "ww_a", agentId: "1", status: "paused" } });
|
|
351
|
+
await (await api()).status.post();
|
|
352
|
+
expect(out.status).toBe(400);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("status.post 停用 → 200 并记安全级审计(level 4)", async () => {
|
|
356
|
+
seedCorp("ww_a", "100001");
|
|
357
|
+
const uid = mkUser("u_super4", ["superadmin"]);
|
|
358
|
+
request({ user: { id: uid }, body: { corpId: "ww_a", agentId: "100001", status: "disabled" } });
|
|
359
|
+
await (await api()).status.post();
|
|
360
|
+
|
|
361
|
+
expect(out.status).toBe(200);
|
|
362
|
+
expect(out.body).toEqual({ updated: 1, revokedSessions: 0 });
|
|
363
|
+
const op = dbo.prepare("SELECT action, module, target, level, detail FROM sys_oplog ORDER BY id DESC LIMIT 1").get() as Record<string, unknown>;
|
|
364
|
+
expect(op).toMatchObject({ action: "wecom.admin.disable", module: "auth", target: "ww_a", level: 4 });
|
|
365
|
+
expect(String(op.detail)).toContain("100001");
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("status.post 未开通的企业 → 404(不是 500)", async () => {
|
|
369
|
+
const uid = mkUser("u_super5", ["superadmin"]);
|
|
370
|
+
request({ user: { id: uid }, body: { corpId: "ww_none", agentId: "1", status: "disabled" } });
|
|
371
|
+
await (await api()).status.post();
|
|
372
|
+
expect(out.status).toBe(404);
|
|
373
|
+
// 404 不该留审计(动作没发生)
|
|
374
|
+
expect(dbo.prepare("SELECT COUNT(*) AS n FROM sys_oplog").get()).toMatchObject({ n: 0 });
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("install.get 未配置部署常量 → 400 并点名缺哪几个 vars", async () => {
|
|
378
|
+
installVars({ wecom_suite_id: "", wecom_base_url: "" });
|
|
379
|
+
const uid = mkUser("u_super6", ["superadmin"]);
|
|
380
|
+
request({ user: { id: uid } });
|
|
381
|
+
await (await api()).install.get();
|
|
382
|
+
expect(out.status).toBe(400);
|
|
383
|
+
expect(out.body.msg).toMatch(/wecom_suite_id/);
|
|
384
|
+
expect(out.body.msg).toMatch(/wecom_base_url/);
|
|
385
|
+
});
|
|
386
|
+
});
|