fix: 终审修复——站点停用接线/prefix查重补创建reset/默认凭据警示+取舍补录

- I-1 站点停用全链接线: Site 数据类加 status 字段; DB 源 _load_one 与
  文件源 read_tenant 均只输出 active 站点(停用站点域名不进 registry
  索引 → 该域名 404;全停用=合法运营状态输出空 sites;文件源无 status
  键默认 active 兼容)。E2E: PATCH 站点 disabled → 其域名 404、复启用
  恢复;源级直测两源过滤。
- I-3 prefix 跨租户查重补两口: db.other_push_prefixes 共用助手
  (MAX(version) 逐租户当前前缀,排除自身);创建向导(出厂档案前缀被占
  → 400 提示改 blank/改占用方)与 reset push(重置回出厂撞车 → 400 提示
  走 PUT)接入;PUT 原有查重改用同一助手。rollback 维持豁免(历史不可变
  语义优先,取舍清单记档)。既有测试第二租户改 blank profile 避撞。
- I-4 默认凭据警示: lifespan 启动检测 ADMIN_PASSWORD 仍为 Settings 字段
  默认值(非硬编码第二份,防双源漂移)→ logger.warning 醒目提示;
  CLAUDE.md Phase 4 部署记档补 ADMIN_PASSWORD 生产要求。
- I-2/M-2 取舍补录: 密钥掩码未实现(GET config/settings 明文返回
  api_key/smtp 密码,Phase 3 前须定 __SET__ 哨兵方案);站点管理权限现为
  仅超管(Phase 3 对接时决策放开或改 spec);取舍清单第 2 条同步刷新
  (rollback 现为唯一豁免口)。

