bfcaba5c87
Co-Authored-By: Claude Code <noreply@anthropic.com>
328 lines
13 KiB
Python
328 lines
13 KiB
Python
"""platform.db 模型与超管引导(Phase 2 Task 1)。
|
|
|
|
独立 PlatformBase(与租户库 Base 无关);WAL/pool=2 纪律照抄
|
|
make_tenant_engine;scrypt 密码哈希 + 常量时间校验;bootstrap 空表才建
|
|
(幂等);FK 级联删除(PAGMA foreign_keys=ON,删租户连带全部子行);
|
|
main.py lifespan 装配缝(预置 platform_engine 则沿用)。
|
|
|
|
Windows 纪律:每个建过 engine 的用例结尾 await engine.dispose()——
|
|
连接不关,文件锁着,tmp_path 清理 PermissionError。
|
|
"""
|
|
import asyncio
|
|
import hashlib
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.platform.db import (
|
|
AdminUser,
|
|
PlatformSetting,
|
|
Site,
|
|
SiteDomain,
|
|
Tenant,
|
|
TenantConfigVersion,
|
|
bootstrap_superuser,
|
|
ensure_platform_schema,
|
|
hash_password,
|
|
list_admin_users,
|
|
make_platform_engine,
|
|
verify_password,
|
|
)
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
SEED_DIR = REPO_ROOT / "seed_profiles"
|
|
|
|
|
|
async def _insert_fixture(engine):
|
|
"""最小平台数据:租户 t1 + 站点 main + 域名 + 配置版本 1 + 租户管理员。"""
|
|
password_hash, salt = hash_password("tenant-pw")
|
|
async with AsyncSession(engine, expire_on_commit=False) as session:
|
|
tenant = Tenant(slug="t1", name="T1")
|
|
session.add(tenant)
|
|
await session.flush()
|
|
site = Site(tenant_id=tenant.id, slug="main", name="Main")
|
|
session.add(site)
|
|
await session.flush()
|
|
session.add(SiteDomain(domain="a.example.com", site_id=site.id))
|
|
session.add(TenantConfigVersion(
|
|
tenant_id=tenant.id, version=1, config_json="{}"))
|
|
session.add(AdminUser(
|
|
tenant_id=tenant.id, username="t1admin",
|
|
password_hash=password_hash, salt=salt))
|
|
await session.commit()
|
|
return tenant.id, site.id
|
|
|
|
|
|
# ---- bootstrap 超管 ---------------------------------------------------------
|
|
|
|
|
|
async def test_bootstrap_creates_superuser_once(tmp_path):
|
|
engine = make_platform_engine(tmp_path / "platform.db")
|
|
try:
|
|
await ensure_platform_schema(engine)
|
|
await bootstrap_superuser(engine, username="admin", password="s3cret!")
|
|
await bootstrap_superuser(engine, username="admin", password="other") # 幂等:不重复建/不改密
|
|
users = await list_admin_users(engine)
|
|
assert len(users) == 1 and users[0].username == "admin"
|
|
assert users[0].tenant_id is None # NULL = 超管
|
|
assert users[0].must_change_password is True # 引导密码必须首登改掉
|
|
assert verify_password("s3cret!", users[0])
|
|
assert not verify_password("other", users[0])
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
async def test_bootstrap_noop_when_any_user_exists(tmp_path):
|
|
"""空表才建:表非空时换用户名再来也不建不改(启动安全重入)。"""
|
|
engine = make_platform_engine(tmp_path / "platform.db")
|
|
try:
|
|
await ensure_platform_schema(engine)
|
|
await bootstrap_superuser(engine, username="admin", password="first")
|
|
await bootstrap_superuser(engine, username="root", password="second")
|
|
users = await list_admin_users(engine)
|
|
assert len(users) == 1 and users[0].username == "admin"
|
|
assert verify_password("first", users[0])
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
# ---- engine 纪律 -------------------------------------------------------------
|
|
|
|
|
|
async def test_platform_db_wal(tmp_path):
|
|
"""spec §6.1 硬纪律:平台库打开即 WAL(查法照抄 test_registry)。"""
|
|
from sqlalchemy import text
|
|
|
|
engine = make_platform_engine(tmp_path / "platform.db")
|
|
try:
|
|
await ensure_platform_schema(engine)
|
|
async with engine.connect() as conn:
|
|
mode = (await conn.execute(text("PRAGMA journal_mode"))).scalar()
|
|
assert str(mode).lower() == "wal"
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
# ---- 密码哈希 -----------------------------------------------------------------
|
|
|
|
|
|
def test_hash_password_versioned_format_and_fresh_salt():
|
|
"""同密码两次哈希:salt/摘要都不同(随机 salt,彩虹表防护);
|
|
显式传 salt 可复现(verify_password 内部路径)。
|
|
版本化串(M-1): "scrypt$n$r$p$salt_hex$hash_hex"——参数/盐/摘要
|
|
同存,将来提参/换算法有迁移路径。"""
|
|
h1, s1 = hash_password("same")
|
|
h2, s2 = hash_password("same")
|
|
assert s1 != s2 and h1 != h2
|
|
parts = h1.split("$")
|
|
assert parts[0] == "scrypt" and len(parts) == 6
|
|
assert parts[1:4] == ["16384", "8", "1"]
|
|
assert parts[4] == s1 # 串内盐与 salt 列(hex)一致
|
|
h3, s3 = hash_password("same", salt=bytes.fromhex(s1))
|
|
assert h3 == h1 and s3 == s1
|
|
|
|
|
|
def test_verify_password_bad_stored_hash_returns_false():
|
|
"""存储异常按验败处理不炸(M-4):坏 hex/坏段数/坏参数 → False;
|
|
无前缀过渡行兼容:salt 列 + 默认参数仍可验真(M-1 解析兼容)。"""
|
|
bad_stored = [
|
|
"not-hex!", # 无前缀,坏 hex
|
|
"scrypt$16384$8$1$deadbeef", # 段数不足
|
|
"scrypt$abc$8$1$00$00", # 参数非整数
|
|
]
|
|
for stored in bad_stored:
|
|
user = AdminUser(username="u", password_hash=stored, salt="zz")
|
|
assert verify_password("whatever", user) is False
|
|
|
|
salt = bytes(16)
|
|
digest = hashlib.scrypt( # 裸摘要 + salt 列的过渡行形态
|
|
b"legacy", salt=salt, n=2 ** 14, r=8, p=1)
|
|
legacy = AdminUser(
|
|
username="u", password_hash=digest.hex(), salt=salt.hex())
|
|
assert verify_password("legacy", legacy) is True
|
|
assert verify_password("other", legacy) is False
|
|
|
|
|
|
# ---- 模型约束 -------------------------------------------------------------
|
|
|
|
|
|
async def test_unique_constraints(tmp_path):
|
|
"""UNIQUE 们:tenants.slug / sites(tenant_id, slug) /
|
|
site_domains.domain / tenant_config_versions(tenant_id, version) /
|
|
admin_users.username。"""
|
|
engine = make_platform_engine(tmp_path / "platform.db")
|
|
try:
|
|
await ensure_platform_schema(engine)
|
|
await _insert_fixture(engine)
|
|
async with AsyncSession(engine) as session:
|
|
session.add(Tenant(slug="t1", name="dup"))
|
|
with pytest.raises(IntegrityError):
|
|
await session.commit()
|
|
await session.rollback()
|
|
|
|
# 取 id 列而非 ORM 实体:rollback 会 expire 实例,事后访问
|
|
# 属性将触发同步懒加载(async 下 MissingGreenlet)
|
|
t1_id = (await session.execute(
|
|
select(Tenant.id).filter_by(slug="t1"))).scalar_one()
|
|
session.add(Site(tenant_id=t1_id, slug="main", name="dup"))
|
|
with pytest.raises(IntegrityError):
|
|
await session.commit()
|
|
await session.rollback()
|
|
|
|
site_id = (await session.execute(
|
|
select(Site.id).filter_by(slug="main"))).scalar_one()
|
|
session.add(SiteDomain(domain="a.example.com", site_id=site_id))
|
|
with pytest.raises(IntegrityError):
|
|
await session.commit()
|
|
await session.rollback()
|
|
|
|
session.add(TenantConfigVersion(
|
|
tenant_id=t1_id, version=1, config_json="{}"))
|
|
with pytest.raises(IntegrityError):
|
|
await session.commit()
|
|
await session.rollback()
|
|
|
|
session.add(TenantConfigVersion(
|
|
tenant_id=t1_id, version=2, config_json="{oops"))
|
|
with pytest.raises(IntegrityError): # M-3: json_valid CHECK
|
|
await session.commit()
|
|
await session.rollback()
|
|
|
|
session.add(AdminUser(
|
|
username="t1admin", password_hash="x", salt="y"))
|
|
with pytest.raises(IntegrityError):
|
|
await session.commit()
|
|
await session.rollback()
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
async def test_schema_metadata_isolation(tmp_path):
|
|
"""互不误建(M-4):ensure_platform_schema 只建平台表,租户库的
|
|
ensure_schema 只建租户表——两套 MetaData 各管各的,engine 指错库
|
|
也不会混表。"""
|
|
from sqlalchemy import text
|
|
|
|
from app.engine.models import ensure_schema
|
|
from app.platform.registry import make_tenant_engine
|
|
|
|
platform_tables = {"tenants", "sites", "site_domains",
|
|
"tenant_config_versions", "admin_users",
|
|
"platform_settings"}
|
|
tenant_tables = {"conversations", "messages", "customer_profiles",
|
|
"evaluations", "lead_push_logs"}
|
|
|
|
pe = make_platform_engine(tmp_path / "platform.db")
|
|
try:
|
|
await ensure_platform_schema(pe)
|
|
async with pe.connect() as conn:
|
|
names = set((await conn.execute(
|
|
text("SELECT name FROM sqlite_master WHERE type='table'")
|
|
)).scalars())
|
|
assert platform_tables <= names
|
|
assert not (tenant_tables & names) # 不建租户表
|
|
finally:
|
|
await pe.dispose()
|
|
|
|
te = make_tenant_engine(tmp_path / "tenant.db")
|
|
try:
|
|
await ensure_schema(te)
|
|
async with te.connect() as conn:
|
|
names = set((await conn.execute(
|
|
text("SELECT name FROM sqlite_master WHERE type='table'")
|
|
)).scalars())
|
|
assert tenant_tables <= names
|
|
assert not (platform_tables & names) # 不建平台表
|
|
finally:
|
|
await te.dispose()
|
|
|
|
|
|
async def test_delete_tenant_cascades_children(tmp_path):
|
|
"""FK 级联(make_platform_engine 每连接 PRAGMA foreign_keys=ON):
|
|
删租户 → sites/site_domains/tenant_config_versions/该租户的 admin_users
|
|
级联消失;超管(tenant_id NULL)不受波及。"""
|
|
engine = make_platform_engine(tmp_path / "platform.db")
|
|
try:
|
|
await ensure_platform_schema(engine)
|
|
await _insert_fixture(engine)
|
|
password_hash, salt = hash_password("super-pw")
|
|
async with AsyncSession(engine) as session:
|
|
session.add(AdminUser(
|
|
username="super", password_hash=password_hash, salt=salt))
|
|
await session.commit()
|
|
async with AsyncSession(engine) as session:
|
|
t1 = (await session.execute(
|
|
select(Tenant).filter_by(slug="t1"))).scalar_one()
|
|
await session.delete(t1)
|
|
await session.commit()
|
|
async with AsyncSession(engine) as session:
|
|
assert (await session.scalar(
|
|
select(func.count()).select_from(Site))) == 0
|
|
assert (await session.scalar(
|
|
select(func.count()).select_from(SiteDomain))) == 0
|
|
assert (await session.scalar(
|
|
select(func.count()).select_from(TenantConfigVersion))) == 0
|
|
admins = (await session.execute(
|
|
select(AdminUser).order_by(AdminUser.id))).scalars().all()
|
|
assert [u.username for u in admins] == ["super"]
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
async def test_platform_setting_roundtrip(tmp_path):
|
|
engine = make_platform_engine(tmp_path / "platform.db")
|
|
try:
|
|
await ensure_platform_schema(engine)
|
|
async with AsyncSession(engine) as session:
|
|
session.add(PlatformSetting(
|
|
key="smtp.default.host", value="smtp.example.com"))
|
|
await session.commit()
|
|
got = await session.get(PlatformSetting, "smtp.default.host")
|
|
assert got is not None and got.value == "smtp.example.com"
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
# ---- main.py lifespan 装配缝 ---------------------------------------------------
|
|
|
|
|
|
def test_lifespan_platform_engine_seam_and_bootstrap(tmp_path, monkeypatch):
|
|
"""预置 app.state.platform_engine → lifespan 沿用该引擎(不在仓库根建
|
|
真实 platform.db),对它跑 schema+bootstrap(settings 凭据),退出 dispose;
|
|
落盘结果用独立引擎复验(跨循环纪律:asyncio.run 单循环内完成)。"""
|
|
from app.engine.config import settings
|
|
from app.main import app
|
|
from app.platform.registry import TenantRegistry
|
|
|
|
monkeypatch.setattr(settings, "ADMIN_USERNAME", "bootadmin")
|
|
monkeypatch.setattr(settings, "ADMIN_PASSWORD", "boot-pw")
|
|
|
|
engine = make_platform_engine(tmp_path / "platform.db")
|
|
app.state.registry = TenantRegistry.load_from_dir(
|
|
SEED_DIR, data_dir=tmp_path / "tenants")
|
|
app.state.platform_engine = engine
|
|
try:
|
|
with TestClient(app) as client:
|
|
assert app.state.platform_engine is engine # 沿用,未被换掉
|
|
assert client.get("/health").status_code == 200
|
|
# 退出时 lifespan finally 已 dispose(Windows 文件锁释放,tmp 可清理)
|
|
finally:
|
|
app.state.registry = None
|
|
app.state.platform_engine = None
|
|
|
|
async def _probe():
|
|
check = make_platform_engine(tmp_path / "platform.db")
|
|
try:
|
|
return await list_admin_users(check)
|
|
finally:
|
|
await check.dispose()
|
|
|
|
users = asyncio.run(_probe())
|
|
assert [u.username for u in users] == ["bootadmin"]
|
|
assert verify_password("boot-pw", users[0])
|
|
assert users[0].must_change_password is True
|