Files
xiaotutongxue/scripts/e2e-m1-tenant-domain.py
T
lovife e6cd1049d4 chore(tenant): 租户域名改为默认关闭(客户决定先不做)—— 能力保留,关得干净
客户:先不做多租户了。

处理:fill.tenant-domain.enabled 默认 true → **false**,代码全部保留。
理由:
  · 这个能力是隔离的:关掉后 TenantDomainResolver 一律返回 null,
    登录走老路径、AuthInterceptor 那段校验根本不触发 —— 不影响任何既有行为
  · 它已经验过(§一·二十五 16/16),删掉等于扔掉一份验过、将来要用的东西
  · 要真用上它,前提是有正式域名 + 泛域名解析 + 泛域名证书;这些没定之前
    开它只会让人以为"域名能区分单位了",实际并不能。等定了改一个配置 + 重启即可。

「关得干净」是本轮的验收点:E2E 改成**两种模式都能跑**,按当前模式选断言。
现在跑的是关闭态这一节(6 条):
  ✓ t1.localhost + 不填租户码 → 40012(域名不参与判断)
  ✓ 同一域名 + 填租户码 → 照样能登;主域名 + 租户码 → 照样能登
  ✓ 主域名 + 不填租户码 → 仍是 40012(跨单位重名的原行为)
  ✓ 关掉之后不引入任何新校验:带 t1 的 token 访问 t14 域名照常通过
脚本自己探测模式(免租户码登录成功=开、40012=关),所以开着时跑它会跑那 16 条,
不会因为模式不对而假绿。

将来开启:填 base → 配泛域名解析+证书 → XT_TENANT_DOMAIN_ENABLED=true → 重启 → 跑 E2E。
两条硬约束不变:微信授权回调域名只能配一个(回调统一落主域名再按 scene 跳回)、
小程序域名固定白名单(小程序端永远靠 scene)。

随之暂缓:§一·二十五 的三条待拍板(正式域名 / 手填租户码 / 小程序确认)现在都不用追了;
PRD 与 03-接口设计的补文档也等真要做时再写(现在写进去只会多一条"设计了但没启用")。

全量 666 条断言、0 失败(租户域名 16 → 6,因为跑的是关闭态那节)。
2026-09-29 21:42:00 +08:00