全量回归: 571 passed(基线 566 + 新增 5)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-13 12:40:21 +08:00
parent 6cd343e24a
commit 6839cb94d2
14 changed files with 281 additions and 52 deletions
+2 -1
View File
@@ -188,7 +188,8 @@ def test_traversal_safe(super_client, db_source_app, tmp_path):
def test_upload_auth_boundaries(super_client):
_create(super_client, slug="demo")
_create(super_client, slug="other")
# blank profile: 第二个租户不撞 jiancai 的出厂 push.prefix(终审 I-3)
_create(super_client, slug="other", profile="blank")
url = f"{TENANTS}/demo/brand/avatar"
# 未认证 → 401
+30 -1
View File
@@ -350,6 +350,33 @@ def test_reset_blank_tenant_groups(super_client):
assert r.json()["config"] == DEFAULT_CONFIG["contact"]
def test_reset_push_prefix_collision(super_client):
"""终审 I-3:reset push 组补跨租户查重——本租户改过前缀后,出厂前缀被
别人取走,重置回出厂会撞车 → 400 提示改走 PUT 指定前缀。"""
_create(super_client, slug="jc1", profile="jiancai") # 出厂 ai-kefu-jc
_create(super_client, slug="au1", profile="auto") # 出厂 ai-kefu
# jc1 让出出厂前缀,au1 取走
assert _put_group(super_client, "jc1", "push",
{"prefix": "jc1-new"}).status_code == 200
assert _put_group(super_client, "au1", "push",
{"prefix": "ai-kefu-jc"}).status_code == 200
# 重置 jc1 的 push → 出厂 ai-kefu-jc 已被 au1 占用 → 400
r = super_client.post(f"{_cfg('jc1')}/push/reset",
headers=_auth(super_client))
assert r.status_code == 400
detail = r.json()["detail"]
assert "ai-kefu-jc" in detail and "占用" in detail
# 当前版本未被重置覆盖(仍是 jc1-new)
assert _get_config(super_client, "jc1")["config"]["push"]["prefix"] == "jc1-new"
# 撞车解除后(au1 改走)重置即可成功
assert _put_group(super_client, "au1", "push",
{"prefix": "au1-new"}).status_code == 200
r = super_client.post(f"{_cfg('jc1')}/push/reset",
headers=_auth(super_client))
assert r.status_code == 200
assert r.json()["config"]["prefix"] == "ai-kefu-jc"
# ---- 平台设置(仅超管;本阶段只落存储与接口) ----------------------------------------
@@ -423,7 +450,9 @@ def test_wall_group_round_trip_and_body_hygiene(super_client):
assert r.status_code == 400
# 首登未改密的租户管理员 → 409(与登录/改密之外的受保护端点同款门)
_create(super_client, slug="t2", admin="t2-admin", password="t2-pass-1")
# (blank: 第二个租户不撞 jiancai 出厂 push.prefix——终审 I-3 创建查重)
_create(super_client, slug="t2", admin="t2-admin", password="t2-pass-1",
profile="blank")
token = super_client.post(
LOGIN, json={"username": "t2-admin", "password": "t2-pass-1"}
).json()["token"]
+5 -3
View File
@@ -53,11 +53,12 @@ def _bearer(token):
return {"Authorization": f"Bearer {token}"}
def _create(client, slug="demo", admin=None, password=None, domains=None):
def _create(client, slug="demo", admin=None, password=None, domains=None,
profile="jiancai"):
return client.post(TENANTS, headers=_auth(client), json={
"name": f"{slug} 租户",
"slug": slug,
"profile": "jiancai",
"profile": profile,
"admin_username": admin or f"{slug}-admin",
"admin_password": password or f"{slug}-pass-1",
"domains": domains or [],
@@ -378,8 +379,9 @@ def test_push_logs_payload_lazy(super_client, tmp_path):
def test_tenant_scope_isolation(super_client, tmp_path):
_create(super_client, slug="jca", admin="jca-admin",
password="jca-pass-1")
# blank: 第二个租户不撞 jiancai 出厂 push.prefix(终审 I-3)
_create(super_client, slug="jcb", admin="jcb-admin",
password="jcb-pass-1")
password="jcb-pass-1", profile="blank")
async def populate(session):
at = datetime(2026, 1, 5, 12, 0, tzinfo=UTC)
+53 -1
View File
@@ -30,6 +30,7 @@ from app.platform.db import (
make_platform_engine,
)
from app.platform.registry import UnknownTenantError, TenantRegistry
from app.platform.tenant_config import TenantConfig
from app.platform.tenant_source import DbTenantSource, FileTenantSource
REPO_ROOT = Path(__file__).resolve().parents[1]
@@ -61,7 +62,7 @@ async def _make_engine(tmp_path):
async def _add_jiancai(engine, prefix="ai-kefu-jc", version=1,
config_json=None, with_version=True):
config_json=None, with_version=True, es_status="active"):
"""jiancai 租户 + main(JC_DOMAIN)+ es(brand_overlay)+ 版本行。"""
async with AsyncSession(engine, expire_on_commit=False) as session:
tenant = Tenant(slug="jiancai", name="HESUPIPE 建材", status="active")
@@ -69,6 +70,7 @@ async def _add_jiancai(engine, prefix="ai-kefu-jc", version=1,
await session.flush()
main = Site(tenant_id=tenant.id, slug="main", name="main")
es = Site(tenant_id=tenant.id, slug="es", name="es",
status=es_status,
brand_overlay={"title": "HESUPIPE Soporte"})
session.add_all([main, es])
await session.flush()
@@ -223,6 +225,34 @@ async def test_db_source_missing_prompts_group_raises(tmp_path):
await engine.dispose()
async def test_db_source_filters_disabled_sites(tmp_path):
"""终审 I-1:site.status != active 的站点不进 LoadedTenant(域名不进
registry 索引 → 该域名 404;site_by_slug 取不到)。es 停用后只剩
main;全部停用 → 空 sites(零站点校验只对库表形状)。"""
engine = await _make_engine(tmp_path)
try:
await _add_jiancai(engine, es_status="disabled")
lt = await DbTenantSource(engine).load_tenant("jiancai")
assert [s["slug"] for s in lt.sites] == ["main"] # es 被过滤
main = lt.sites[0]
assert main["status"] == "active"
# TenantConfig 消费侧:Site 数据类带 status,域名索引只含 main
config = TenantConfig.from_snapshot(
lt.slug, lt.name, lt.snapshot, sites=lt.sites, status=lt.status)
assert [s.slug for s in config.sites] == ["main"]
assert config.sites[0].status == "active"
# 全部停用 = 合法运营状态(门面全下线),输出空 sites 不炸
async with AsyncSession(engine, expire_on_commit=False) as session:
await session.execute(text(
"UPDATE sites SET status='disabled'"))
await session.commit()
lt2 = await DbTenantSource(engine).load_tenant("jiancai")
assert lt2.sites == []
finally:
await engine.dispose()
# ---- 文件源(直测;等价性由既有 test_registry 全绿证明)-----------------------
@@ -270,6 +300,28 @@ def test_file_source_read_tenant_rereads_disk(tmp_path):
assert src.read_tenant("t1").snapshot["push"]["prefix"] == "v2"
def test_file_source_filters_disabled_sites(tmp_path):
"""终审 I-1:tenant.json 站点带 status: "disabled" → 不进
LoadedTenant(文件源同步行为);无 status 键 → 默认 active,既有
种子零改动兼容。"""
_write_min_seed(tmp_path, {"t1": {
"slug": "t1", "name": "t1", "status": "active",
"sites": [
{"slug": "main", "domains": ["a.example.com"]}, # 无键=active
{"slug": "es", "status": "active", "domains": []},
{"slug": "down", "status": "disabled",
"domains": ["down.example.com"], "brand": {"title": "x"}},
],
"config": {}}})
lt = FileTenantSource(tmp_path / "seed").read_tenant("t1")
assert [s["slug"] for s in lt.sites] == ["main", "es"] # down 被滤
# 消费侧同构:from_snapshot 只见 active 站点
config = TenantConfig.from_snapshot(
lt.slug, lt.name, lt.snapshot, sites=lt.sites, status=lt.status)
assert [s.slug for s in config.sites] == ["main", "es"]
assert all(s.status == "active" for s in config.sites)
# ---- registry × DB 源(reload / upsert)--------------------------------------
+39
View File
@@ -325,3 +325,42 @@ def test_lifespan_platform_engine_seam_and_bootstrap(tmp_path, monkeypatch):
assert [u.username for u in users] == ["bootadmin"]
assert verify_password("boot-pw", users[0].password_hash, users[0].salt)
assert users[0].must_change_password is True
def test_default_admin_password_warns_at_startup(
tmp_path, monkeypatch, caplog):
"""终审 I-4:lifespan 启动时 ADMIN_PASSWORD 仍为出厂默认 → 醒目
WARNING(生产必须设环境变量);设了自定义值 → 无警示。比较基准 =
Settings 字段默认值(与 main.py 同式,改默认值判定自动跟随)。"""
import logging
from app.engine.config import settings
from app.main import app
from app.platform.registry import TenantRegistry
def _run_lifespan():
app.state.registry = TenantRegistry.load_from_dir(
SEED_DIR, data_dir=tmp_path / "tenants")
app.state.platform_engine = make_platform_engine(
tmp_path / "platform.db")
try:
with caplog.at_level(logging.WARNING, logger="app.main"):
with TestClient(app) as client:
assert client.get("/health").status_code == 200
finally:
app.state.registry = None
app.state.platform_engine = None
# 出厂默认密码 → WARNING 提及用户名与"默认密码"
monkeypatch.setattr(settings, "ADMIN_PASSWORD",
type(settings).model_fields["ADMIN_PASSWORD"].default)
_run_lifespan()
assert any("默认密码" in r.getMessage()
and settings.ADMIN_USERNAME in r.getMessage()
for r in caplog.records)
# 自定义密码 → 无该警示
caplog.clear()
monkeypatch.setattr(settings, "ADMIN_PASSWORD", "n0t-default-pw")
_run_lifespan()
assert not any("默认密码" in r.getMessage() for r in caplog.records)
+5 -4
View File
@@ -53,11 +53,11 @@ def _bearer(token):
return {"Authorization": f"Bearer {token}"}
def _create(client, slug, admin=None, password=None):
def _create(client, slug, admin=None, password=None, profile="jiancai"):
return client.post(TENANTS, headers=_auth(client), json={
"name": f"{slug} 租户",
"slug": slug,
"profile": "jiancai",
"profile": profile,
"admin_username": admin or f"{slug}-admin",
"admin_password": password or f"{slug}-pass-1",
"domains": [],
@@ -337,10 +337,11 @@ def test_cross_tenant_super_view(super_client, tmp_path):
合并含停用租户、跳过无库文件的未懒建租户;403/401 边界。"""
_create(super_client, slug="jca", admin="jca-admin",
password="jca-pass-1")
# blank: 第二/三个租户不撞 jiancai 出厂 push.prefix(终审 I-3)
_create(super_client, slug="jcb", admin="jcb-admin",
password="jcb-pass-1")
password="jcb-pass-1", profile="blank")
# jcc: 创建后从未 resolve——库文件不存在,合并视图应跳过(不建空库)
_create(super_client, slug="jcc")
_create(super_client, slug="jcc", profile="blank")
async def seed_a(session):
a1 = datetime(2026, 1, 5, 10, 0, tzinfo=UTC) # 北京 1/5
+52 -8
View File
@@ -249,8 +249,10 @@ def test_tenant_admin_cannot_create_tenant(super_client):
"name": "越权租户", "slug": "evil", "profile": "jiancai",
"admin_username": "evil-admin", "admin_password": "evil-pass-1"})
assert r.status_code == 403
# 对照: 超管可建
assert _create(super_client, slug="ok1").status_code == 201
# 对照: 超管可建(blank profile——jiancai 的出厂 prefix 已被 demo 占用,
# 见 test_create_second_same_profile_prefix_conflict)
assert _create(super_client, slug="ok1",
profile="blank").status_code == 201
# ---- _blankify 单测 --------------------------------------------------------------
@@ -306,9 +308,27 @@ def test_create_auto_profile(super_client, tmp_path):
assert r.json()["title"] == "HESU Support" # auto 档案身份
def test_create_second_same_profile_prefix_conflict(super_client, tmp_path):
"""终审 I-3:创建向导补 push.prefix 跨租户查重——同 profile 第二个租户
的出厂前缀必撞(jiancai=ai-kefu-jc),spec E1 前缀全局唯一创建期就拦;
blank 出厂前缀为空串,不参战。"""
assert _create(super_client, slug="jc1", profile="jiancai").status_code == 201
r = _create(super_client, slug="jc2", profile="jiancai")
assert r.status_code == 400
detail = r.json()["detail"]
assert "ai-kefu-jc" in detail and "push.prefix" in detail
# auto 前缀不同,不撞
assert _create(super_client, slug="au1", profile="auto").status_code == 201
# blank 出厂前缀为空 → 可建(创建后经配置 API 配前缀)
assert _create(super_client, slug="bl1", profile="blank").status_code == 201
# jc2 没落库(整事务未提交)
rows = _db_fetch(tmp_path, select(Tenant.slug).where(Tenant.slug == "jc2"))
assert rows == []
def test_domain_collision_reports_owner(super_client):
_create(super_client, slug="demo", domains=["demo.example.com"])
_create(super_client, slug="other")
_create(super_client, slug="other", profile="blank")
# 大写归一后同域 → 400 带现有归属(tenant + site)
r = super_client.post(f"{TENANTS}/other/sites/main/domains",
headers=_auth(super_client),
@@ -316,8 +336,9 @@ def test_domain_collision_reports_owner(super_client):
assert r.status_code == 400
detail = r.json()["detail"]
assert "demo" in detail and "main" in detail
# 创建向导的初始域名同样预检
r = _create(super_client, slug="third", domains=["demo.example.com"])
# 创建向导的初始域名同样预检(blank profile——排除 prefix 查重的干扰)
r = _create(super_client, slug="third", profile="blank",
domains=["demo.example.com"])
assert r.status_code == 400
@@ -384,7 +405,9 @@ def test_site_create_patch_overlay(super_client, tmp_path):
f"{TENANTS}/demo/sites/ghost", headers=_auth(super_client),
json={"name": "x"}).status_code == 404
# 站点停用持久化(解析级语义后续任务接线,此处钉字段落库+reload 不炸)
# 站点停用全链路(终审 I-1):字段落库 + reload 后数据源过滤停用站点——
# 其域名不进索引(经中间件 404,与租户停用的 503 语义差:门面下线≠服务
# 暂停),同租户其他站点照常;?site= 覆盖也取不到(回退默认门面)。
r = super_client.patch(f"{TENANTS}/demo/sites/es",
headers=_auth(super_client),
json={"status": "disabled"})
@@ -393,6 +416,27 @@ def test_site_create_patch_overlay(super_client, tmp_path):
.join(Tenant, Site.tenant_id == Tenant.id)
.where(Tenant.slug == "demo", Site.slug == "es"))
assert rows[0][0].status == "disabled"
r = super_client.get("/api/public/brand",
headers={"host": "es.demo.example.com"})
assert r.status_code == 404 # 停用站点域名 404
r = super_client.get("/api/public/brand",
headers={"host": "demo.example.com"})
assert r.status_code == 200 # main 站点不受影响
assert r.json()["title"] == "HESUPIPE Support" # 租户级门面(jiancai 档案)
# ?site=es 覆盖取不到停用站点 → 回退 main 门面(title 是租户级,非 ES v2)
r = super_client.get("/api/public/brand?site=es",
headers={"host": "demo.example.com"})
assert r.status_code == 200
assert r.json()["title"] != "Demo ES v2"
# 复启用 → 域名恢复服务
r = super_client.patch(f"{TENANTS}/demo/sites/es",
headers=_auth(super_client),
json={"status": "active"})
assert r.status_code == 200
assert super_client.get(
"/api/public/brand",
headers={"host": "es.demo.example.com"}).status_code == 200
def test_patch_tenant_name_and_validation(super_client, tmp_path):
@@ -417,7 +461,7 @@ def test_patch_tenant_name_and_validation(super_client, tmp_path):
def test_list_tenants(super_client):
_create(super_client, slug="demo",
domains=["a.example.com", "b.example.com"])
_create(super_client, slug="other")
_create(super_client, slug="other", profile="blank")
r = super_client.get(TENANTS, headers=_auth(super_client))
assert r.status_code == 200
items = {i["slug"]: i for i in r.json()["items"]}
@@ -433,7 +477,7 @@ def test_list_tenants(super_client):
def test_detail_shape_and_scope(super_client):
_create(super_client, slug="demo", admin="demo-admin",
password="demo-pass-1", domains=["demo.example.com"])
_create(super_client, slug="other")
_create(super_client, slug="other", profile="blank")
r = super_client.get(f"{TENANTS}/demo", headers=_auth(super_client))
assert r.status_code == 200
d = r.json()