"""P0-4 租户边界越权集成测试:数据库级强制 + 服务层隔离 + 真实登录。 用独立 SQLite 文件库起完整应用(不 mock 认证),bootstrap 出租户 A 管理员, 直接在库里造租户 B 的数据,验证: 1. 触发器阻止 organization_id < 0 的数据落库(数据库级强制); 2. A 租户管理员登录后看不到/改不了 B 租户资源(服务层越权防护); 3. 全局资源(organization_id=0)对任意租户可见。 """ from contextlib import contextmanager from fastapi.testclient import TestClient from sqlmodel import Session, SQLModel, create_engine, select from app.db import init_db from app.main import app from app.models import AppUser, Campaign, KnowledgeBase from app import db as db_module @contextmanager def _client(tmp_db_path): """独立数据库 + 真实登录会话的测试客户端。""" engine = create_engine(f"sqlite:///{tmp_db_path}", connect_args={"check_same_thread": False}) original = db_module.engine db_module.engine = engine SQLModel.metadata.create_all(engine) init_db() from app import seed as seed_mod seed_mod.seed() try: client = TestClient(app) # 真实 bootstrap:创建租户 A 管理员并拿到 token resp = client.post("/api/auth/bootstrap", json={ "username": "tenant_a_admin", "display_name": "租户A管理员", "password": "TenantA-Test-2026!"}) assert resp.status_code == 200, resp.text token = resp.json()["token"] client.headers["Authorization"] = f"Bearer {token}" with Session(engine) as s: user_a = s.exec(select(AppUser).where( AppUser.username == "tenant_a_admin")).first() yield client, engine, user_a finally: db_module.engine = original def test_database_trigger_blocks_negative_org(tmp_path): """数据库级:organization_id < 0 无法插入。""" with _client(str(tmp_path / "t.db")) as (_c, engine, _u): with Session(engine) as s: s.add(Campaign(name="越界", organization_id=-5, owner_user_id=1)) try: s.commit() raised = False except Exception: raised = True s.rollback() assert raised, "organization_id=-5 应被数据库触发器拒绝" s.add(Campaign(name="正常", organization_id=2, owner_user_id=1)) s.commit() assert s.exec(select(Campaign).where(Campaign.name == "正常")).first() def test_cross_tenant_campaign_hidden(tmp_path): """服务层:租户 A 管理员看不到租户 B 的任务。""" with _client(str(tmp_path / "t.db")) as (client, engine, user_a): with Session(engine) as s: s.add(Campaign(name="A租户任务", organization_id=user_a.organization_id, owner_user_id=user_a.id)) s.add(Campaign(name="B租户任务", organization_id=99, owner_user_id=999)) s.commit() b_id = s.exec(select(Campaign).where(Campaign.name == "B租户任务")).first().id a_id = s.exec(select(Campaign).where(Campaign.name == "A租户任务")).first().id listing = client.get("/api/campaigns").json() names = {c["name"] for c in listing} assert "A租户任务" in names assert "B租户任务" not in names, "跨租户任务不得出现在列表" assert client.get(f"/api/campaigns/{b_id}").status_code == 404, "跨租户详情应 404" assert client.get(f"/api/campaigns/{a_id}").status_code == 200 def test_global_resource_visible_across_tenants(tmp_path): """organization_id=0 的全局资源对任意租户可见。""" with _client(str(tmp_path / "t.db")) as (client, engine, user_a): with Session(engine) as s: s.add(KnowledgeBase(name="全局库", organization_id=0, owner_user_id=0, visibility="global")) s.add(KnowledgeBase(name="B租户库", organization_id=99, owner_user_id=999, visibility="private")) s.commit() listing = client.get("/api/kbs").json() names = {kb["name"] for kb in listing} assert "全局库" in names assert "B租户库" not in names