Files
xiaotutongxue/scripts/e2e-m1-tenant-domain.py
T
lovife 9f6d26c650 fix(e2e,pc): 不再产生跨租户同名的登录账号
账号唯一性是 (tenant_id, username),跨租户重名本身是合法行为(登录靠租户码区分);但默认值与 e2e 残留会让它变成默认状态:不带租户码登录会撞上 40012(该账号存在于多个单位),平台侧看账号也分不清哪个 admin 属于哪所学校。三处一起收口 ——

1) scripts/e2e-m0.py 跑完不再留租户:以前每跑一次就留下一个 e2e###### 租户 + 里面叫 admin 的管理员 + demo 下 3 个测试组织,库里那两个孤儿租户就是这么来的。现在按本次跑出的 id 自清(崩溃路径也兜住),不越界成 LIKE 'e2e%' 全清。

2) scripts/e2e-m1-tenant-domain.py 自己造、自己清「另一个租户里也有 admin」的夹具:它原本靠第 1 条的残留凑出 40012 这个前提,残留一被清掉就会静默反转成 8 条 FAIL。顺带修掉同族的第二处隐式依赖 —— 它假定 t14.localhost 是个真实租户,而那个租户早就不存在了。

3) PC 端「开通租户」的登录账号默认值不再写死 admin,改为跟随租户编码生成 admin_<编码>;运营一旦手改就不再被编码覆盖。

验证:全量 python scripts/verify-all.py --with-e2e 通过(37 脚本 / 1597 断言 / 0 失败),跑完库里仍只有 1 个租户、0 处跨租户重名;PC 端 pnpm typecheck 0 错、pnpm lint 干净、check-wiring 未接线 0 条,浏览器实演默认值跟随与手改不回退。
2026-10-13 10:21:40 +08:00

