9f6d26c650
账号唯一性是 (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 条,浏览器实演默认值跟随与手改不回退。
364 lines
17 KiB
Python
364 lines
17 KiB
Python
# -*- 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())
|