"""租户管理 API(Phase 2 Task 5):超管的创建向导/站点/域名/停用 + 403 边界。 db_source_app/super_client 夹具(Task 6 起收拢到 conftest,与配置 API 测试 共用):app.state.registry 用 DbTenantSource(生产形态)指向临时 platform.db——创建 API 写库后 registry_reload 走 DB 源闭环(Task 4 的 upsert 语义:未加载的 slug 直接插入,创建即可用)。platform.db 起始 为空(schema+bootstrap 超管由 lifespan 跑)——租户全部经创建 API 进入, registry 不做初始 load_all(生产装配的启动次序归 Task 11)。 跨循环纪律:platform engine(pool=2 常驻连接)只在 TestClient portal 循环内使用(lifespan+路由);测试线程直查 platform.db 一律走 _db_fetch (独立 NullPool engine,即用即弃,绝不复用 portal 循环的常驻连接)。 """ import asyncio import json import pytest from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine from sqlalchemy.pool import NullPool from app.engine.services.ai_service import PromptProvider from app.engine.wall_schema import compile_wall from app.platform.db import ( AdminUser, Site, SiteDomain, Tenant, TenantConfigVersion, ) from app.platform.defaults import DEFAULT_CONFIG from app.platform.registry import TenantDisabledError from app.platform.tenant_source import FileTenantSource, _blankify from tests.conftest import SEED_DIR TENANTS = "/api/platform/tenants" LOGIN = "/api/platform/auth/login" CHANGE_PW = "/api/platform/auth/change-password" # ---- 助手 ---------------------------------------------------------------------- def _auth(client): return {"Authorization": f"Bearer {client.platform_token}"} def _bearer(token): return {"Authorization": f"Bearer {token}"} def _create(client, slug="demo", profile="jiancai", name=None, admin=None, password=None, domains=None): """创建租户的便捷 POST(admin/password 缺省按 slug 派生,免撞用户名)。""" return client.post(TENANTS, headers=_auth(client), json={ "name": name or f"{slug} 租户", "slug": slug, "profile": profile, "admin_username": admin or f"{slug}-admin", "admin_password": password or f"{slug}-pass-1", "domains": domains or [], }) def _tenant_admin_token(client, username, old_password, new_password="tn-pass-1"): """租户管理员登录 + 首登改密(过 409 门),返回可用 token。""" r = client.post(LOGIN, json={"username": username, "password": old_password}) assert r.status_code == 200 token = r.json()["token"] r = client.post(CHANGE_PW, headers=_bearer(token), json={ "old_password": old_password, "new_password": new_password}) assert r.status_code == 200 return token def _db_fetch(root, stmt): """测试线程直查 platform.db:独立 NullPool engine,同一 asyncio.run 内 查完即 dispose(跨循环纪律,见模块 docstring)。""" engine = create_async_engine( f"sqlite+aiosqlite:///{(root / 'platform.db').as_posix()}", poolclass=NullPool) async def _run(): try: async with AsyncSession(engine) as session: return list((await session.execute(stmt)).all()) finally: await engine.dispose() return asyncio.run(_run()) def _current_snapshot(root, slug): """该租户当前生效版本(MAX)的 config_json 解析。""" rows = _db_fetch(root, select(TenantConfigVersion) .join(Tenant, TenantConfigVersion.tenant_id == Tenant.id) .where(Tenant.slug == slug) .order_by(TenantConfigVersion.version.desc())) assert rows, f"租户 {slug} 没有配置版本" return json.loads(rows[0][0].config_json) def _jc_snapshot(): return FileTenantSource(SEED_DIR).read_tenant("jiancai").snapshot # ---- 核心 6 项 ------------------------------------------------------------------ def test_create_tenant_from_profile(super_client, db_source_app, tmp_path): r = _create(super_client, slug="demo", profile="jiancai", domains=["demo.example.com"]) assert r.status_code == 201 body = r.json() assert body["slug"] == "demo" assert body["status"] == "active" assert body["version"] == 1 assert body["site"] == {"slug": "main", "domains": ["demo.example.com"]} # platform.db: tenant + main 站点 + 版本1(config = seed 档案快照) rows = _db_fetch(tmp_path, select(Tenant).where(Tenant.slug == "demo")) assert len(rows) == 1 tenant = rows[0][0] assert tenant.status == "active" sites = _db_fetch(tmp_path, select(Site).where(Site.tenant_id == tenant.id)) assert [(row[0].slug, row[0].name) for row in sites] == [("main", "main")] versions = _db_fetch( tmp_path, select(TenantConfigVersion) .where(TenantConfigVersion.tenant_id == tenant.id)) assert len(versions) == 1 assert versions[0][0].version == 1 assert json.loads(versions[0][0].config_json) == _jc_snapshot() domains = _db_fetch( tmp_path, select(SiteDomain).join(Site, SiteDomain.site_id == Site.id) .where(Site.tenant_id == tenant.id)) assert [row[0].domain for row in domains] == ["demo.example.com"] # 租户管理员可登录(role=tenant, tenant_slug=demo, 引导改密标志) admins = _db_fetch( tmp_path, select(AdminUser).where(AdminUser.tenant_id == tenant.id)) assert len(admins) == 1 admin = admins[0][0] assert admin.username == "demo-admin" assert admin.must_change_password is True assert admin.password_hash.startswith("scrypt$") # 哈希在库,不在响应 r = super_client.post(LOGIN, json={ "username": "demo-admin", "password": "demo-pass-1"}) assert r.status_code == 200 data = r.json() assert data["role"] == "tenant" assert data["tenant_slug"] == "demo" assert data["must_change_password"] is True # 创建后立即可用: reload upsert 后经中间件 resolve 命中(DB 源闭环) r = super_client.get("/api/public/brand", headers={"host": "demo.example.com"}) assert r.status_code == 200 assert r.json()["slug"] == "demo" def test_create_blank_tenant(super_client, tmp_path): r = _create(super_client, slug="blank1", profile="blank", domains=["blank.example.com"]) assert r.status_code == 201 # 版本1快照: 身份组清零,wall/prompts 取 jiancai 档案 snapshot = _current_snapshot(tmp_path, "blank1") jc = _jc_snapshot() db_brand = DEFAULT_CONFIG["brand"] assert snapshot["brand"]["title"] == db_brand["title"] assert snapshot["brand"]["whatsapp"] == db_brand["whatsapp"] == "" assert snapshot["brand"]["email"] == db_brand["email"] == "" assert snapshot["brand"]["privacy_url"] == db_brand["privacy_url"] == "" assert snapshot["push"]["prefix"] == "" assert snapshot["engine"]["context_seed_keys"] == {} # 非身份字段与 wall/prompts 保留(jiancai 档案) assert snapshot["brand"]["color"] == jc["brand"]["color"] assert snapshot["brand"]["topics"] == jc["brand"]["topics"] assert snapshot["wall"] == jc["wall"] assert snapshot["prompts"] == jc["prompts"] # wall/prompts 可消费(compile_wall / PromptProvider 能过) compile_wall(snapshot["wall"]) PromptProvider.from_config(snapshot["prompts"]) # E2E: blank 租户对外门面是出厂身份 r = super_client.get("/api/public/brand", headers={"host": "blank.example.com"}) assert r.status_code == 200 assert r.json()["title"] == db_brand["title"] assert r.json()["whatsapp"] == "" def test_slug_immutable(super_client, tmp_path): _create(super_client, slug="demo", name="原名") r = super_client.patch(f"{TENANTS}/demo", headers=_auth(super_client), json={"slug": "demo2", "name": "新名"}) assert r.status_code == 400 assert "slug" in r.json()["detail"] # 400 先于字段应用: 名字没被捎带改掉,库里也只有原租户 rows = _db_fetch(tmp_path, select(Tenant)) assert [(row[0].slug, row[0].name) for row in rows] == [("demo", "原名")] def test_add_domain_and_resolve(super_client): _create(super_client, slug="demo") # domains=[] # 加域名前: 未知 Host 404 r = super_client.get("/api/public/brand", headers={"host": "demo.example.com"}) assert r.status_code == 404 r = super_client.post(f"{TENANTS}/demo/sites/main/domains", headers=_auth(super_client), json={"domain": "demo.example.com"}) assert r.status_code == 201 # registry reload 后 resolve 命中 r = super_client.get("/api/public/brand", headers={"host": "demo.example.com"}) assert r.status_code == 200 assert r.json()["slug"] == "demo" def test_disable_tenant_releases(super_client, db_source_app): _create(super_client, slug="demo", domains=["demo.example.com"]) assert super_client.get( "/api/public/brand", headers={"host": "demo.example.com"}).status_code == 200 registry = db_source_app.state.registry rt_old = registry._by_slug["demo"] assert rt_old.engine is not None # 懒建已发生(portal 循环内) r = super_client.patch(f"{TENANTS}/demo", headers=_auth(super_client), json={"status": "disabled"}) assert r.status_code == 200 assert r.json()["status"] == "disabled" assert rt_old.engine is None # reload 路径已 dispose 旧 engine # resolve 抛 TenantDisabledError(停用检查先于懒建,无 engine 副作用) with pytest.raises(TenantDisabledError): asyncio.run(registry.resolve_site("demo.example.com")) # 中间件映射 503 中性页 r = super_client.get("/api/public/brand", headers={"host": "demo.example.com"}) assert r.status_code == 503 # 复启用 → resolve 恢复(新 runtime 重新懒建) r = super_client.patch(f"{TENANTS}/demo", headers=_auth(super_client), json={"status": "active"}) assert r.status_code == 200 assert super_client.get( "/api/public/brand", headers={"host": "demo.example.com"}).status_code == 200 def test_tenant_admin_cannot_create_tenant(super_client): _create(super_client, slug="demo", admin="demo-admin", password="demo-pass-1") token = _tenant_admin_token(super_client, "demo-admin", "demo-pass-1") r = super_client.post(TENANTS, headers=_bearer(token), json={ "name": "越权租户", "slug": "evil", "profile": "jiancai", "admin_username": "evil-admin", "admin_password": "evil-pass-1"}) assert r.status_code == 403 # 对照: 超管可建(blank profile——jiancai 的出厂 prefix 已被 demo 占用, # 见 test_create_second_same_profile_prefix_conflict) assert _create(super_client, slug="ok1", profile="blank").status_code == 201 # ---- _blankify 单测 -------------------------------------------------------------- def test_blankify_strips_identity_keeps_wall_prompts(): jc = _jc_snapshot() out = _blankify(jc) assert out is not jc # 不改入参 assert jc["brand"]["title"] == "HESUPIPE Support" # 入参原样 db_brand = DEFAULT_CONFIG["brand"] for key in ("title", "whatsapp", "email", "privacy_url"): assert out["brand"][key] == db_brand[key] assert out["push"]["prefix"] == "" assert out["engine"]["context_seed_keys"] == {} # 非身份字段保留 assert out["brand"]["color"] == jc["brand"]["color"] assert out["brand"]["welcome"] == jc["brand"]["welcome"] assert out["brand"]["topics"] == jc["brand"]["topics"] assert out["email"] == jc["email"] # wall/prompts 完整可消费 assert out["wall"] == jc["wall"] assert out["prompts"] == jc["prompts"] compile_wall(out["wall"]) PromptProvider.from_config(out["prompts"]) # ---- 校验与冲突 ----------------------------------------------------------------- def test_slug_validation_and_collision(super_client, tmp_path): assert _create(super_client, slug="Bad_Slug").status_code == 400 assert _create(super_client, slug="下划线_x").status_code == 400 assert _create(super_client, slug="demo").status_code == 201 r = _create(super_client, slug="demo") assert r.status_code == 400 # 撞现有租户 assert "demo" in r.json()["detail"] # 管理员用户名全局唯一 r = _create(super_client, slug="other", admin="demo-admin") assert r.status_code == 400 # 未知 profile assert _create(super_client, slug="x9", profile="vip").status_code == 400 def test_create_auto_profile(super_client, tmp_path): r = _create(super_client, slug="auto2", profile="auto", domains=["auto2.example.com"]) assert r.status_code == 201 assert _current_snapshot(tmp_path, "auto2") == FileTenantSource( SEED_DIR).read_tenant("auto").snapshot r = super_client.get("/api/public/brand", headers={"host": "auto2.example.com"}) assert r.status_code == 200 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", profile="blank") # 大写归一后同域 → 400 带现有归属(tenant + site) r = super_client.post(f"{TENANTS}/other/sites/main/domains", headers=_auth(super_client), json={"domain": "Demo.Example.com"}) assert r.status_code == 400 detail = r.json()["detail"] assert "demo" in detail and "main" in detail # 创建向导的初始域名同样预检(blank profile——排除 prefix 查重的干扰) r = _create(super_client, slug="third", profile="blank", domains=["demo.example.com"]) assert r.status_code == 400 def test_delete_domain(super_client): _create(super_client, slug="demo", domains=["a.example.com", "b.example.com"]) r = super_client.delete(f"{TENANTS}/demo/sites/main/domains/a.example.com", headers=_auth(super_client)) assert r.status_code == 200 assert super_client.get( "/api/public/brand", headers={"host": "a.example.com"}).status_code == 404 assert super_client.get( "/api/public/brand", headers={"host": "b.example.com"}).status_code == 200 # 重复删/不存在 → 404 assert super_client.delete( f"{TENANTS}/demo/sites/main/domains/a.example.com", headers=_auth(super_client)).status_code == 404 assert super_client.delete( f"{TENANTS}/demo/sites/main/domains/nope.example.com", headers=_auth(super_client)).status_code == 404 # ---- 站点 CRUD / brand_overlay 整赋值 --------------------------------------------- def test_site_create_patch_overlay(super_client, tmp_path): _create(super_client, slug="demo", domains=["demo.example.com"]) r = super_client.post(f"{TENANTS}/demo/sites", headers=_auth(super_client), json={"slug": "es", "name": "ES 站", "brand_overlay": {"title": "Demo ES", "welcome": "Hola"}, "domains": ["es.demo.example.com"]}) assert r.status_code == 201 # 域名直连该站点(reload 后 resolve 命中站点) r = super_client.get("/api/public/brand", headers={"host": "es.demo.example.com"}) assert r.status_code == 200 assert r.json()["title"] == "Demo ES" # PATCH 改名 + overlay 整字典赋值(M-2a: 换 dict,旧键不残留) r = super_client.patch(f"{TENANTS}/demo/sites/es", headers=_auth(super_client), json={"name": "ES2", "brand_overlay": {"title": "Demo ES v2"}}) assert r.status_code == 200 rows = _db_fetch(tmp_path, select(Site) .join(Tenant, Site.tenant_id == Tenant.id) .where(Tenant.slug == "demo", Site.slug == "es")) site = rows[0][0] assert site.name == "ES2" assert site.brand_overlay == {"title": "Demo ES v2"} # welcome 键没了 r = super_client.get("/api/public/brand", headers={"host": "es.demo.example.com"}) assert r.json()["title"] == "Demo ES v2" # welcome 回退租户级 brand(覆盖层不再含它) assert r.json()["welcome"] != "Hola" # 站点 slug 不可改;站点内 slug 撞车 400;未知站点 404 assert super_client.patch( f"{TENANTS}/demo/sites/es", headers=_auth(super_client), json={"slug": "es2"}).status_code == 400 assert super_client.post( f"{TENANTS}/demo/sites", headers=_auth(super_client), json={"slug": "es"}).status_code == 400 assert super_client.patch( f"{TENANTS}/demo/sites/ghost", headers=_auth(super_client), json={"name": "x"}).status_code == 404 # 站点停用全链路(终审 I-1):字段落库 + reload 后数据源过滤停用站点—— # 其域名不进索引(经中间件 404,与租户停用的 503 语义差:门面下线≠服务 # 暂停),同租户其他站点照常;?site= 覆盖也取不到(回退默认门面)。 r = super_client.patch(f"{TENANTS}/demo/sites/es", headers=_auth(super_client), json={"status": "disabled"}) assert r.status_code == 200 rows = _db_fetch(tmp_path, select(Site) .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): _create(super_client, slug="demo", name="原名") r = super_client.patch(f"{TENANTS}/demo", headers=_auth(super_client), json={"name": "新名"}) assert r.status_code == 200 rows = _db_fetch(tmp_path, select(Tenant).where(Tenant.slug == "demo")) assert rows[0][0].name == "新名" # 非法 status / 空补丁 / 未知租户 assert super_client.patch(f"{TENANTS}/demo", headers=_auth(super_client), json={"status": "paused"}).status_code == 400 assert super_client.patch(f"{TENANTS}/demo", headers=_auth(super_client), json={}).status_code == 400 assert super_client.patch(f"{TENANTS}/ghost", headers=_auth(super_client), json={"name": "x"}).status_code == 404 # ---- 列表/详情/凭据不外泄/403 边界 ------------------------------------------------- def test_list_tenants(super_client): _create(super_client, slug="demo", domains=["a.example.com", "b.example.com"]) _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"]} assert set(items) == {"demo", "other"} assert items["demo"]["status"] == "active" assert items["demo"]["domain_count"] == 2 assert items["demo"]["current_version"] == 1 assert items["demo"]["admins"] == [ {"username": "demo-admin", "status": "active"}] assert items["other"]["domain_count"] == 0 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", profile="blank") r = super_client.get(f"{TENANTS}/demo", headers=_auth(super_client)) assert r.status_code == 200 d = r.json() assert d["slug"] == "demo" assert d["current_version"] == 1 assert [s["slug"] for s in d["sites"]] == ["main"] assert d["sites"][0]["domains"] == ["demo.example.com"] assert d["admins"] == [{"username": "demo-admin", "status": "active"}] # 详情放宽到本租户管理员;看别的租户 403;列表(超管专属)403 token = _tenant_admin_token(super_client, "demo-admin", "demo-pass-1") assert super_client.get( f"{TENANTS}/demo", headers=_bearer(token)).status_code == 200 assert super_client.get( f"{TENANTS}/other", headers=_bearer(token)).status_code == 403 assert super_client.get( TENANTS, headers=_bearer(token)).status_code == 403 # 未认证 401 assert super_client.get(f"{TENANTS}/demo").status_code == 401 def test_no_credentials_leak_in_responses(super_client): """密码哈希/salt/明文密码绝不出现在列表与详情响应(M-5 红线)。""" _create(super_client, slug="demo", admin="demo-admin", password="demo-pass-1", domains=["demo.example.com"]) for r in (super_client.get(TENANTS, headers=_auth(super_client)), super_client.get(f"{TENANTS}/demo", headers=_auth(super_client))): assert r.status_code == 200 for needle in ("password_hash", "salt", "scrypt", "demo-pass-1"): assert needle not in r.text, f"响应泄露凭据字段: {needle}" # ---- 站点/域名管理放开本租户管理员(Phase 3 Task 1) -------------------------------- def test_tenant_admin_can_manage_own_sites(super_client): """四端点(POST sites / PATCH sites / POST domains / DELETE domains) 放宽到 _tenant_guard:本租户管理员可自助管理站点与域名;打别的租户 仍 403(slug 收口)。""" _create(super_client, slug="demo", admin="demo-admin", password="demo-pass-1", domains=["demo.example.com"]) _create(super_client, slug="other", profile="blank") token = _tenant_admin_token(super_client, "demo-admin", "demo-pass-1") h = _bearer(token) # POST sites → 201 r = super_client.post(f"{TENANTS}/demo/sites", headers=h, json={"slug": "es", "name": "ES 站", "domains": ["es.demo.example.com"]}) assert r.status_code == 201, r.text # PATCH sites/{site} → 200 r = super_client.patch(f"{TENANTS}/demo/sites/es", headers=h, json={"name": "ES 站 v2"}) assert r.status_code == 200 assert r.json()["name"] == "ES 站 v2" # POST domains → 201 r = super_client.post(f"{TENANTS}/demo/sites/es/domains", headers=h, json={"domain": "es2.demo.example.com"}) assert r.status_code == 201 # DELETE domains/{domain} → 200 r = super_client.delete( f"{TENANTS}/demo/sites/es/domains/es2.demo.example.com", headers=h) assert r.status_code == 200 # 对照: 同 token 打别的租户四端点 → 403 assert super_client.post(f"{TENANTS}/other/sites", headers=h, json={"slug": "x"}).status_code == 403 assert super_client.patch(f"{TENANTS}/other/sites/main", headers=h, json={"name": "x"}).status_code == 403 assert super_client.post(f"{TENANTS}/other/sites/main/domains", headers=h, json={"domain": "x.example.com"}).status_code == 403 assert super_client.delete( f"{TENANTS}/other/sites/main/domains/x.example.com", headers=h).status_code == 403 def test_tenant_lifecycle_still_super_only(super_client): """不动清单:租户生命周期端点保持超管专属——创建 / 全平台列表 / PATCH /tenants/{slug}(停用/改名,租户不能自停自改)。""" _create(super_client, slug="demo", admin="demo-admin", password="demo-pass-1") token = _tenant_admin_token(super_client, "demo-admin", "demo-pass-1") h = _bearer(token) r = super_client.post(TENANTS, headers=h, json={ "name": "越权租户", "slug": "evil", "profile": "blank", "admin_username": "evil-admin", "admin_password": "evil-pass-1"}) assert r.status_code == 403 assert super_client.get(TENANTS, headers=h).status_code == 403 assert super_client.patch(f"{TENANTS}/demo", headers=h, json={"status": "disabled"}).status_code == 403 assert super_client.patch(f"{TENANTS}/demo", headers=h, json={"name": "自改名"}).status_code == 403