@oj-module/cli 0.2.43 → 0.2.46
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 +421 -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 +176 -0
- package/templates/src/auth/api/_shared/wecom/crypt.ts +317 -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 +246 -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 +701 -0
- package/templates/tests/wecom-crypt.spec.ts +155 -0
- package/templates/tests/wecom-endpoints.spec.ts +468 -0
- package/templates/tests/wecom-landing.spec.ts +54 -0
- package/vendor/host-versions.json +3 -3
|
@@ -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
|
+
});
|