243 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
租户域名 E2E:**用子域前缀判断"这是哪个单位的站点"**(t1.xxx.com / t2.xxx.com)。
验的核心只有两条,但它们决定了这个功能能不能上:
① **登录时不用再填租户码**。在此之前,「这次是哪个单位」要么靠人手填租户码
(账号跨单位重名时报 40012),要么靠扫码 scene —— 而域名本身就是最自然的答案:
用户在 t1.xxx.com 上打开页面时,系统不该再问他"你是哪个单位的"。
② ⚠️ **Host 只用于"定位",授权一律以 token 为准,且两者不一致必须拒绝**。
不校验的话:在 t1.xxx.com 上拿着 t2 的 token 就能读 t2 的数据 ——
界面一切正常(前端本来就会带 token),是个安静且极难被发现的越权。
所以这条比第 ① 条更重要。
跑法:python scripts/e2e-m1-tenant-domain.py
前提:后端 8080 在跑,且 `fill.tenant-domain` 已启用(dev 默认
enabled=true / base=localhost —— 只有 *.localhost 会走这条路,不影响其它域名)。
"""
from __future__ import annotations
import argparse
import base64
import http.client
import json
import os
import sys
sys.stdout.reconfigure(encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PUB_KEY = os.path.join(ROOT, "deploy", "rsa-public-key-dev.pem")
PASSED: list[str] = []
FAILED: list[tuple[str, str]] = []
def ok(name: str) -> None:
PASSED.append(name)
print(f" [OK] {name}")
def fail(name: str, detail: str) -> None:
FAILED.append((name, detail))
print(f" [FAIL] {name}\n {detail}")
def check(name: str, cond: bool, detail: str = "") -> bool:
if cond:
ok(name)
else:
fail(name, detail or "断言不成立")
return cond
def rsa_encrypt(plain: str) -> str:
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
with open(PUB_KEY, "rb") as f:
pub = serialization.load_pem_public_key(f.read())
return base64.b64encode(pub.encrypt(plain.encode(), padding.PKCS1v15())).decode()
def call(method: str, path: str, host: str, token: str | None = None,
body: dict | None = None) -> dict:
"""
发一个请求,**Host 由调用方指定**。
用 http.client 而不是 urllib:urllib 会按 URL 自己算 Host,
而这里恰恰要伪造 Host(模拟 t1.localhost 这种子域),必须完全掌控这个头。
"""
payload = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
headers = {"Host": host, "Content-Type": "application/json; charset=utf-8"}
if token:
headers["Authorization"] = f"Bearer {token}"
conn = http.client.HTTPConnection("127.0.0.1", 8080, timeout=30)
try:
conn.request(method, path, body=payload, headers=headers)
resp = conn.getresponse()
raw = resp.read().decode("utf-8")
try:
return json.loads(raw)
except json.JSONDecodeError:
return {"_raw": raw[:200], "_http": resp.status}
finally:
conn.close()
def login(host: str, tenant_code: str | None = None, account: str = "admin",
password: str = "Admin@123") -> dict:
body: dict = {"username": account, "password": rsa_encrypt(password)}
if tenant_code is not None:
body["tenantCode"] = tenant_code
return call("POST", "/api/v1/auth/login", host, body=body)
def run_on() -> int:
ap = argparse.ArgumentParser(description="租户域名 E2E")
ap.add_argument("--password", default="Admin@123")
args = ap.parse_args()
print("=" * 76)
print("租户域名 E2E(t{tenantId}.{base})")
print("=" * 76)
# ── 1. 域名就是租户身份:不用填租户码 ─────────────────
print("\n[1] 在 t1.localhost 上,不填租户码也应能登录")
# 先证明"没有域名时确实需要租户码"——否则下面的成功说明不了是域名起的作用
no_domain = login("localhost", None)
check("对照:不带域名 + 不填租户码 → 40012(账号跨单位重名)",
no_domain.get("code") == 40012, f"{no_domain}")
with_domain = login("t1.localhost", None)
check("**t1.localhost + 不填租户码 → 登录成功**(域名提供了租户身份)",
with_domain.get("code") == 0, f"{with_domain}")
tenant = (with_domain.get("data") or {}).get("tenant") or {}
check("登进去的确实是租户 1(demo)", tenant.get("id") == 1 and tenant.get("tenantCode") == "demo",
f"{tenant}")
token1 = (with_domain.get("data") or {}).get("token")
# 带端口、带大写、多一层子域,都应解析到同一个租户
check("Host 带端口(t1.localhost:8080)也能识别",
login("t1.localhost:8080", None).get("code") == 0, "带端口失败")
check("大小写不敏感(T1.LocalHost)",
login("T1.LocalHost", None).get("code") == 0, "大小写失败")
check("多一层子域(a.t1.localhost)取最后一段",
login("a.t1.localhost", None).get("code") == 0, "多层子域失败")
# ── 2. 与手填租户码矛盾时:明确报错,不静默忽略 ────────
print("\n[2] 域名与手填的租户码矛盾时必须报错")
conflict = login("t1.localhost", "somewrongcode")
check("t1 域名 + 别的租户码 → 明确报错(不静默忽略)",
conflict.get("code") == 40001, f"{conflict}")
check("错误里说清了「当前访问的是哪个单位」与「不一致」",
"不一致" in (conflict.get("msg") or "") and "站点" in (conflict.get("msg") or ""),
f"{conflict.get('msg')}")
# 一致的租户码是允许的(老客户端仍会带上)
same = login("t1.localhost", "demo")
check("t1 域名 + 正确的租户码 → 正常登录", same.get("code") == 0, f"{same}")
# ── 3. ★ Host 只用于定位,授权以 token 为准 ────────────
print("\n[3] ★ Host 与 token 的租户不一致 → 必须拒绝")
check("拿 t1 的 token 访问 t1 域名 → 正常",
call("GET", "/api/v1/users?pageNum=1&pageSize=1", "t1.localhost", token1).get("code") == 0,
"同租户被误拒")
# 14 号租户是 e2e 建的(status=1,存在且启用),所以 t14.localhost 能被解析成一个真实租户
cross = call("GET", "/api/v1/users?pageNum=1&pageSize=1", "t14.localhost", token1)
check("**拿 t1 的 token 访问 t14 域名 → 403(拒绝)**",
cross.get("code") == 40301 or cross.get("_http") == 403
or "另一个单位" in (cross.get("msg") or ""), f"{cross}")
check("拒绝时给出可理解的提示(不是一句「无权限」)",
"另一个单位" in (cross.get("msg") or ""), f"{cross.get('msg')}")
# ── 4. 边界:不合法/不存在的前缀一律回退到老路径 ────────
print("\n[4] 边界:解析不出来就当「没有租户域名」,回退到老路径")
check("前缀不是 t{数字}(x1.localhost)→ 回退(仍 40012)",
login("x1.localhost", None).get("code") == 40012, "非 t 前缀被误当租户")
check("租户不存在(t999999.localhost)→ 回退(仍 40012,而不是「找不到此人」)",
login("t999999.localhost", None).get("code") == 40012,
f"{login('t999999.localhost', None)}")
check("主域名本身(localhost)= 平台入口,不属于任何租户",
login("localhost", "demo").get("code") == 0, "主域名 + 租户码被误拒")
check("无关域名(example.com)不解析",
login("example.com", None).get("code") == 40012, "无关域名被误当租户")
print()
print("=" * 76)
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
if FAILED:
for n, det in FAILED:
print(f" · {n}\n {det}")
print("=" * 76)
return 1 if FAILED else 0
def detect_mode() -> str:
"""
探测后端有没有启用租户域名:用 t1.localhost 且不填租户码试一次登录。
为什么要探测而不是写死:客户 2026-09-29 决定"先不做",默认已关;
但这个能力保留着(填个域名就能开)。脚本两种模式都要能跑,
而且**两种模式的断言是不同的** —— 关闭态要验的是"行为与从前完全一致"。
"""
r = login("t1.localhost", None)
if r.get("code") == 0:
return "ON"
if r.get("code") == 40012:
return "OFF"
raise SystemExit(f" ✗ 探测失败(既不是 0 也不是 40012):{r}")
def run_off() -> int:
"""
关闭态(当前默认):验"关掉之后,行为与从前**完全一致**"。
这一节看着简单,但它是"先不做"这件事的验收 ——
关一个功能键,最怕的是关不干净(悄悄留下一点新行为)。
"""
print("=" * 76)
print("租户域名 E2E —— 当前是**关闭态**(客户先不做,能力保留)")
print("=" * 76)
print("\n[关闭态] 域名不再提供租户身份,老路径完全不变")
a = login("t1.localhost", None)
check("t1.localhost + 不填租户码 → 40012(域名不参与判断)",
a.get("code") == 40012, f"{a}")
b = login("t1.localhost", "demo")
check("同一域名 + 填租户码 → 照样能登(老路径不受影响)", b.get("code") == 0, f"{b}")
c = login("localhost", "demo")
check("主域名 + 租户码 → 照样能登", c.get("code") == 0, f"{c}")
d = login("localhost", None)
check("主域名 + 不填租户码 → 仍是 40012(跨单位重名的原行为)",
d.get("code") == 40012, f"{d}")
token = (b.get("data") or {}).get("token")
e = call("GET", "/api/v1/users?pageNum=1&pageSize=1", "t14.localhost", token)
check("关掉之后**不引入任何新校验**:带着 t1 的 token 访问 t14 域名照常通过",
e.get("code") == 0,
f"{e}(这是关闭前的既有行为;开启态下会被 403 拒掉,见 run_on)")
ok("要验开启态:以 XT_TENANT_DOMAIN_ENABLED=true 重启后端,再跑本脚本")
print()
print("=" * 76)
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
if FAILED:
for n, det in FAILED:
print(f" · {n}\n {det}")
print("=" * 76)
return 1 if FAILED else 0
def main() -> int:
mode = detect_mode()
if mode == "ON":
return run_on()
return run_off()
if __name__ == "__main__":
raise SystemExit(main())