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 条,浏览器实演默认值跟随与手改不回退。
This commit is contained in:
2026-10-13 10:21:40 +08:00
parent 7df078e0ea
commit 9f6d26c650
3 changed files with 275 additions and 18 deletions
+47 -3
View File
@@ -18,7 +18,7 @@ import {
NTag, NTag,
useMessage useMessage
} from 'naive-ui' } from 'naive-ui'
import { computed, h, onMounted, reactive, ref } from 'vue' import { computed, h, onMounted, reactive, ref, watch } from 'vue'
import * as tenantApi from '@/apis/xt/tenant' import * as tenantApi from '@/apis/xt/tenant'
import { TENANT_STATUS, TENANT_TYPE_NAMES } from '@/apis/xt/tenant' import { TENANT_STATUS, TENANT_TYPE_NAMES } from '@/apis/xt/tenant'
import { listPagination } from '@/composables/pagination' import { listPagination } from '@/composables/pagination'
@@ -55,6 +55,26 @@ const created = ref<TenantCreateResult | null>(null)
const createdName = ref('') const createdName = ref('')
const formRef = ref<FormInst | null>(null) const formRef = ref<FormInst | null>(null)
/**
* 「首个管理员的登录账号」默认值**按租户编码生成**(`admin_jxjd`),不是写死的 `admin`。
*
* ⚠️ 为什么不能用固定默认值:账号唯一性是 `(tenant_id, username)`,即"跨租户重名"
* 本身是被允许的(登录时靠租户码区分)。但**默认值**固定成 `admin` 的后果是
* "运营不动手,每开一所学校就多一个 `admin`" —— 于是不带租户码登录会撞上
* `40012 该账号存在于多个单位`,平台侧看账号也分不清是哪所学校的。
* 默认值跟着编码走,开一所学校天然不重名;运营仍可以改成自己想要的账号名。
*/
const ADMIN_USERNAME_PREFIX = 'admin_'
/** 人有没有手动改过登录账号:改过就不再被编码覆盖(否则会把人填的值冲掉) */
const adminUsernameTouched = ref(false)
/** 上一次**自动填出来**的值。用它区分"人改的"与"我们填的"(两者改的是同一个字段) */
let autoUsername = ''
function deriveAdminUsername(code: string): string {
const c = code.trim().toLowerCase()
return c ? `${ADMIN_USERNAME_PREFIX}${c}` : ''
}
function emptyForm() { function emptyForm() {
return { return {
tenantName: '', tenantName: '',
@@ -65,7 +85,7 @@ function emptyForm() {
// 用 null 而不是 '':n-date-picker 清空时会回传 null, // 用 null 而不是 '':n-date-picker 清空时会回传 null,
// 声明成 string 会在 v-model 上产生类型打架(运行期没事,但 vue-tsc 会红) // 声明成 string 会在 v-model 上产生类型打架(运行期没事,但 vue-tsc 会红)
expireTime: null as string | null, expireTime: null as string | null,
adminUsername: 'admin', adminUsername: '',
adminRealName: '', adminRealName: '',
adminPhone: '' adminPhone: ''
} }
@@ -199,6 +219,9 @@ function setStatus(v: number | undefined): void {
function onAdd(): void { function onAdd(): void {
Object.assign(form, emptyForm()) Object.assign(form, emptyForm())
// 每次打开弹窗都重新开始"跟着编码走"的状态,否则第二次打开时人会发现自己改不动账号名
adminUsernameTouched.value = false
autoUsername = ''
created.value = null created.value = null
visible.value = true visible.value = true
} }
@@ -251,6 +274,23 @@ function closeCreated(): void {
visible.value = false visible.value = false
} }
// ── 登录账号跟着租户编码走(默认值,人一改就交还给填写者) ──
watch(() => form.tenantCode, (code) => {
if (adminUsernameTouched.value) {
return
}
autoUsername = deriveAdminUsername(code)
form.adminUsername = autoUsername
})
watch(() => form.adminUsername, (v) => {
// 只有"值不是我们刚填进去的那个"才算人改的 —— 少了这一层,
// 自动填充本身就会把字段标成"已手改",编码再变一次就再也不跟了
if (v !== autoUsername) {
adminUsernameTouched.value = true
}
})
onMounted(load) onMounted(load)
</script> </script>
@@ -399,7 +439,11 @@ onMounted(load)
</div> </div>
<div class="tenant__grid tenant__grid--3"> <div class="tenant__grid tenant__grid--3">
<NFormItem label="登录账号" path="adminUsername"> <NFormItem label="登录账号" path="adminUsername">
<NInput v-model:value="form.adminUsername" clearable /> <NInput
v-model:value="form.adminUsername"
placeholder="按租户编码自动生成,如 admin_jxjd"
clearable
/>
</NFormItem> </NFormItem>
<NFormItem label="姓名" path="adminRealName"> <NFormItem label="姓名" path="adminRealName">
<NInput v-model:value="form.adminRealName" clearable /> <NInput v-model:value="form.adminRealName" clearable />
+94 -2
View File
@@ -16,7 +16,14 @@ M0 验收测试。
退出码:0 = 全部通过;1 = 有失败项(可直接用于 CI)。 退出码:0 = 全部通过;1 = 有失败项(可直接用于 CI)。
依赖:cryptography 依赖:cryptography;且需要能 `docker exec xt-pg`(**清理本脚本造出的测试租户与测试组织**)。
⚠️ 关于"跑完要清干净"(2026-10 补)
本脚本以前**完全不清理**:每跑一次就留下一个 `e2e######` 租户(里面有一个叫 `admin`
的管理员)+ demo 租户下的 3 个测试组织。跑两轮之后,库里同一时刻就存在两个
`admin` —— 而"账号跨单位重名"这条保护(40012)一旦被别的东西当作前提,
清理残留这一个动作就会把那个脚本**静默反转**(见 `e2e-m1-tenant-domain.py` 文件头)。
所以:跑完(含中途崩溃)必须把自己造的东西删干净,且**只删自己建的**。
""" """
from __future__ import annotations from __future__ import annotations
@@ -24,6 +31,7 @@ import argparse
import base64 import base64
import json import json
import os import os
import subprocess
import sys import sys
import time import time
import urllib.error import urllib.error
@@ -35,9 +43,18 @@ if hasattr(sys.stdout, "reconfigure"):
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PUB_KEY = os.path.join(ROOT, "deploy", "rsa-public-key-dev.pem") 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] = [] PASSED: list[str] = []
FAILED: list[tuple[str, str]] = [] FAILED: list[tuple[str, str]] = []
#: 本脚本造出来的东西。放模块级 —— 脚本中途抛异常时也要能被清掉。
CREATED_TENANT_IDS: list[int] = []
CREATED_ORG_IDS: list[int] = []
# ── HTTP ────────────────────────────────────────────── # ── HTTP ──────────────────────────────────────────────
@@ -129,6 +146,58 @@ def finish() -> int:
return 0 if not FAILED else 1 return 0 if not FAILED else 1
# ── 清理 ──────────────────────────────────────────────
def sql(stmt: str) -> tuple[bool, str]:
"""直接执行 SQL —— 只用于清理测试夹具。"""
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)
_TENANT_TABLES: list[str] | None = None
def tables_with_tenant_id() -> list[str]:
"""
带 `tenant_id` 的表 —— **动态枚举**,不手抄表名。
手抄的清单在"新增了一张业务表"那天就开始漏,而漏掉的那张表恰好很难被注意到
(租户没了、行还在)。`dev-clean-e2e.py` 是同一口径。
"""
global _TENANT_TABLES
if _TENANT_TABLES is None:
_, out = sql("SELECT table_name FROM information_schema.columns "
"WHERE table_schema='public' AND column_name='tenant_id'")
_TENANT_TABLES = [t.strip() for t in out.splitlines() if t.strip()]
return _TENANT_TABLES
def cleanup_all() -> None:
"""
删掉本脚本造出来的数据。**幂等**,可以重复调用。
⚠️ 只删**自己建的**(按本次跑出来的 id):这里刻意不做
`tenant_code LIKE 'e2e%'` 那种"顺手全清" —— 那是 `dev-clean-e2e.py` 的活。
测试脚本越界删别人建的东西,就会让"跑一个脚本把另一个脚本的在途数据删了"
这种事变得可能。
"""
for oid in list(CREATED_ORG_IDS):
sql(f"DELETE FROM sys_org WHERE id = {oid}")
CREATED_ORG_IDS.remove(oid)
for tid in list(CREATED_TENANT_IDS):
for table in tables_with_tenant_id():
sql(f"DELETE FROM {table} WHERE tenant_id = {tid}")
sql(f"DELETE FROM sys_tenant WHERE id = {tid}")
CREATED_TENANT_IDS.remove(tid)
# ── 用例 ────────────────────────────────────────────── # ── 用例 ──────────────────────────────────────────────
def main() -> int: def main() -> int:
@@ -216,6 +285,8 @@ def main() -> int:
expect_code("B2 平台运营开通租户成功", b, 0) expect_code("B2 平台运营开通租户成功", b, 0)
created = data_of(b) created = data_of(b)
new_tenant_id = created.get("id") new_tenant_id = created.get("id")
if isinstance(new_tenant_id, int):
CREATED_TENANT_IDS.append(new_tenant_id) # 登记,收尾与崩溃路径都要清它
new_pwd = created.get("adminInitialPassword") new_pwd = created.get("adminInitialPassword")
check("B3 返回初始密码(仅此一次)", bool(new_pwd), json.dumps(created, ensure_ascii=False)[:200]) check("B3 返回初始密码(仅此一次)", bool(new_pwd), json.dumps(created, ensure_ascii=False)[:200])
check("B4 返回小程序码参数", created.get("miniProgramScene") == f"t{new_tenant_id}", check("B4 返回小程序码参数", created.get("miniProgramScene") == f"t{new_tenant_id}",
@@ -262,6 +333,10 @@ def main() -> int:
b = mk_org(demo_token, dept_id, f"E2E 24数控1班 {stamp}", 3, f"CLS{stamp}") b = mk_org(demo_token, dept_id, f"E2E 24数控1班 {stamp}", 3, f"CLS{stamp}")
expect_code("C3 建班级节点", b, 0) expect_code("C3 建班级节点", b, 0)
class_id = b.get("data") class_id = b.get("data")
# 登记(C1/C2/C3 若失败,对应的 data 是 None,不能登记成 0/None)
for oid in (school_id, dept_id, class_id):
if isinstance(oid, int):
CREATED_ORG_IDS.append(oid)
expect_code("C4 同级重复组织编码被拒(40001)", expect_code("C4 同级重复组织编码被拒(40001)",
mk_org(demo_token, school_id, "重复编码", 2, f"DEPT{stamp}"), 40001) mk_org(demo_token, school_id, "重复编码", 2, f"DEPT{stamp}"), 40001)
@@ -321,8 +396,25 @@ def main() -> int:
check("E4 参数校验失败返回 40001 且带字段级信息", b.get("code") == 40001, check("E4 参数校验失败返回 40001 且带字段级信息", b.get("code") == 40001,
f"code={b.get('code')}") f"code={b.get('code')}")
# ── F. 清理 ───────────────────────────────────────
print("\n[F] 清理本次造出的测试数据")
n_tenant, n_org = len(CREATED_TENANT_IDS), len(CREATED_ORG_IDS)
cleanup_all()
print(f" 已清理 {n_tenant} 个测试租户(连它的角色与账号)"
f"、{n_org} 个挂在 demo 下的测试组织")
check("F1 测试租户与测试组织都删掉了", not CREATED_TENANT_IDS and not CREATED_ORG_IDS,
f"tenant={CREATED_TENANT_IDS} org={CREATED_ORG_IDS}")
return finish() return finish()
if __name__ == "__main__": if __name__ == "__main__":
raise SystemExit(main()) # 崩溃也要清:否则每次失败的运行都往库里丢一个 e2e 租户和一个叫 admin 的账号
# (跨租户同名账号就是这么攒出来的,见文件头 ⚠️)
try:
_code = main()
except BaseException:
print("\n ⚠ 脚本异常退出,正在清理已造出的测试数据…")
cleanup_all()
raise
raise SystemExit(_code)
+134 -13
View File
@@ -14,8 +14,20 @@
所以这条比第 ① 条更重要。 所以这条比第 ① 条更重要。
跑法:python scripts/e2e-m1-tenant-domain.py 跑法:python scripts/e2e-m1-tenant-domain.py
前提:后端 8080 在跑,且 `fill.tenant-domain` 已启用(dev 默认 前提:后端 8080 在跑;能 `docker exec xt-pg`(造/清"跨租户同名账号"夹具)。
enabled=true / base=localhost —— 只有 *.localhost 会走这条路,不影响其它域名)。 开关开与关都能跑(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 from __future__ import annotations
@@ -24,16 +36,26 @@ import base64
import http.client import http.client
import json import json
import os import os
import subprocess
import sys import sys
import time
sys.stdout.reconfigure(encoding="utf-8") sys.stdout.reconfigure(encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PUB_KEY = os.path.join(ROOT, "deploy", "rsa-public-key-dev.pem") 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] = [] PASSED: list[str] = []
FAILED: list[tuple[str, str]] = [] FAILED: list[tuple[str, str]] = []
#: 本脚本造出来的「跨租户同名账号」夹具租户 id。模块级 —— 脚本中途抛异常也要能清掉。
FIXTURE_TENANT_IDS: list[int] = []
def ok(name: str) -> None: def ok(name: str) -> None:
PASSED.append(name) PASSED.append(name)
@@ -53,6 +75,93 @@ def check(name: str, cond: bool, detail: str = "") -> bool:
return cond 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: def rsa_encrypt(plain: str) -> str:
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding from cryptography.hazmat.primitives.asymmetric import padding
@@ -94,7 +203,7 @@ def login(host: str, tenant_code: str | None = None, account: str = "admin",
return call("POST", "/api/v1/auth/login", host, body=body) return call("POST", "/api/v1/auth/login", host, body=body)
def run_on() -> int: def run_on(fixture_tid: int) -> int:
ap = argparse.ArgumentParser(description="租户域名 E2E") ap = argparse.ArgumentParser(description="租户域名 E2E")
ap.add_argument("--password", default="Admin@123") ap.add_argument("--password", default="Admin@123")
args = ap.parse_args() args = ap.parse_args()
@@ -144,9 +253,10 @@ def run_on() -> int:
check("拿 t1 的 token 访问 t1 域名 → 正常", check("拿 t1 的 token 访问 t1 域名 → 正常",
call("GET", "/api/v1/users?pageNum=1&pageSize=1", "t1.localhost", token1).get("code") == 0, call("GET", "/api/v1/users?pageNum=1&pageSize=1", "t1.localhost", token1).get("code") == 0,
"同租户被误拒") "同租户被误拒")
# 14 号租户是 e2e 建的(status=1,存在且启用),所以 t14.localhost 能被解析成一个真实租户 # 夹具租户就是"存在、启用、且不是 t1"的另一个真实租户,
cross = call("GET", "/api/v1/users?pageNum=1&pageSize=1", "t14.localhost", token1) # 所以 t{夹具id}.localhost 能被解析成一个真实租户
check("**拿 t1 的 token 访问 t14 域名 → 403(拒绝)**", 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 cross.get("code") == 40301 or cross.get("_http") == 403
or "另一个单位" in (cross.get("msg") or ""), f"{cross}") or "另一个单位" in (cross.get("msg") or ""), f"{cross}")
check("拒绝时给出可理解的提示(不是一句「无权限」)", check("拒绝时给出可理解的提示(不是一句「无权限」)",
@@ -164,6 +274,8 @@ def run_on() -> int:
check("无关域名(example.com)不解析", check("无关域名(example.com)不解析",
login("example.com", None).get("code") == 40012, "无关域名被误当租户") login("example.com", None).get("code") == 40012, "无关域名被误当租户")
cleanup_and_verify()
print() print()
print("=" * 76) print("=" * 76)
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}") print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
@@ -190,7 +302,7 @@ def detect_mode() -> str:
raise SystemExit(f" ✗ 探测失败(既不是 0 也不是 40012):{r}") raise SystemExit(f" ✗ 探测失败(既不是 0 也不是 40012):{r}")
def run_off() -> int: def run_off(fixture_tid: int) -> int:
""" """
关闭态(当前默认):验"关掉之后,行为与从前**完全一致**"。 关闭态(当前默认):验"关掉之后,行为与从前**完全一致**"。
@@ -214,13 +326,15 @@ def run_off() -> int:
d.get("code") == 40012, f"{d}") d.get("code") == 40012, f"{d}")
token = (b.get("data") or {}).get("token") token = (b.get("data") or {}).get("token")
e = call("GET", "/api/v1/users?pageNum=1&pageSize=1", "t14.localhost", token) e = call("GET", "/api/v1/users?pageNum=1&pageSize=1", f"t{fixture_tid}.localhost", token)
check("关掉之后**不引入任何新校验**:带着 t1 的 token 访问 t14 域名照常通过", check(f"关掉之后**不引入任何新校验**:带着 t1 的 token 访问 t{fixture_tid} 域名照常通过",
e.get("code") == 0, e.get("code") == 0,
f"{e}(这是关闭前的既有行为;开启态下会被 403 拒掉,见 run_on)") f"{e}(这是关闭前的既有行为;开启态下会被 403 拒掉,见 run_on)")
ok("要验开启态:以 XT_TENANT_DOMAIN_ENABLED=true 重启后端,再跑本脚本") ok("要验开启态:以 XT_TENANT_DOMAIN_ENABLED=true 重启后端,再跑本脚本")
cleanup_and_verify()
print() print()
print("=" * 76) print("=" * 76)
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}") print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
@@ -232,10 +346,17 @@ def run_off() -> int:
def main() -> int: def main() -> int:
mode = detect_mode() # 夹具必须在**探测模式之前**造好:detect_mode() 正是靠"不填租户码会不会 40012"
if mode == "ON": # 判断开关的,而没有同名账号时那条路会直接登录成功 → 关闭态被误判成开启态(见文件头 ⚠️)
return run_on() fixture_tid = create_duplicate_fixture()
return run_off() 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__": if __name__ == "__main__":