@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
|
@@ -9,8 +9,15 @@
|
|
|
9
9
|
// delete / truncate / all / run / listTables。子集外的调用**直接报「未实现」**,不静默返回空结果
|
|
10
10
|
// (静默空值会把「垫片缺能力」伪装成「业务没数据」,是最难查的一类假绿)。
|
|
11
11
|
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
12
|
+
// 已知边界(模板当前用不到,不模拟):归属守卫(`owner_of`)——**租户已模拟**,见下。
|
|
13
|
+
// 租户模拟(2026-10-30 补,多租户上线后必须有,否则守卫失效也会绿):
|
|
14
|
+
// · 哪些表按租户隔离不硬编码,建库后按 PRAGMA 找带 `tenant_id` 列的表(与 0015__tenant.sql 同源,
|
|
15
|
+
// 迁移改了这里自动跟随,不会漂移);
|
|
16
|
+
// · 当前租户取 `globalThis.http.tenantId`(与 oj 同源:请求头进来就是它),缺省 `default`;
|
|
17
|
+
// 用例里改 `g.http.tenantId` 即可模拟「伪造 X-TENANT-ID」;
|
|
18
|
+
// · 三条语义与 oj 对齐:insert 自动补 `tenant_id`、select/update/delete/truncate 自动加
|
|
19
|
+
// `tenant_id = ?` 谓词、裸 SQL(db.query/db.exec)触碰租户表却无 `tenant_id` 直接拒
|
|
20
|
+
// (近似 `sql_guard: deny`,是文本级判断,不是解析器)。
|
|
14
21
|
//
|
|
15
22
|
// 分工不变:
|
|
16
23
|
// · L2(本目录 *.spec.ts):构造器渲染出**语义等价**的 SQL 后在真 sqlite 上跑——服务层行为、
|
|
@@ -29,6 +36,45 @@ const SRC = join(dirname(fileURLToPath(import.meta.url)), "..", "src");
|
|
|
29
36
|
|
|
30
37
|
export const ADMIN = 1;
|
|
31
38
|
|
|
39
|
+
/** 部署默认租户(与运行时 store/tenant.ts 的 DEFAULT_TENANT 同值) */
|
|
40
|
+
export const DEFAULT_TENANT = "default";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 带 `tenant_id` 列的表 = 租户化表(A 组)。建库时按 PRAGMA 自省后填充,
|
|
44
|
+
* 不在代码里抄一份清单——迁移口径变了这里跟着变。
|
|
45
|
+
*/
|
|
46
|
+
const TENANT_TABLES = new Set<string>();
|
|
47
|
+
|
|
48
|
+
/** 当前请求的租户:与 oj 同源(`http.tenantId`),无请求上下文时落部署默认 */
|
|
49
|
+
function currentTenant(): string {
|
|
50
|
+
const t = (globalThis as any).http?.tenantId;
|
|
51
|
+
return typeof t === "string" && t.trim() ? t.trim() : DEFAULT_TENANT;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isTenantScoped(table: string): boolean {
|
|
55
|
+
return TENANT_TABLES.has(table.toLowerCase());
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 裸 SQL 的租户红线段落(近似 `sql_guard: deny`):SQL 里出现租户表却整条没有 `tenant_id`
|
|
60
|
+
* 就拒。文本级判断,只挡「整条忘了带租户」这一类,挡不住精心构造的绕过——真判据在 oj 侧。
|
|
61
|
+
*/
|
|
62
|
+
const TENANT_SQL_TABLE_RE = /\b(?:from|into|update|join)\s+"?([a-z_]\w*)"?/gi;
|
|
63
|
+
|
|
64
|
+
function guardTenantSql(sql: string): void {
|
|
65
|
+
if (TENANT_TABLES.size === 0)
|
|
66
|
+
return;
|
|
67
|
+
const touched = [...sql.matchAll(TENANT_SQL_TABLE_RE)]
|
|
68
|
+
.map(m => m[1]!.toLowerCase())
|
|
69
|
+
.filter(t => isTenantScoped(t));
|
|
70
|
+
if (touched.length === 0 || /\btenant_id\b/i.test(sql))
|
|
71
|
+
return;
|
|
72
|
+
throw new Error(
|
|
73
|
+
`[fixture] 裸 SQL 触碰租户表 ${[...new Set(touched)].join(", ")} 但未写 tenant_id:`
|
|
74
|
+
+ "tenant.enable + sql_guard: deny 下真 oj 会拒(租户值须走参数,不拼字符串)",
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
32
78
|
/** 标识符加引号(保留字列名如 `order` / `group` / `count` 全按字面量处理;`表.列` 逐段加) */
|
|
33
79
|
function quoteIdent(name: string): string {
|
|
34
80
|
return name.split(".").map(p => `"${p.replace(/"/g, "\"\"")}"`).join(".");
|
|
@@ -268,15 +314,25 @@ class FakeQuery {
|
|
|
268
314
|
return this.compileUpdate();
|
|
269
315
|
if (s.verb === "delete")
|
|
270
316
|
return this.compileDelete();
|
|
271
|
-
if (s.verb === "truncate")
|
|
272
|
-
|
|
317
|
+
if (s.verb === "truncate") {
|
|
318
|
+
const tenant = this.tenantPredicate();
|
|
319
|
+
return {
|
|
320
|
+
sql: `DELETE FROM ${quoteIdent(s.table)}${tenant ? ` WHERE ${tenant.sql}` : ""}`,
|
|
321
|
+
params: tenant ? tenant.params : [],
|
|
322
|
+
};
|
|
323
|
+
}
|
|
273
324
|
return this.compileSelect(inner);
|
|
274
325
|
}
|
|
275
326
|
|
|
276
327
|
private compileInsert(): Compiled {
|
|
277
|
-
const
|
|
278
|
-
if (
|
|
328
|
+
const raw = (Array.isArray(this.s.values) ? this.s.values : [this.s.values]) as Array<Record<string, unknown>>;
|
|
329
|
+
if (raw.length === 0)
|
|
279
330
|
throw new Error("[fixture] insert 至少需要一行");
|
|
331
|
+
// 租户化表:调用方不写 tenant_id 时自动补当前租户(oj 构造器同语义——
|
|
332
|
+
// 业务代码不该自己写 tenant_id,注入失效才是最危险的失败模式)
|
|
333
|
+
const rows = isTenantScoped(this.s.table)
|
|
334
|
+
? raw.map(r => ("tenant_id" in r ? r : { ...r, tenant_id: currentTenant() }))
|
|
335
|
+
: raw;
|
|
280
336
|
const cols = Object.keys(rows[0]!);
|
|
281
337
|
for (const r of rows) {
|
|
282
338
|
if (Object.keys(r).length !== cols.length)
|
|
@@ -309,11 +365,21 @@ class FakeQuery {
|
|
|
309
365
|
return { sql: `DELETE FROM ${quoteIdent(this.s.table)}${where.sql}`, params: where.params };
|
|
310
366
|
}
|
|
311
367
|
|
|
312
|
-
/**
|
|
368
|
+
/** 基表租户谓词(表名/别名限定,与 oj 渲染的 `"users"."tenant_id"` 同形);共享表返回 null */
|
|
369
|
+
private tenantPredicate(): Compiled | null {
|
|
370
|
+
if (!isTenantScoped(this.s.table))
|
|
371
|
+
return null;
|
|
372
|
+
return { sql: `${quoteIdent(this.s.alias ?? this.s.table)}.tenant_id = ?`, params: [currentTenant()] };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** where 子句(追加进既有 params,返回拼接后的片段);租户谓词始终参与 AND */
|
|
313
376
|
private compileWhereClause(params: unknown[]): Compiled {
|
|
314
|
-
if (this.s.wheres.length === 0)
|
|
315
|
-
return { sql: "", params };
|
|
316
377
|
const parts = this.s.wheres.map(compileWhere);
|
|
378
|
+
const tenant = this.tenantPredicate();
|
|
379
|
+
if (tenant)
|
|
380
|
+
parts.push(tenant);
|
|
381
|
+
if (parts.length === 0)
|
|
382
|
+
return { sql: "", params };
|
|
317
383
|
return {
|
|
318
384
|
sql: ` WHERE ${parts.map(p => `(${p.sql})`).join(" AND ")}`,
|
|
319
385
|
params: [...params, ...parts.flatMap(p => p.params)],
|
|
@@ -408,10 +474,24 @@ export function installDb(): any {
|
|
|
408
474
|
dbo.exec(readFileSync(join(dir, f), "utf8"));
|
|
409
475
|
}
|
|
410
476
|
|
|
477
|
+
// 租户化表自省:有 `tenant_id` 列即按租户隔离(迁移是唯一真相,不在夹具里抄清单)
|
|
478
|
+
TENANT_TABLES.clear();
|
|
479
|
+
for (const { name } of dbo.prepare(SQL_LIST_TABLES).all() as Array<{ name: string }>) {
|
|
480
|
+
const cols = dbo.prepare(`PRAGMA table_info(${quoteIdent(name)})`).all() as Array<{ name: string }>;
|
|
481
|
+
if (cols.some(c => c.name.toLowerCase() === "tenant_id"))
|
|
482
|
+
TENANT_TABLES.add(name.toLowerCase());
|
|
483
|
+
}
|
|
484
|
+
|
|
411
485
|
dbo.prepare("INSERT INTO users (id, username, password_hash) VALUES (?, ?, 'x')").run(ADMIN, "admin");
|
|
412
486
|
|
|
413
|
-
const query = async (sql: string, params: unknown[] = []) =>
|
|
414
|
-
|
|
487
|
+
const query = async (sql: string, params: unknown[] = []) => {
|
|
488
|
+
guardTenantSql(sql);
|
|
489
|
+
return dbo.prepare(sql).all(...(params as never[]));
|
|
490
|
+
};
|
|
491
|
+
const exec = async (sql: string, params: unknown[] = []) => {
|
|
492
|
+
guardTenantSql(sql);
|
|
493
|
+
return Number(dbo.prepare(sql).run(...(params as never[])).changes);
|
|
494
|
+
};
|
|
415
495
|
const table = (name: string) => new FakeQuery(dbo, name);
|
|
416
496
|
const txInstance = { query, exec, table, listTables: (prefix?: string) => listTables(dbo, prefix), isTx: true };
|
|
417
497
|
// db.tx 镜像运行时面:BEGIN/COMMIT/ROLLBACK 包裹回调(versions.ts 注释同语义)
|
|
@@ -43,17 +43,23 @@ describe("spike 夹具冒烟:真实迁移 + 运行时全局面 + 事务", () =
|
|
|
43
43
|
expect(row.username).toBe("admin");
|
|
44
44
|
});
|
|
45
45
|
it("全局 db.query/exec 与运行时同面可用", async () => {
|
|
46
|
-
const rows = await db.query("SELECT id, username FROM users");
|
|
46
|
+
const rows = await db.query("SELECT id, username FROM users WHERE tenant_id = ?", ["default"]);
|
|
47
47
|
expect(rows.length).toBeGreaterThan(0);
|
|
48
48
|
});
|
|
49
|
+
it("裸 SQL 漏写 tenant_id 一律拒(sql_guard: deny 的夹具替身)", async () => {
|
|
50
|
+
await expect(db.query("SELECT id FROM users")).rejects.toThrow(/未写 tenant_id/);
|
|
51
|
+
await expect(db.exec("DELETE FROM users")).rejects.toThrow(/未写 tenant_id/);
|
|
52
|
+
// B 组共享表无 tenant_id,照旧放行
|
|
53
|
+
await expect(db.query("SELECT key FROM sys_setting")).resolves.toBeDefined();
|
|
54
|
+
});
|
|
49
55
|
it("db.tx 事务往返:提交持久化 / 回滚不留痕", async () => {
|
|
50
56
|
await db.tx(async (t) => {
|
|
51
|
-
await t.exec("INSERT INTO users (username, password_hash) VALUES ('tx-ok', 'x')");
|
|
57
|
+
await t.exec("INSERT INTO users (tenant_id, username, password_hash) VALUES ('default', 'tx-ok', 'x')");
|
|
52
58
|
});
|
|
53
59
|
const ok = dbo.prepare("SELECT COUNT(*) AS n FROM users WHERE username = 'tx-ok'").get() as { n: number };
|
|
54
60
|
expect(Number(ok.n)).toBe(1);
|
|
55
61
|
await expect(db.tx(async (t) => {
|
|
56
|
-
await t.exec("INSERT INTO users (username, password_hash) VALUES ('tx-bad', 'x')");
|
|
62
|
+
await t.exec("INSERT INTO users (tenant_id, username, password_hash) VALUES ('default', 'tx-bad', 'x')");
|
|
57
63
|
throw new Error("rollback");
|
|
58
64
|
})).rejects.toThrow("rollback");
|
|
59
65
|
const bad = dbo.prepare("SELECT COUNT(*) AS n FROM users WHERE username = 'tx-bad'").get() as { n: number };
|
|
@@ -326,27 +326,29 @@ describe("smoke 探活(D14/Q-A:health 不强制)", () => {
|
|
|
326
326
|
expect(r.ok).toBe(true);
|
|
327
327
|
});
|
|
328
328
|
|
|
329
|
-
it("health 401
|
|
329
|
+
it("health 401(无凭证探针,restart_pending 常态)→ 一律判通过 + 落待重启提示", async () => {
|
|
330
330
|
await seedDeployed();
|
|
331
331
|
const r = await smoke(smCtx({
|
|
332
332
|
manifest: solManifest({ health: "/demo-solution/ping" }),
|
|
333
333
|
fetch: async () => ({ ok: false, status: 401 }),
|
|
334
|
-
deferredAnonymous: ["/demo-solution/ping"],
|
|
335
334
|
}));
|
|
336
335
|
expect(r.ok).toBe(true);
|
|
337
336
|
expect(r.message).toContain("restart_pending");
|
|
338
|
-
expect(r.message).toContain("
|
|
337
|
+
expect(r.message).toContain("不计入失败");
|
|
339
338
|
});
|
|
340
339
|
|
|
341
|
-
it("health 401
|
|
340
|
+
it("health 401 与否声明 anonymous 无关(统一 deferred,不放宽真失败靠非 401)", async () => {
|
|
341
|
+
// 旧语义按 deferredAnonymous 白名单区分是否 deferred;该字段从未被调用方赋值(死代码),
|
|
342
|
+
// 且设计明确「health 不强制、无凭证预期 401」,故改为 health 401 一律 deferred。
|
|
343
|
+
// 真失败仍由非 401(如 500,见下条)兜底,不靠 anonymous 白名单。
|
|
342
344
|
await seedDeployed();
|
|
343
345
|
const r = await smoke(smCtx({
|
|
344
346
|
manifest: solManifest({ health: "/demo-solution/ping" }),
|
|
345
347
|
fetch: async () => ({ ok: false, status: 401 }),
|
|
346
348
|
deferredAnonymous: ["/demo-solution/other"],
|
|
347
349
|
}));
|
|
348
|
-
expect(r.ok).toBe(
|
|
349
|
-
expect(r.message).toContain("
|
|
350
|
+
expect(r.ok).toBe(true);
|
|
351
|
+
expect(r.message).toContain("restart_pending");
|
|
350
352
|
});
|
|
351
353
|
|
|
352
354
|
it("health 非 2xx → 探活失败", async () => {
|
|
@@ -1237,3 +1237,79 @@ describe("P1-16 接线:reconcile 不一致自动修复(重跑 setEnabled)+
|
|
|
1237
1237
|
expect(mig?.opts).toEqual({ timeoutMs: 600000 });
|
|
1238
1238
|
});
|
|
1239
1239
|
});
|
|
1240
|
+
|
|
1241
|
+
describe("冒烟探活必须自报租户头(企微代开发多租户 v3 Phase 0)", () => {
|
|
1242
|
+
it("probe fetch 带 X-TENANT-ID: default —— 宿主 tenant.enable 时匿名健康端点也强制要租户头,缺即 400", async () => {
|
|
1243
|
+
const seen: Array<{ url: string, headers?: Record<string, string> }> = [];
|
|
1244
|
+
const t = await runInstall(makeReq({
|
|
1245
|
+
fetch: async (url: string, init?: { headers?: Record<string, string> }) => {
|
|
1246
|
+
seen.push({ url, headers: init?.headers });
|
|
1247
|
+
return { ok: true, status: 200, arrayBuffer: async () => ARCHIVE.slice().buffer };
|
|
1248
|
+
},
|
|
1249
|
+
}));
|
|
1250
|
+
expect(t.status).toBe("done");
|
|
1251
|
+
// 探活命中的是首个非匿名路由 /demo-solution(anonymous 仅 demo-solution/ping)
|
|
1252
|
+
const probe = seen.find(c => c.url === "/api/demo-solution/demo-solution");
|
|
1253
|
+
expect(probe, "smoke 应对首个非匿名路由发起探活").toBeDefined();
|
|
1254
|
+
expect(probe!.headers?.["X-TENANT-ID"]).toBe("default");
|
|
1255
|
+
});
|
|
1256
|
+
|
|
1257
|
+
it("遇「缺租户头即 400」的宿主时因已自报 default 而通过(属性测试,非假绿)", async () => {
|
|
1258
|
+
// 模拟宿主 tenant.enable 的硬 fail-closed:探针缺 X-TENANT-ID 即 400、带 default 即 200。
|
|
1259
|
+
// 本例用 health 端点(smoke 对 health 断言 2xx)——若 smoke 没带租户头,探针必 400 → smoke 失败。
|
|
1260
|
+
// 安装成功(done) 即反向证明租户头确已注入,而非靠「非 health 探活忽略 2xx」蒙混。
|
|
1261
|
+
const seen: Array<{ url: string, headers?: Record<string, string> }> = [];
|
|
1262
|
+
const t = await runInstall(makeReq({
|
|
1263
|
+
manifest: { ...manifestFixture("0.1.0"), health: "/demo-solution/ping" },
|
|
1264
|
+
fetch: async (url: string, init?: { headers?: Record<string, string> }) => {
|
|
1265
|
+
seen.push({ url, headers: init?.headers });
|
|
1266
|
+
// 仅对探活 URL 模拟「缺租户头即 400」;下载 CDN 等一律 200,避免误伤其他阶段。
|
|
1267
|
+
if (url === "/api/demo-solution/demo-solution/ping" && init?.headers?.["X-TENANT-ID"] !== "default") {
|
|
1268
|
+
return { ok: false, status: 400, arrayBuffer: async () => new ArrayBuffer(0) };
|
|
1269
|
+
}
|
|
1270
|
+
return { ok: true, status: 200, arrayBuffer: async () => ARCHIVE.slice().buffer };
|
|
1271
|
+
},
|
|
1272
|
+
}));
|
|
1273
|
+
expect(t.status).toBe("done");
|
|
1274
|
+
const probe = seen.find(c => c.url === "/api/demo-solution/demo-solution/ping");
|
|
1275
|
+
expect(probe, "health 优先被探活").toBeDefined();
|
|
1276
|
+
expect(probe!.headers?.["X-TENANT-ID"]).toBe("default");
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
it("health 端点声明为匿名:探针 401(restart_pending)应判 deferred 而非失败", async () => {
|
|
1280
|
+
// 租户化站点上「本次新增的匿名端点要重启才生效」,探活收 401 是预期,不计入失败(设计 §4.1)。
|
|
1281
|
+
// 复现 reinstall/upgrade 在隔离站点的 401 误判失败:修复前 deferredAnonymous 从未被赋值 → 误失败。
|
|
1282
|
+
const t = await runInstall(makeReq({
|
|
1283
|
+
manifest: {
|
|
1284
|
+
...manifestFixture("0.1.0"),
|
|
1285
|
+
health: "/demo-solution/ping",
|
|
1286
|
+
api: { prefix: "/api/demo-solution", anonymous: ["demo-solution/ping"] },
|
|
1287
|
+
},
|
|
1288
|
+
fetch: async (url: string) => {
|
|
1289
|
+
if (url === "/api/demo-solution/demo-solution/ping") {
|
|
1290
|
+
return { ok: false, status: 401, arrayBuffer: async () => new ArrayBuffer(0) };
|
|
1291
|
+
}
|
|
1292
|
+
return { ok: true, status: 200, arrayBuffer: async () => ARCHIVE.slice().buffer };
|
|
1293
|
+
},
|
|
1294
|
+
}));
|
|
1295
|
+
expect(t.status).toBe("done");
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
it("health 端点收非 401(如 500):仍判失败(仅 401 转 deferred)", async () => {
|
|
1299
|
+
const t = await runInstall(makeReq({
|
|
1300
|
+
manifest: {
|
|
1301
|
+
...manifestFixture("0.1.0"),
|
|
1302
|
+
health: "/demo-solution/ping",
|
|
1303
|
+
api: { prefix: "/api/demo-solution", anonymous: ["demo-solution/ping"] },
|
|
1304
|
+
},
|
|
1305
|
+
fetch: async (url: string) => {
|
|
1306
|
+
if (url === "/api/demo-solution/demo-solution/ping") {
|
|
1307
|
+
return { ok: false, status: 500, arrayBuffer: async () => new ArrayBuffer(0) };
|
|
1308
|
+
}
|
|
1309
|
+
return { ok: true, status: 200, arrayBuffer: async () => ARCHIVE.slice().buffer };
|
|
1310
|
+
},
|
|
1311
|
+
}));
|
|
1312
|
+
expect(t.status).toBe("partial");
|
|
1313
|
+
expect(t.stage).toBe("smoke");
|
|
1314
|
+
});
|
|
1315
|
+
});
|
|
@@ -280,7 +280,7 @@ describe("信任库运维端点(superadmin,R9)", () => {
|
|
|
280
280
|
await api.trust.key.del();
|
|
281
281
|
expect(last).toMatchObject({ status: 400 });
|
|
282
282
|
|
|
283
|
-
const rows = await (g.db.query as (sql: string, p?: unknown[]) => Promise<Array<Record<string, unknown>>>)("select action, target, detail from sys_oplog where action like 'solution.trust.%' order by id");
|
|
283
|
+
const rows = await (g.db.query as (sql: string, p?: unknown[]) => Promise<Array<Record<string, unknown>>>)("select action, target, detail from sys_oplog where tenant_id = ? and action like 'solution.trust.%' order by id", ["default"]);
|
|
284
284
|
expect(rows.map(r => r.action)).toEqual(["solution.trust.add", "solution.trust.revoke"]);
|
|
285
285
|
expect(rows[0]).toMatchObject({ target: "ojm.test" });
|
|
286
286
|
expect(JSON.parse(String(rows[0].detail))).toMatchObject({ key_id: "ojm.test", operator: "admin" });
|
|
@@ -14,12 +14,15 @@ beforeAll(() => {
|
|
|
14
14
|
dbo.prepare("INSERT INTO users (username, password_hash) VALUES (?, 'x')").run(`u${String(i).padStart(3, "0")}`);
|
|
15
15
|
});
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
// 裸 SQL(paged 直通):租户化表必须显式写 tenant_id 且走参数——tenant.enable + sql_guard: deny
|
|
18
|
+
// 下缺它会被真 oj 拒(夹具的 guardTenantSql 同样拒,见 tests/fixture.ts 头注释)
|
|
19
|
+
const TENANT = "default";
|
|
20
|
+
const LIST = "select id, username from users where tenant_id = ?";
|
|
21
|
+
const COUNT = "select count(*) as c from users where tenant_id = ?";
|
|
19
22
|
|
|
20
23
|
describe("AC1-24:limit 钳制与截断标记", () => {
|
|
21
24
|
it("size 静默钳 200,truncated=true(库 301 人=夹具 admin+300)", async () => {
|
|
22
|
-
const r = await paged<any>(LIST, COUNT, [], { page: 1, size: 500 });
|
|
25
|
+
const r = await paged<any>(LIST, COUNT, [TENANT], { page: 1, size: 500 });
|
|
23
26
|
expect(r.size).toBe(200);
|
|
24
27
|
expect(r.list.length).toBe(200);
|
|
25
28
|
expect(r.total).toBe(301);
|
|
@@ -27,16 +30,16 @@ describe("AC1-24:limit 钳制与截断标记", () => {
|
|
|
27
30
|
});
|
|
28
31
|
|
|
29
32
|
it("末页 truncated=false", async () => {
|
|
30
|
-
const r = await paged<any>(LIST, COUNT, [], { page: 2, size: 200 });
|
|
33
|
+
const r = await paged<any>(LIST, COUNT, [TENANT], { page: 2, size: 200 });
|
|
31
34
|
expect(r.list.length).toBe(101);
|
|
32
35
|
expect(r.truncated).toBe(false);
|
|
33
36
|
});
|
|
34
37
|
|
|
35
38
|
it("缺省 page=1 / size=20;页码/尺寸非法值回落(0/负数/非数)", async () => {
|
|
36
|
-
const d = await paged<any>(LIST, COUNT, [], {});
|
|
39
|
+
const d = await paged<any>(LIST, COUNT, [TENANT], {});
|
|
37
40
|
expect(d.page).toBe(1);
|
|
38
41
|
expect(d.size).toBe(20);
|
|
39
|
-
const bad = await paged<any>(LIST, COUNT, [], { page: 0, size: -3 });
|
|
42
|
+
const bad = await paged<any>(LIST, COUNT, [TENANT], { page: 0, size: -3 });
|
|
40
43
|
expect(bad.page).toBe(1);
|
|
41
44
|
expect(bad.size).toBe(20);
|
|
42
45
|
});
|
|
@@ -54,9 +57,9 @@ describe("AC1-22:LIKE 模式与 keyword 长度红线", () => {
|
|
|
54
57
|
it("LIKE 元字符转义:% _ ! 原样匹配不泛化", async () => {
|
|
55
58
|
dbo.prepare("INSERT INTO users (username, password_hash) VALUES ('a%b_c!d', 'x')").run();
|
|
56
59
|
const r = await paged<any>(
|
|
57
|
-
"select id, username from users where username like ? escape '!'",
|
|
58
|
-
"select count(*) as c from users where username like ? escape '!'",
|
|
59
|
-
[likePattern("a%b_c!d")],
|
|
60
|
+
"select id, username from users where tenant_id = ? and username like ? escape '!'",
|
|
61
|
+
"select count(*) as c from users where tenant_id = ? and username like ? escape '!'",
|
|
62
|
+
[TENANT, likePattern("a%b_c!d")],
|
|
60
63
|
{ size: 10 },
|
|
61
64
|
);
|
|
62
65
|
expect(r.list.map((x: any) => x.username)).toEqual(["a%b_c!d"]);
|
|
@@ -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
|
+
});
|