2026-10-09 06:46:36 -04:00
|
|
|
|
"""配置模块单元测试。
|
|
|
|
|
|
|
|
|
|
|
|
用 `_env_file=None` 绕过 backend/.env,使断言不受开发者本地配置影响。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
from pydantic import ValidationError
|
|
|
|
|
|
|
|
|
|
|
|
from app.core.config import Settings
|
|
|
|
|
|
|
|
|
|
|
|
VALID_SECRET = "x" * 32
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _settings(**overrides: object) -> Settings:
|
|
|
|
|
|
"""构造一个不读取 .env 的配置实例。"""
|
2026-10-27 23:42:15 -04:00
|
|
|
|
# _env_file=None 只禁用文件读取;显式值避免隔离测试库的环境变量影响默认值断言。
|
|
|
|
|
|
params: dict[str, object] = {
|
|
|
|
|
|
"_env_file": None,
|
|
|
|
|
|
"app_secret_key": VALID_SECRET,
|
|
|
|
|
|
"mysql_database": "payroll",
|
|
|
|
|
|
}
|
2026-10-09 06:46:36 -04:00
|
|
|
|
params.update(overrides)
|
|
|
|
|
|
return Settings(**params) # type: ignore[arg-type]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSecretKey:
|
|
|
|
|
|
"""密钥强度校验。"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_缺失密钥时拒绝构造(self) -> None:
|
|
|
|
|
|
with pytest.raises(ValidationError):
|
|
|
|
|
|
Settings(_env_file=None) # type: ignore[call-arg]
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("length", [0, 1, 31])
|
|
|
|
|
|
def test_密钥短于32字符时拒绝(self, length: int) -> None:
|
|
|
|
|
|
with pytest.raises(ValidationError):
|
|
|
|
|
|
_settings(app_secret_key="x" * length)
|
|
|
|
|
|
|
|
|
|
|
|
def test_密钥恰好32字符时通过(self) -> None:
|
|
|
|
|
|
assert _settings(app_secret_key="x" * 32).app_secret_key == "x" * 32
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSecurityBounds:
|
|
|
|
|
|
"""安全边界:规范要求 JWT ≤120 分钟、验证码 300 秒。"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_jwt有效期超过120分钟时拒绝(self) -> None:
|
|
|
|
|
|
with pytest.raises(ValidationError):
|
|
|
|
|
|
_settings(jwt_expire_minutes=121)
|
|
|
|
|
|
|
|
|
|
|
|
def test_jwt有效期为120分钟时通过(self) -> None:
|
|
|
|
|
|
assert _settings(jwt_expire_minutes=120).jwt_expire_minutes == 120
|
|
|
|
|
|
|
|
|
|
|
|
def test_验证码有效期超过300秒时拒绝(self) -> None:
|
|
|
|
|
|
with pytest.raises(ValidationError):
|
|
|
|
|
|
_settings(captcha_expire_seconds=301)
|
|
|
|
|
|
|
|
|
|
|
|
def test_验证码有效期为300秒时通过(self) -> None:
|
|
|
|
|
|
assert _settings(captcha_expire_seconds=300).captcha_expire_seconds == 300
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestConnectionUrl:
|
|
|
|
|
|
"""连接 URL 构造:密码含特殊字符时不可手工拼接。"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_数据库URL使用同步驱动(self) -> None:
|
|
|
|
|
|
"""运行期与迁移共用同步连接串,不存在同步/异步两套配置。"""
|
|
|
|
|
|
url = _settings().database_url
|
|
|
|
|
|
assert url.drivername == "mysql+pymysql"
|
|
|
|
|
|
assert url.database == "payroll"
|
|
|
|
|
|
assert url.query.get("charset") == "utf8mb4"
|
|
|
|
|
|
|
|
|
|
|
|
def test_密码含特殊字符时被原样保存于URL对象(self) -> None:
|
|
|
|
|
|
url = _settings(mysql_password="p@ss:w/ord#1").database_url
|
|
|
|
|
|
# URL.create 以结构化方式保存各组件,不做编码 —— 编码发生在驱动建连时,
|
|
|
|
|
|
# 这正是手工拼接字符串会出错(如把 : 误认为端口分隔符)而它不会的原因
|
|
|
|
|
|
assert url.password == "p@ss:w/ord#1"
|
|
|
|
|
|
assert url.username == "payroll"
|
|
|
|
|
|
assert url.port == 3306
|
|
|
|
|
|
|
|
|
|
|
|
def test_渲染URL时默认隐藏密码(self) -> None:
|
|
|
|
|
|
"""异常或日志中打印 URL 时不得泄露密码。"""
|
|
|
|
|
|
url = _settings(mysql_password="p@ss:w/ord#1").database_url
|
|
|
|
|
|
assert "p@ss:w/ord#1" not in url.render_as_string()
|
|
|
|
|
|
|
|
|
|
|
|
def test_Redis无密码时不拼接认证段(self) -> None:
|
|
|
|
|
|
assert _settings(redis_password="").redis_url == "redis://127.0.0.1:6379/0"
|
|
|
|
|
|
|
|
|
|
|
|
def test_Redis有密码时拼接认证段(self) -> None:
|
|
|
|
|
|
url = _settings(redis_password="secret").redis_url
|
|
|
|
|
|
assert url == "redis://:secret@127.0.0.1:6379/0"
|