feat: platform.db 模型与超管引导(WAL/scrypt/幂等bootstrap)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-13 07:57:15 +08:00
parent d088264ca0
commit 2d62108c73
5 changed files with 520 additions and 6 deletions
+17 -4
View File
@@ -18,6 +18,7 @@ from app.engine.models import Base
# "先 import app.main 再 import chat_rest"的循环导入次序坑随全局单例一并
# 消失(routes 不再反向 import app.main),直接导入即可。
from app.main import app
from app.platform.db import make_platform_engine
from app.platform.registry import TenantRegistry
REPO_ROOT = Path(__file__).resolve().parents[1]
@@ -136,9 +137,20 @@ def build_app(seed_dir, data_dir):
延迟取 registry),工厂只负责把 Registry 放进 app.state——lifespan 见
已挂则沿用(测试注入缝),不再加载仓库 seed。调用方用完必须复位
app.state.registry = None(见 app_test_client 的 finally)。
Phase 2 Task 1 起同时预置 platform_engine(data_dir 下的临时平台库,
同一注入缝)——不预置则 lifespan 会在仓库根建出真实 platform.db;
schema+bootstrap 由 lifespan 统一跑(幂等),dispose 也在 lifespan
finally(与 registry.dispose_all 同处)。
"""
registry = TenantRegistry.load_from_dir(seed_dir, data_dir=data_dir)
app.state.registry = registry
# data_dir 可能尚不存在(租户库由 _ensure_ready 按需 mkdir,平台库由
# lifespan 立即连接——这里先建目录,否则 sqlite "unable to open database
# file")
Path(data_dir).mkdir(parents=True, exist_ok=True)
app.state.platform_engine = make_platform_engine(
Path(data_dir) / "platform.db")
return app
@@ -172,10 +184,10 @@ class TenantTestClient(TestClient):
@contextmanager
def app_test_client(app_instance, base_url, cleanup_root):
"""TestClient 包装:统一退出纪律——lifespan finally 已 dispose_all
(Windows 文件锁),再置空 app.state.registry(关残留窗:不让已 dispose
的测试 Registry 被后续无夹具的 lifespan 误用),最后清 tmp 目录
(删不掉告警留痕,不静默吞)。"""
"""TestClient 包装:统一退出纪律——lifespan finally 已 dispose_all +
platform_engine.dispose(Windows 文件锁),再置空 app.state.registry /
platform_engine(关残留窗:不让已 dispose 的测试对象被后续无夹具的
lifespan 误用),最后清 tmp 目录(删不掉告警留痕,不静默吞)。"""
try:
with TenantTestClient(
app_instance, base_url=base_url,
@@ -183,6 +195,7 @@ def app_test_client(app_instance, base_url, cleanup_root):
yield c
finally:
app_instance.state.registry = None
app_instance.state.platform_engine = None
_rmtree_warn(cleanup_root)
+253
View File
@@ -0,0 +1,253 @@
"""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
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_fresh_salt_per_call():
"""同密码两次哈希:salt/摘要都不同(随机 salt,彩虹表防护);
显式传 salt 可复现(verify_password 内部路径)。"""
h1, s1 = hash_password("same")
h2, s2 = hash_password("same")
assert s1 != s2 and h1 != h2
h3, s3 = hash_password("same", salt=bytes.fromhex(s1))
assert h3 == h1 and s3 == s1
# ---- 模型约束 -------------------------------------------------------------
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(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_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