98 lines
4.3 KiB
Python
98 lines
4.3 KiB
Python
"""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
|