364 lines
17 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 在跑;能 `docker exec xt-pg`(造/清"跨租户同名账号"夹具)。
开关开与关都能跑(dev 默认 enabled=false,脚本自己探测)。
⚠️ 「账号跨单位重名」(40012)这个前提,本脚本**自己造、自己清**(2026-10 改)
40012 只有在**真的有两个租户用同一个账号名**时才会触发。以前这里靠的是
"E2E 残留的测试租户凑巧也建了一个 admin" —— 那是个隐式依赖:
跑一次 `python scripts/dev-clean-e2e.py` 把残留清掉,本脚本就会**静默反转**
(账号变唯一 → 不填租户码也能直接登录 → 一整套 40012 断言全反,
连 `detect_mode()` 都会把关闭态误判成开启态,报 8 条莫名其妙的 FAIL)。
所以现在:跑之前建一个 `e2edup######` 租户 + 里面的 admin,跑完(含中途崩溃)删掉。
夹具在,40012 才是可复现的;夹具不在,dev 库里就不该有同名账号。
顺带修掉了同一族的第二处隐式依赖:原脚本假定 `t14.localhost` 是"e2e 建的、
存在且启用的另一个租户",而租户 14 早就被清掉了 —— 改成用夹具租户自己的 id。
"""
from __future__ import annotations
import argparse
import base64
import http.client
import json
import os
import subprocess
import sys
import time
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")
PG_CONTAINER = os.environ.get("XT_PG_CONTAINER", "xt-pg")
PG_USER = os.environ.get("PG_USER", "fill")
PG_DB = os.environ.get("PG_DB", "filldb")
PG_PWD = os.environ.get("PG_PASSWORD", "fill_dev_pwd")
PASSED: list[str] = []
FAILED: list[tuple[str, str]] = []
#: 本脚本造出来的「跨租户同名账号」夹具租户 id。模块级 —— 脚本中途抛异常也要能清掉。
FIXTURE_TENANT_IDS: list[int] = []
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 sql(stmt: str) -> tuple[bool, str]:
"""
直接执行 SQL —— 只用于造/清夹具。
⚠️ 造数据时用 `WITH … AS (INSERT … RETURNING id) SELECT id FROM …` 的写法,
**不要**直接 `INSERT … RETURNING id`:后者 psql 会把返回值与命令标签
(`INSERT 0 1`)一起打出来,解析的人一不留神就把 `INSERT 0 1` 当成 id 了。
"""
try:
r = subprocess.run(
["docker", "exec", "-e", f"PGPASSWORD={PG_PWD}", PG_CONTAINER,
"psql", "-U", PG_USER, "-d", PG_DB, "-t", "-A", "-c", stmt],
capture_output=True, text=True, timeout=40)
return r.returncode == 0, (r.stdout or r.stderr).strip()
except Exception as e: # noqa: BLE001
return False, str(e)
def create_duplicate_fixture() -> int | None:
"""
造一个「另一个租户里也有一个叫 admin 的账号」。
只造登录定位真正需要的两行:`sys_tenant` + `sys_user`。
**故意不给密码** —— 40012 在比对密码之前就抛了(`AuthService.locateUser`),
给密码只会让人误以为这个账号能登进去。
租户编码用 `e2edup` 前缀:万一脚本被强杀(SIGKILL,`finally` 跑不到),
`scripts/dev-clean-e2e.py`(按 `tenant_code LIKE 'e2e%'` 收)还能兜底清掉。
"""
code = f"e2edup{str(int(time.time()))[-6:]}"
okc, out = sql(
"WITH ins AS (INSERT INTO sys_tenant "
"(tenant_code, tenant_name, tenant_type, contact_name, status) "
f"VALUES ('{code}', 'E2E 重名夹具', 1, '测试联系人', 1) RETURNING id) "
"SELECT id FROM ins")
# 取**第一行**判 id:psql 可能把命令标签(`INSERT 0 1`)也打在同一份输出里。
# ⚠️ 这里踩过一次:整串 isdigit() 判不过 → 直接 return None,
# 而那时 id 还没登记进 FIXTURE_TENANT_IDS → 租户行留在库里没人清。
first = out.strip().splitlines()[0].strip() if out.strip() else ""
if not okc or not first.isdigit():
print(f" ✗ 造重名夹具失败:{out}")
return None
tid = int(first)
# 拿到 id 立刻登记:后面任何一步失败,它都能被清掉
FIXTURE_TENANT_IDS.append(tid)
okc, out = sql(
"WITH ins AS (INSERT INTO sys_user "
"(tenant_id, username, real_name, user_type, status) "
f"VALUES ({tid}, 'admin', 'E2E 重名夹具', 2, 1) RETURNING id) "
"SELECT id FROM ins")
if not okc:
print(f" ✗ 造重名夹具账号失败:{out}")
return None
print(f" 已造重名夹具:租户 id={tid}({code}),里面也有一个 admin")
return tid
def cleanup_fixture() -> None:
"""删掉夹具。**幂等** —— main 的 finally 与函数内的显式调用各来一次。"""
for tid in list(FIXTURE_TENANT_IDS):
sql(f"DELETE FROM sys_user WHERE tenant_id = {tid}")
sql(f"DELETE FROM sys_tenant WHERE id = {tid}")
FIXTURE_TENANT_IDS.remove(tid)
def cleanup_and_verify() -> None:
"""
用完就删,并**断言"同名账号"这个前提真的消失了**。
这条断言守的是本脚本自己的副作用:夹具没清干净的话,
下一轮跑起来前提就是假的,而 dev 库里会多出两个同名 admin ——
正是"不同租户不要出现相同账号名"要清掉的那种垃圾。
"""
print("\n[清理] 重名夹具用完即删")
cleanup_fixture()
again = login("localhost", None)
check("夹具删除后 demo 的 admin 又变回唯一 → 不填租户码可直接登录",
again.get("code") == 0,
f"code={again.get('code')} msg={again.get('msg')}"
"(库里还有别的同名 admin:先跑 python scripts/dev-clean-e2e.py 清残留)")
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(fixture_tid: int) -> 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,
"同租户被误拒")
# 夹具租户就是"存在、启用、且不是 t1"的另一个真实租户,
# 所以 t{夹具id}.localhost 能被解析成一个真实租户
cross = call("GET", "/api/v1/users?pageNum=1&pageSize=1", f"t{fixture_tid}.localhost", token1)
check(f"**拿 t1 的 token 访问 t{fixture_tid} 域名 → 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, "无关域名被误当租户")
cleanup_and_verify()
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(fixture_tid: int) -> 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", f"t{fixture_tid}.localhost", token)
check(f"关掉之后**不引入任何新校验**:带着 t1 的 token 访问 t{fixture_tid} 域名照常通过",
e.get("code") == 0,
f"{e}(这是关闭前的既有行为;开启态下会被 403 拒掉,见 run_on)")
ok("要验开启态:以 XT_TENANT_DOMAIN_ENABLED=true 重启后端,再跑本脚本")
cleanup_and_verify()
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:
# 夹具必须在**探测模式之前**造好:detect_mode() 正是靠"不填租户码会不会 40012"
# 判断开关的,而没有同名账号时那条路会直接登录成功 → 关闭态被误判成开启态(见文件头 ⚠️)
fixture_tid = create_duplicate_fixture()
if fixture_tid is None:
return 1
try:
mode = detect_mode()
return run_on(fixture_tid) if mode == "ON" else run_off(fixture_tid)
finally:
# 中途崩溃也要清 —— 否则"清残留"这件事自己开始制造残留
cleanup_fixture()
if __name__ == "__main__":
raise SystemExit(main())