feat: platform.db 模型与超管引导(WAL/scrypt/幂等bootstrap)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user