Files
ai-kefu-admin/tests/test_platform_db.py
浅念 6839cb94d2 fix: 终审修复——站点停用接线/prefix查重补创建reset/默认凭据警示+取舍补录
- I-1 站点停用全链接线: Site 数据类加 status 字段; DB 源 _load_one 与
  文件源 read_tenant 均只输出 active 站点(停用站点域名不进 registry
  索引 → 该域名 404;全停用=合法运营状态输出空 sites;文件源无 status
  键默认 active 兼容)。E2E: PATCH 站点 disabled → 其域名 404、复启用
  恢复;源级直测两源过滤。
- I-3 prefix 跨租户查重补两口: db.other_push_prefixes 共用助手
  (MAX(version) 逐租户当前前缀,排除自身);创建向导(出厂档案前缀被占
  → 400 提示改 blank/改占用方)与 reset push(重置回出厂撞车 → 400 提示
  走 PUT)接入;PUT 原有查重改用同一助手。rollback 维持豁免(历史不可变
  语义优先,取舍清单记档)。既有测试第二租户改 blank profile 避撞。
- I-4 默认凭据警示: lifespan 启动检测 ADMIN_PASSWORD 仍为 Settings 字段
  默认值(非硬编码第二份,防双源漂移)→ logger.warning 醒目提示;
  CLAUDE.md Phase 4 部署记档补 ADMIN_PASSWORD 生产要求。
- I-2/M-2 取舍补录: 密钥掩码未实现(GET config/settings 明文返回
  api_key/smtp 密码,Phase 3 前须定 __SET__ 哨兵方案);站点管理权限现为
  仅超管(Phase 3 对接时决策放开或改 spec);取舍清单第 2 条同步刷新
  (rollback 现为唯一豁免口)。

全量回归: 571 passed(基线 566 + 新增 5)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 12:40:21 +08:00

367 lines
15 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].password_hash, users[0].salt)
assert not verify_password("other", users[0].password_hash, users[0].salt)
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].password_hash, users[0].salt)
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 解析兼容)。
Task 2 起 verify 纯参式 (password, password_hash, salt)——不再需要
ORM 实体即可直测。"""
bad_stored = [
"not-hex!", # 无前缀,坏 hex
"scrypt$16384$8$1$deadbeef", # 段数不足
"scrypt$abc$8$1$00$00", # 参数非整数
]
for stored in bad_stored:
assert verify_password("whatever", stored, "zz") is False
salt = bytes(16)
digest = hashlib.scrypt( # 裸摘要 + salt 列的过渡行形态
b"legacy", salt=salt, n=2 ** 14, r=8, p=1)
legacy_hash, legacy_salt = digest.hex(), salt.hex()
assert verify_password("legacy", legacy_hash, legacy_salt) is True
assert verify_password("other", legacy_hash, legacy_salt) 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].password_hash, users[0].salt)
assert users[0].must_change_password is True
def test_default_admin_password_warns_at_startup(
tmp_path, monkeypatch, caplog):
"""终审 I-4:lifespan 启动时 ADMIN_PASSWORD 仍为出厂默认 → 醒目
WARNING(生产必须设环境变量);设了自定义值 → 无警示。比较基准 =
Settings 字段默认值(与 main.py 同式,改默认值判定自动跟随)。"""
import logging
from app.engine.config import settings
from app.main import app
from app.platform.registry import TenantRegistry
def _run_lifespan():
app.state.registry = TenantRegistry.load_from_dir(
SEED_DIR, data_dir=tmp_path / "tenants")
app.state.platform_engine = make_platform_engine(
tmp_path / "platform.db")
try:
with caplog.at_level(logging.WARNING, logger="app.main"):
with TestClient(app) as client:
assert client.get("/health").status_code == 200
finally:
app.state.registry = None
app.state.platform_engine = None
# 出厂默认密码 → WARNING 提及用户名与"默认密码"
monkeypatch.setattr(settings, "ADMIN_PASSWORD",
type(settings).model_fields["ADMIN_PASSWORD"].default)
_run_lifespan()
assert any("默认密码" in r.getMessage()
and settings.ADMIN_USERNAME in r.getMessage()
for r in caplog.records)
# 自定义密码 → 无该警示
caplog.clear()
monkeypatch.setattr(settings, "ADMIN_PASSWORD", "n0t-default-pw")
_run_lifespan()
assert not any("默认密码" in r.getMessage() for r in caplog.records)