@oj-module/cli 0.2.42 → 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/__tests__/verify.test.ts +32 -1
- package/src/market/bootstrap.ts +15 -0
- package/src/market/setup.ts +3 -3
- package/src/market/verify.ts +7 -3
- package/src/market/walkthrough.ts +35 -29
- package/src/site.ts +17 -1
- package/templates/config.yaml +111 -101
- 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
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
/* eslint-disable test/prefer-lowercase-title -- AC 编号是验收锚点,保留大写 */
|
|
5
5
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
6
6
|
|
|
7
|
-
import { assertPermission, assertSuperAdmin, assertSystemAdmin, invalidateUser } from "../src/_platform/api/_shared/service/infra/access";
|
|
7
|
+
import { assertPermission, assertSuperAdmin, assertSystemAdmin, currentAccess, invalidateUser } from "../src/_platform/api/_shared/service/infra/access";
|
|
8
8
|
import { installDb } from "./fixture";
|
|
9
9
|
|
|
10
10
|
let dbo: ReturnType<typeof installDb>;
|
|
@@ -151,6 +151,23 @@ describe("禁用与未认证", () => {
|
|
|
151
151
|
asUser(undefined);
|
|
152
152
|
await expect(assertPermission("wiki.page.edit")).rejects.toMatchObject({ status: 403 });
|
|
153
153
|
});
|
|
154
|
+
|
|
155
|
+
it("伪造租户头:token 的 sub 在本租户查无此人 → 403(不能只靠「角色为空」兜底)", async () => {
|
|
156
|
+
// 租户化后 `users` 的查询自动带 `tenant_id = 当前头值`,于是「A 企业的人带 B 企业的头」
|
|
157
|
+
// 等价于「这一行查不到」。夹具不模拟租户注入(fixture.ts 头注释已声明),
|
|
158
|
+
// 故用「行不存在」直接钉住这条分支——它就是伪造头在 SQL 层的表现。
|
|
159
|
+
asUser(987_654_321);
|
|
160
|
+
await expect(currentAccess()).rejects.toMatchObject({ status: 403 });
|
|
161
|
+
// 只要求登录态的端点(不走 assertPermission)也必须被挡住
|
|
162
|
+
await expect(assertSystemAdmin()).rejects.toMatchObject({ status: 403 });
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("用户行存在才放行登录态(对照组:同一断言换个真实 uid 即通过)", async () => {
|
|
166
|
+
const uid = mkUser("u9x");
|
|
167
|
+
bindRole(uid, "superadmin");
|
|
168
|
+
asUser(uid);
|
|
169
|
+
await expect(currentAccess()).resolves.toMatchObject({ exists: true });
|
|
170
|
+
});
|
|
154
171
|
});
|
|
155
172
|
|
|
156
173
|
describe("AC1-9:系统管理员断言(D11)", () => {
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// BDD:判权快照缓存的租户边界(多租户 2026-10,`_platform/.../infra/access.ts`)。
|
|
2
|
+
//
|
|
3
|
+
// 攻击/故障形态:`access.ts` 的判权快照按 **uid 单键**缓存时,「A 企业的张三」判过权之后
|
|
4
|
+
// 同一个进程再处理「B 企业的张三」的请求——后者的 `users` 行按 B 租户查不到(构造器自动
|
|
5
|
+
// 注入 `tenant_id` 谓词),本应 403,但缓存里还留着 A 租户那张 `exists: true` 的快照,
|
|
6
|
+
// 于是**放行**。
|
|
7
|
+
//
|
|
8
|
+
// oj 目前每请求重建模块实例,缓存恒不命中,所以线上看不到;但只要有一次复用
|
|
9
|
+
// (常驻进程、或本工程自己的测试宿主)就是静默的跨租户放行——而这一层是伪造
|
|
10
|
+
// `X-TENANT-ID` 的**唯一**服务端拦截点,它一旦放行就没有第二道了。
|
|
11
|
+
// 这里把「同一 uid 换租户必须重查」钉死,顺手钉住写时失效不得漏掉别的租户。
|
|
12
|
+
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
13
|
+
|
|
14
|
+
import { currentAccess, invalidateAllUsers, invalidateUser, isSelfTenant } from "../src/_platform/api/_shared/service/infra/access";
|
|
15
|
+
import { installDb } from "./fixture";
|
|
16
|
+
|
|
17
|
+
const g = globalThis as any;
|
|
18
|
+
let dbo: ReturnType<typeof installDb>;
|
|
19
|
+
|
|
20
|
+
/** 模拟一次请求的租户头(`X-TENANT-ID` → http.tenantId)与登录态 */
|
|
21
|
+
function as(tenant: string, uid?: number): void {
|
|
22
|
+
g.http = tenant ? { tenantId: tenant, user: uid == null ? {} : { id: uid } } : { user: uid == null ? {} : { id: uid } };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 在指定租户里建一个用户(业务侧永不自己写 tenant_id,这里是造数据) */
|
|
26
|
+
function mkUser(tenant: string, username: string): number {
|
|
27
|
+
dbo.prepare("INSERT INTO users (tenant_id, username, password_hash) VALUES (?, ?, 'x')").run(tenant, username);
|
|
28
|
+
return (dbo.prepare("SELECT id FROM users WHERE username = ? AND tenant_id = ?").get(username, tenant) as { id: number }).id;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function bind(tenant: string, uid: number, role: string): void {
|
|
32
|
+
dbo.prepare("INSERT OR IGNORE INTO sys_user_role (tenant_id, user_id, role_name) VALUES (?, ?, ?)").run(tenant, uid, role);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
beforeAll(() => {
|
|
36
|
+
dbo = installDb();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
invalidateAllUsers();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
g.http = {};
|
|
45
|
+
dbo.exec("DELETE FROM sys_role_permission");
|
|
46
|
+
dbo.exec("DELETE FROM sys_user_role");
|
|
47
|
+
dbo.exec("DELETE FROM users WHERE username LIKE 'tk_%'");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("判权快照按 (租户, uid) 缓存", () => {
|
|
51
|
+
it("同一 uid 换到别家租户 → 查无此人(不吃上一家留下的快照)", async () => {
|
|
52
|
+
const uid = mkUser("acme", "tk_zhangsan");
|
|
53
|
+
as("acme", uid);
|
|
54
|
+
expect(await isSelfTenant()).toBe(true);
|
|
55
|
+
|
|
56
|
+
as("globex", uid);
|
|
57
|
+
expect(await isSelfTenant()).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("逆时针顺序同样成立(先访问别人家,回自己家不能被判没)", async () => {
|
|
61
|
+
const uid = mkUser("acme", "tk_lisi");
|
|
62
|
+
as("globex", uid);
|
|
63
|
+
expect(await isSelfTenant()).toBe(false);
|
|
64
|
+
|
|
65
|
+
as("acme", uid);
|
|
66
|
+
expect(await isSelfTenant()).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("两个租户各自的同 uid 快照互不覆盖:角色也不串台", async () => {
|
|
70
|
+
const uid = mkUser("acme", "tk_wangwu");
|
|
71
|
+
bind("acme", uid, "wiki.editor");
|
|
72
|
+
bind("globex", uid, "crm.viewer");
|
|
73
|
+
|
|
74
|
+
as("acme", uid);
|
|
75
|
+
expect((await currentAccess()).roles).toEqual(["wiki.editor"]);
|
|
76
|
+
as("globex", uid);
|
|
77
|
+
expect(await isSelfTenant()).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("写时失效覆盖该 uid 的全部租户快照", () => {
|
|
82
|
+
it("在别家上下文里 invalidateUser,回原租户也必须重查", async () => {
|
|
83
|
+
const uid = mkUser("acme", "tk_zhaoliu");
|
|
84
|
+
as("acme", uid);
|
|
85
|
+
expect((await currentAccess()).roles).toEqual([]);
|
|
86
|
+
|
|
87
|
+
// 直接改库(模拟别处写路径),此 uid 在 acme 的旧快照此刻已过期
|
|
88
|
+
bind("acme", uid, "wiki.editor");
|
|
89
|
+
as("acme", uid);
|
|
90
|
+
expect((await currentAccess()).roles, "未失效前读到旧快照(TTL 内缓存命中,符合预期)").toEqual([]);
|
|
91
|
+
|
|
92
|
+
// 失效发生在**别家**的上下文里 —— 不得只清当前租户那一张
|
|
93
|
+
as("globex", uid);
|
|
94
|
+
invalidateUser(uid);
|
|
95
|
+
|
|
96
|
+
as("acme", uid);
|
|
97
|
+
expect((await currentAccess()).roles).toEqual(["wiki.editor"]);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// BDD:伪造 `X-TENANT-ID` 的拦截面(计划 §5.7 第 1 项)。
|
|
2
|
+
//
|
|
3
|
+
// 攻击形态:攻击者持 A 企业的合法 token(JWT 只带 sub,不带租户),请求时把
|
|
4
|
+
// `X-TENANT-ID` 改成 B 企业。oj 构造器按当前头值注入 `tenant_id`,于是「A 企业的人
|
|
5
|
+
// 查 B 企业的 users」等价于**查无此行**——这是服务端唯一能看见的异常。
|
|
6
|
+
//
|
|
7
|
+
// 为什么必须单独钉这一层:`assertPermission` / `currentAccess()` 覆盖的是挂了权限点的
|
|
8
|
+
// 端点;而通知、我的资料、人员目录这些「登录即可」的端点根本不走它们。少了这一步,
|
|
9
|
+
// 伪造头的人能读到别家企业的广播通知、改别家企业的个人资料。
|
|
10
|
+
//
|
|
11
|
+
// 夹具自此已模拟租户注入(fixture.ts 头注释),跨租户「查不到、改不动」的地基语义由
|
|
12
|
+
// tenant-isolation.spec.ts 单独钉;本文件仍用「sub 在库中不存在」来钉端点级拦截——
|
|
13
|
+
// 它就是伪造头在 SQL 层的表现,且不依赖构造器能力。对照组用真实 uid 证明同一路径照常放行。
|
|
14
|
+
/* eslint-disable test/prefer-lowercase-title -- 用例标题带端点路径,保留原样更易检索 */
|
|
15
|
+
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
16
|
+
|
|
17
|
+
import { invalidateUser } from "../src/_platform/api/_shared/service/infra/access";
|
|
18
|
+
import notification from "../src/notification/api/api";
|
|
19
|
+
import notificationDel from "../src/notification/api/del/api";
|
|
20
|
+
import profile from "../src/personal-center/api/profile/api";
|
|
21
|
+
import upload from "../src/personal-center/api/upload/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
|
+
/** 本租户查无此人的 sub —— 伪造租户头在 SQL 层就是这个样子 */
|
|
51
|
+
const FORGED = 987_654_321;
|
|
52
|
+
|
|
53
|
+
beforeAll(() => {
|
|
54
|
+
dbo = installDb();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
invalidateUser(FORGED);
|
|
59
|
+
g.http = {};
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("伪造租户头:通知族全部 403(6 个入口无一漏网)", () => {
|
|
63
|
+
it("GET /api/notification/notifications → 403", async () => {
|
|
64
|
+
installJson();
|
|
65
|
+
g.http = { user: { id: FORGED } };
|
|
66
|
+
await notification.notifications.get();
|
|
67
|
+
expect(out.status).toBe(403);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("POST /api/notification/notifications/read → 403(先于 body 校验)", async () => {
|
|
71
|
+
installJson();
|
|
72
|
+
g.http = { user: { id: FORGED }, body: { id: "u:1" } };
|
|
73
|
+
await notification.notifications.read.post();
|
|
74
|
+
expect(out.status).toBe(403);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("POST /api/notification/notifications/read-all → 403", async () => {
|
|
78
|
+
installJson();
|
|
79
|
+
g.http = { user: { id: FORGED } };
|
|
80
|
+
await notification.notifications["read-all"].post();
|
|
81
|
+
expect(out.status).toBe(403);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("POST /api/notification/notifications/clear → 403", async () => {
|
|
85
|
+
installJson();
|
|
86
|
+
g.http = { user: { id: FORGED } };
|
|
87
|
+
await notification.notifications.clear.post();
|
|
88
|
+
expect(out.status).toBe(403);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("POST /api/notification/push → 403(不能先按 body 回 400)", async () => {
|
|
92
|
+
installJson();
|
|
93
|
+
g.http = { user: { id: FORGED }, body: { title: "t", message: "m" } };
|
|
94
|
+
await notification.push.post();
|
|
95
|
+
expect(out.status).toBe(403);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("POST /api/notification/push-broadcast → 403(否则可向别家企业广播)", async () => {
|
|
99
|
+
installJson();
|
|
100
|
+
g.http = { user: { id: FORGED }, body: { title: "t", message: "m" } };
|
|
101
|
+
await notification["push-broadcast"].post();
|
|
102
|
+
expect(out.status).toBe(403);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("POST /api/notification/del → 403(否则可按 id 删别家企业的通知)", async () => {
|
|
106
|
+
installJson();
|
|
107
|
+
g.http = { user: { id: FORGED }, body: { id: "u:1" } };
|
|
108
|
+
await notificationDel.post();
|
|
109
|
+
expect(out.status).toBe(403);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("伪造租户头:个人中心写端点 403", () => {
|
|
114
|
+
it("POST /api/personal-center/profile → 403(否则「更新 0 行回 200」)", async () => {
|
|
115
|
+
installJson();
|
|
116
|
+
g.http = { user: { id: FORGED }, body: { email: "x@example.com" } };
|
|
117
|
+
await profile.post();
|
|
118
|
+
expect(out.status).toBe(403);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("POST /api/personal-center/upload → 403(且不落 blob 垃圾文件)", async () => {
|
|
122
|
+
installJson();
|
|
123
|
+
g.http = { user: { id: FORGED }, files: [{ key: "", content_type: "image/png" }] };
|
|
124
|
+
await upload.post();
|
|
125
|
+
expect(out.status).toBe(403);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("对照组:同一路径换真实 uid 照常放行(证明 403 来自租户自洽而非误伤)", () => {
|
|
130
|
+
// 对照组不取通知列表:'noticesFor' 走 UNION,夹具渲染成 `(SELECT …) UNION ALL (SELECT …)`,
|
|
131
|
+
// sqlite 的 compound select 不接受括号(真 oj 构造器无此问题)——那是夹具的能力边界,
|
|
132
|
+
// 与本层要钉的租户拦截无关,故换同一护栏下的 read-all 作对照。
|
|
133
|
+
it("read-all 与我的资料:真实 uid → 200", async () => {
|
|
134
|
+
const uid = mkUser("tf_ok");
|
|
135
|
+
installJson();
|
|
136
|
+
g.http = { user: { id: uid }, tenantId: "default" };
|
|
137
|
+
await notification.notifications["read-all"].post();
|
|
138
|
+
expect(out.status).toBe(200);
|
|
139
|
+
|
|
140
|
+
installJson();
|
|
141
|
+
g.http = { user: { id: uid }, body: { description: "hi" } };
|
|
142
|
+
await profile.post();
|
|
143
|
+
expect(out.status).toBe(200);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("未认证(无 http.user)仍是 401,不是 403", async () => {
|
|
147
|
+
installJson();
|
|
148
|
+
g.http = {};
|
|
149
|
+
await notification.notifications.get();
|
|
150
|
+
expect(out.status).toBe(401);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -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
|
+
});
|