chore: 初始化项目结构与登录鉴权模块

后端 FastAPI 六层结构(api/services/repositories/models/schemas/core),
统一响应信封、全局异常、日志脱敏;6 张表经 Alembic 迁移建库,SQL 留存
db/migration;登录鉴权含图形验证码(Redis GETDEL 防重放)、JWT、登录限流、
登出黑名单(TTL=令牌剩余有效期)、登录日志。

前端 Vue3 + TS + Element Plus 骨架,含登录页、路由守卫、请求封装与 Vite 代理。

工程配置:uv 锁定 Python 3.12、ruff 检查与格式化、editorconfig、gitattributes。

测试 184 项(单元 155 / 集成 29)全绿,浏览器端到端 8 项通过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
liuwenxinandClaude Code committed 2026-10-09 18:46:36 +08:00
commit 273ebf4657
101 files changed
+8725

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""不依赖外部服务的单元测试。"""
+71
View File
@@ -0,0 +1,71 @@
"""认证请求模型单元测试。
守的是:**密码按字节数校验**。
bcrypt 5.0 起对超过 72 字节的密码直接抛 ValueError(此前是静默截断),
而中文一个字符占 3 字节 —— 只按字符数校验(如 max_length=20)根本拦不住,
24 个汉字就会让哈希函数抛异常、登录接口返回 500。
"""
import pytest
from pydantic import ValidationError
from app.core.constants import CAPTCHA_LENGTH, PASSWORD_MAX_BYTES
from app.schemas.auth import LoginIn
def _payload(**overrides: object) -> dict[str, object]:
base: dict[str, object] = {
"username": "admin",
"password": "Pwd@12345",
"captcha_id": "a" * 32,
"captcha_code": "A" * CAPTCHA_LENGTH,
}
base.update(overrides)
return base
class TestPasswordByteLimit:
"""72 字节边界 —— 与 bcrypt 算法上限严格对齐。"""
def test_合法密码通过(self) -> None:
assert LoginIn(**_payload()).password == "Pwd@12345"
def test_恰好72字节ASCII通过(self) -> None:
password = "a" * PASSWORD_MAX_BYTES
assert LoginIn(**_payload(password=password)).password == password
def test_超过72字节ASCII被拒(self) -> None:
with pytest.raises(ValidationError):
LoginIn(**_payload(password="a" * (PASSWORD_MAX_BYTES + 1)))
def test_24个汉字恰好72字节通过(self) -> None:
"""一个汉字 3 字节:24 个正好等于上限。"""
password = "中" * 24
assert len(password.encode("utf-8")) == PASSWORD_MAX_BYTES
LoginIn(**_payload(password=password))
def test_25个汉字被拒(self) -> None:
"""若只按字符数校验,25 个字符远低于常见的 max_length=128,
会一路放行到哈希函数才炸成 500。"""
with pytest.raises(ValidationError):
LoginIn(**_payload(password="中" * 25))
class TestFieldConstraints:
"""其余字段的边界。"""
def test_验证码位数不符被拒(self) -> None:
with pytest.raises(ValidationError):
LoginIn(**_payload(captcha_code="AB"))
def test_用户名为空被拒(self) -> None:
with pytest.raises(ValidationError):
LoginIn(**_payload(username=""))
def test_用户名超长被拒(self) -> None:
with pytest.raises(ValidationError):
LoginIn(**_payload(username="a" * 65))
def test_首尾空白被自动去除(self) -> None:
"""用户复制粘贴账号时常带空格,自动去除比报错友好。"""
assert LoginIn(**_payload(username=" admin ")).username == "admin"
+91
View File
@@ -0,0 +1,91 @@
"""图形验证码单元测试。
守的是:**验证码的一次性语义**。若 GETDEL 退化为「先 GET 再 DEL」,
两条命令之间会出现竞态窗口,攻击者可用同一验证码并发重复提交 ——
而这个问题在正常使用下完全看不出来。
"""
import base64
from app.core.captcha_image import generate_text, render_png_base64
from app.core.constants import CAPTCHA_CHARSET, CAPTCHA_LENGTH, REDIS_KEY_CAPTCHA
from app.services import captcha_service
PNG_MAGIC = b"\x89PNG"
class TestCaptchaText:
"""验证码文本生成。"""
def test_长度固定(self) -> None:
assert len(generate_text()) == CAPTCHA_LENGTH
def test_字符集不含易混淆字符(self) -> None:
"""0/O、1/I/l 在图片上难以区分,会显著提高用户输错率。"""
text = "".join(generate_text() for _ in range(200))
assert set(text) <= set(CAPTCHA_CHARSET)
assert not (set(text) & set("0O1Il"))
def test_具备随机性(self) -> None:
assert len({generate_text() for _ in range(50)}) > 40
class TestCaptchaImage:
"""图片渲染。"""
def test_输出为合法PNG的base64(self) -> None:
raw = base64.b64decode(render_png_base64("AB3D"))
assert raw.startswith(PNG_MAGIC)
def test_不含data前缀(self) -> None:
"""返回纯 base64,由前端自行拼接 data:image/png;base64, ——
服务端不替调用方决定呈现方式。"""
assert not render_png_base64("AB3D").startswith("data:")
def test_同一文本两次渲染结果不同(self) -> None:
"""每次绘制含随机干扰线与噪点,防止通过图片比对反推答案。"""
assert render_png_base64("AB3D") != render_png_base64("AB3D")
class TestCaptchaService:
"""签发与校验(用假 Redis)。"""
def test_签发后可用正确验证码通过(self, fake_redis) -> None:
captcha_id, image, expires_in = captcha_service.issue()
assert expires_in == 300
assert base64.b64decode(image).startswith(PNG_MAGIC)
answer = fake_redis.get(REDIS_KEY_CAPTCHA.format(captcha_id=captcha_id))
assert captcha_service.verify_and_consume(captcha_id, answer) is True
def test_同一验证码二次使用必然失败(self, fake_redis) -> None:
"""GETDEL 一次性语义的核心断言 —— 防重放。"""
captcha_id, _, _ = captcha_service.issue()
answer = fake_redis.get(REDIS_KEY_CAPTCHA.format(captcha_id=captcha_id))
assert captcha_service.verify_and_consume(captcha_id, answer) is True
assert captcha_service.verify_and_consume(captcha_id, answer) is False
def test_校验失败后验证码同样被消费(self, fake_redis) -> None:
"""失败也要消费:否则攻击者可拿一个验证码反复试密码。"""
captcha_id, _, _ = captcha_service.issue()
answer = fake_redis.get(REDIS_KEY_CAPTCHA.format(captcha_id=captcha_id))
assert captcha_service.verify_and_consume(captcha_id, "XXXX") is False
assert captcha_service.verify_and_consume(captcha_id, answer) is False
def test_不存在的验证码返回False(self, fake_redis) -> None:
assert captcha_service.verify_and_consume("nonexistent-id", "AB3D") is False
def test_校验时忽略大小写与首尾空格(self, fake_redis) -> None:
"""用户从图片抄写时容易带上空格;答案统一按大写存储。"""
captcha_id, _, _ = captcha_service.issue()
answer = fake_redis.get(REDIS_KEY_CAPTCHA.format(captcha_id=captcha_id))
assert captcha_service.verify_and_consume(captcha_id, f" {answer.lower()} ") is True
def test_有效期300秒符合规范(self, fake_redis) -> None:
"""规范第 6 节要求验证码 300 秒。
容忍 1 秒偏差:Redis 的 TTL 以毫秒精度存绝对过期时刻,
命令返回时向下取整,刚写入就查询必然是 N 或 N-1。
"""
captcha_id, _, _ = captcha_service.issue()
ttl = fake_redis.ttl(REDIS_KEY_CAPTCHA.format(captcha_id=captcha_id))
assert ttl in (300, 299)
+83
View File
@@ -0,0 +1,83 @@
"""配置模块单元测试。
用 `_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 的配置实例。"""
params: dict[str, object] = {"_env_file": None, "app_secret_key": VALID_SECRET}
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"
@@ -0,0 +1,61 @@
"""全局异常处理器单元测试。
重点验证校验错误的文案压缩逻辑:FastAPI 默认返回 422 与 detail 数组,
本项目需映射为 400 与单行中文提示。
"""
from fastapi.exceptions import RequestValidationError
from app.core.constants import CODE_BAD_REQUEST
from app.core.exception_handlers import _build_response, _format_validation_errors
class TestFormatValidationErrors:
"""校验错误文案压缩。"""
def test_包含字段名与原始说明(self) -> None:
exc = RequestValidationError(
[{"loc": ("body", "username"), "msg": "Field required", "type": "missing"}]
)
assert _format_validation_errors(exc) == "参数错误:username Field required"
def test_去掉来源前缀只保留字段路径(self) -> None:
exc = RequestValidationError(
[
{
"loc": ("query", "pageNum"),
"msg": "Input should be greater than 0",
"type": "greater_than",
}
]
)
assert "query" not in _format_validation_errors(exc)
assert "pageNum" in _format_validation_errors(exc)
def test_多个错误时只取第一条(self) -> None:
exc = RequestValidationError(
[
{"loc": ("body", "a"), "msg": "错误甲", "type": "missing"},
{"loc": ("body", "b"), "msg": "错误乙", "type": "missing"},
]
)
message = _format_validation_errors(exc)
assert "错误甲" in message
assert "错误乙" not in message
def test_错误列表为空时兜底(self) -> None:
assert _format_validation_errors(RequestValidationError([])) == "参数错误"
class TestBuildResponse:
"""统一响应构造:HTTP 状态码与响应体 code 必须一致。"""
def test_状态码与响应体code一致(self) -> None:
response = _build_response(CODE_BAD_REQUEST, "参数错误")
assert response.status_code == CODE_BAD_REQUEST
import json
body = json.loads(response.body)
assert body["code"] == CODE_BAD_REQUEST
assert body["message"] == "参数错误"
assert body["data"] is None
+112
View File
@@ -0,0 +1,112 @@
"""JWT 签发与校验单元测试。
守的是:令牌的**有效期语义**与**版本号机制**。
这两点出错都不会在开发期暴露,但后果严重 ——
有效期算错 8 小时会表现为「刚登录就过期」或「过期了还能用」;
版本号机制失效则意味着禁用账号后旧令牌仍可访问全员薪酬。
"""
import uuid
from datetime import UTC, datetime, timedelta
import jwt
import pytest
from app.core.config import get_settings
from app.core.constants import JWT_ALGORITHM
from app.core.exceptions import AuthError
from app.core.security import create_access_token, decode_token
class TestCreateAccessToken:
"""令牌签发。"""
def test_返回令牌_jti与有效期(self) -> None:
token, jti, expires_in = create_access_token(user_id=1, token_version=1, roles=["admin"])
assert isinstance(token, str) and token.count(".") == 2 # header.payload.signature
assert len(jti) == 32
assert expires_in == get_settings().jwt_expire_minutes * 60
def test_载荷包含鉴定所需的全部字段(self) -> None:
token, jti, _ = create_access_token(user_id=7, token_version=3, roles=["admin", "user"])
payload = decode_token(token)
assert payload["sub"] == "7" # PyJWT 要求 sub 为字符串
assert payload["jti"] == jti
assert payload["ver"] == 3
assert payload["roles"] == ["admin", "user"]
def test_每次签发的jti都不同(self) -> None:
"""jti 是登出拉黑的唯一键,重复会导致误拉黑他人令牌。"""
jtis = set()
for _ in range(20):
_, jti, _ = create_access_token(user_id=1, token_version=1, roles=[])
jtis.add(jti)
assert len(jtis) == 20
def test_有效期基于UTC而非本地时间(self) -> None:
"""库列存本地时间、JWT 用 UTC,两者混算会产生 8 小时偏差。
这里断言 exp 与 UTC 当前时间的差值确实等于配置值 ——
若误用 datetime.now()(本地时间)生成,差值会差 8 小时。
"""
token, _, expires_in = create_access_token(user_id=1, token_version=1, roles=[])
exp = decode_token(token)["exp"]
drift = exp - datetime.now(UTC).timestamp()
assert abs(drift - expires_in) < 5 # 允许几秒执行耗时
class TestDecodeToken:
"""令牌解析与校验。"""
def test_正常令牌可解析(self) -> None:
token, _, _ = create_access_token(user_id=1, token_version=1, roles=["admin"])
assert decode_token(token)["sub"] == "1"
def test_已过期令牌抛AuthError(self) -> None:
"""直接构造过期令牌 —— create_access_token 的最小有效期为 1 分钟,
无法用它验证过期分支。"""
settings = get_settings()
past = datetime.now(UTC) - timedelta(hours=1)
expired = jwt.encode(
{
"sub": "1",
"jti": uuid.uuid4().hex,
"ver": 1,
"iat": past,
"exp": past + timedelta(seconds=60),
},
settings.app_secret_key,
algorithm=JWT_ALGORITHM,
)
with pytest.raises(AuthError):
decode_token(expired)
def test_签名被篡改时抛AuthError(self) -> None:
forged = jwt.encode(
{
"sub": "1",
"jti": uuid.uuid4().hex,
"ver": 1,
"exp": datetime.now(UTC) + timedelta(hours=1),
},
"wrong-secret-key-that-is-long-enough-to-pass-validation",
algorithm=JWT_ALGORITHM,
)
with pytest.raises(AuthError):
decode_token(forged)
@pytest.mark.parametrize("garbage", ["", "not.a.token", "a.b", "...."])
def test_格式非法的令牌抛AuthError而非其他异常(self, garbage: str) -> None:
"""令牌内容不可信,任何畸形输入都必须收敛为 401 而不是 500。"""
with pytest.raises(AuthError):
decode_token(garbage)
def test_算法被改为none时拒绝(self) -> None:
"""经典的 JWT 算法混淆攻击:把 alg 改成 none 伪造无签名令牌。"""
unsigned = jwt.encode(
{"sub": "1", "exp": datetime.now(UTC) + timedelta(hours=1)},
key="",
algorithm="none",
)
with pytest.raises(AuthError):
decode_token(unsigned)
+111
View File
@@ -0,0 +1,111 @@
"""日志脱敏单元测试。
规范第 8 节禁止日志输出密码、手机号、身份证、Token。
脱敏是最容易在重构中悄悄失效的一环,故用参数化用例覆盖各条规则。
"""
import logging
import pytest
from app.core.logging import SensitiveFormatter, scrub
class TestScrub:
"""文本脱敏规则。"""
@pytest.mark.parametrize(
("raw", "leaked"),
[
('{"password": "Admin@123"}', "Admin@123"),
("password=Secret123", "Secret123"),
("pwd: hunter2xyz", "hunter2xyz"),
('{"app_secret_key": "abcdef123456"}', "abcdef123456"),
("token=eyJhbGciOiJIUzI1NiJ9", "eyJhbGciOiJIUzI1NiJ9"),
],
)
def test_凭据值被脱敏(self, raw: str, leaked: str) -> None:
assert leaked not in scrub(raw)
def test_Bearer令牌被脱敏(self) -> None:
result = scrub("Authorization: Bearer eyJhbGciOi.J9.abc-_123")
assert "eyJhbGciOi.J9.abc-_123" not in result
assert "Bearer ***" in result
def test_手机号保留前三后四(self) -> None:
assert scrub("联系电话 13812345678") == "联系电话 138****5678"
def test_身份证保留前六后四(self) -> None:
assert scrub("证件号 110101199003071234") == "证件号 110101********1234"
def test_普通文本不被误伤(self) -> None:
text = "用户 admin 登录成功,耗时 120ms"
assert scrub(text) == text
def _format(msg: str, args: object = None) -> str:
"""用 SensitiveFormatter 渲染一条日志,返回最终文本。"""
record = logging.LogRecord(
name="t",
level=logging.INFO,
pathname="",
lineno=0,
msg=msg,
args=args,
exc_info=None,
)
return SensitiveFormatter("%(message)s").format(record)
class TestSensitiveFormatter:
"""脱敏必须在 %-格式化之后进行。"""
def test_脱敏位置参数中的敏感值(self) -> None:
# 回归用例:早期实现在 filter 阶段改写 record.msg,把 %s 占位符一并抹掉,
# 导致 logging 内部 msg % args 抛 TypeError,整条日志丢失
result = _format("登录失败 password=%s", ("Secret123",))
assert "Secret123" not in result
assert "登录失败" in result
def test_脱敏字典参数中的敏感值(self) -> None:
# LogRecord 要求 args 为元组;字典参数须包成单元素元组,
# 这正是 logger.info(msg, {...}) 的内部形态
result = _format("token=%(token)s", ({"token": "abc123xyz"},))
assert "abc123xyz" not in result
def test_脱敏消息模板内联的敏感值(self) -> None:
"""敏感值直接写在消息模板里(无占位符)时同样要脱敏。"""
result = _format('请求体 {"password": "Admin@123"}')
assert "Admin@123" not in result
def test_普通日志不被改动(self) -> None:
assert (
_format("用户 admin 登录成功,耗时 %d ms", (120,)) == "用户 admin 登录成功,耗时 120 ms"
)
def test_无参数日志正常通过(self) -> None:
assert _format("服务已启动") == "服务已启动"
class TestSetupLogging:
"""setup_logging 必须挂上脱敏格式化器,否则规则形同虚设。"""
def test_所有handler均使用脱敏格式化器(self) -> None:
import logging as _logging
from app.core.logging import setup_logging
setup_logging(level="INFO", log_to_file=False)
handlers = _logging.getLogger().handlers
assert handlers, "根日志器应至少有一个 handler"
assert all(isinstance(h.formatter, SensitiveFormatter) for h in handlers)
def test_重复调用不会累积handler(self) -> None:
import logging as _logging
from app.core.logging import setup_logging
setup_logging(log_to_file=False)
first = len(_logging.getLogger().handlers)
setup_logging(log_to_file=False)
assert len(_logging.getLogger().handlers) == first
+82
View File
@@ -0,0 +1,82 @@
"""登录失败限流单元测试。
守的是:锁定阈值与**滑动窗口**语义。若记录失败时不刷新 TTL,
「4 次失败 + 等 14 分钟 + 1 次失败」就不会触发锁定,
正好给慢速撞库留出缝隙。
"""
from app.core.config import get_settings
from app.core.constants import REDIS_KEY_LOGIN_FAIL
from app.services import login_limiter
def _ttl(username: str) -> int:
from app.core.redis_client import get_redis
return get_redis().ttl(REDIS_KEY_LOGIN_FAIL.format(username=username))
class TestThreshold:
"""阈值判定。"""
def test_初始状态未锁定且计数为零(self, fake_redis) -> None:
assert login_limiter.failure_count("u") == 0
assert login_limiter.is_locked("u") is False
def test_未达阈值时不锁定(self, fake_redis) -> None:
for _ in range(get_settings().login_fail_limit - 1):
login_limiter.record_failure("u")
assert login_limiter.is_locked("u") is False
def test_达到阈值即锁定(self, fake_redis) -> None:
for _ in range(get_settings().login_fail_limit):
login_limiter.record_failure("u")
assert login_limiter.is_locked("u") is True
def test_不同用户之间互不影响(self, fake_redis) -> None:
"""按用户名隔离,避免一个账号被攻击导致全员被锁。"""
for _ in range(get_settings().login_fail_limit):
login_limiter.record_failure("victim")
assert login_limiter.is_locked("victim") is True
assert login_limiter.is_locked("others") is False
class TestSlidingWindow:
"""滑动窗口:每次失败都刷新过期时间。"""
def test_锁定时长与配置一致(self, fake_redis) -> None:
"""容忍 1 秒偏差:Redis 的 TTL 向下取整,刚写入就查询可能少 1 秒。"""
login_limiter.record_failure("u")
expected = get_settings().login_lock_minutes * 60
assert _ttl("u") in (expected, expected - 1)
def test_再次失败会刷新过期时间(self, fake_redis) -> None:
"""把 TTL 改小模拟「距上次失败已过 14 分钟」,再失败一次应重新计满。
若不刷新,此处 TTL 仍是改小后的值,"慢速撞库"就能绕过锁定。
"""
login_limiter.record_failure("u")
fake_redis.expire(REDIS_KEY_LOGIN_FAIL.format(username="u"), 10)
login_limiter.record_failure("u")
assert _ttl("u") > 600
class TestClearAndRemaining:
"""解锁与剩余时长。"""
def test_登录成功清除失败计数(self, fake_redis) -> None:
for _ in range(get_settings().login_fail_limit):
login_limiter.record_failure("u")
login_limiter.clear("u")
assert login_limiter.is_locked("u") is False
assert login_limiter.failure_count("u") == 0
def test_未锁定时剩余秒数为零(self, fake_redis) -> None:
assert login_limiter.remaining_lock_seconds("u") == 0
def test_锁定后剩余秒数为正且不超过锁定时长(self, fake_redis) -> None:
for _ in range(get_settings().login_fail_limit):
login_limiter.record_failure("u")
remaining = login_limiter.remaining_lock_seconds("u")
assert 0 < remaining <= get_settings().login_lock_minutes * 60
+127
View File
@@ -0,0 +1,127 @@
"""ORM 模型元数据测试。
建表规范(表小写蛇形复数、字段中文 COMMENT、主键 id、索引 uk_/idx_ 前缀)
属于「红线」,靠人工 review 迟早会漏,这里用测试把它固定下来。
"""
import re
import pytest
from sqlalchemy.dialects import mysql
from app.models import Base
EXPECTED_TABLES = {"users", "roles", "user_roles", "permissions", "role_permissions", "login_logs"}
def _ddl(table_name: str) -> str:
"""返回某张表的建表 DDL 文本。"""
from sqlalchemy.schema import CreateTable
table = Base.metadata.tables[table_name]
return str(CreateTable(table).compile(dialect=mysql.dialect()))
class TestTableSet:
"""表集合必须完整 —— 漏一张就会让 alembic 生成「删除该表」的迁移。"""
def test_表集合与预期一致(self) -> None:
assert set(Base.metadata.tables) == EXPECTED_TABLES
def test_所有表名均为小写蛇形复数(self) -> None:
for name in Base.metadata.tables:
assert re.fullmatch(r"[a-z][a-z0-9_]*", name), f"表名 {name} 不符合小写蛇形"
assert name.endswith("s"), f"表名 {name} 应为复数"
class TestNamingConvention:
"""约束命名:规范要求 uk_表_字段 / idx_表_字段。"""
@pytest.mark.parametrize("table_name", sorted(EXPECTED_TABLES))
def test_主键命名为pk_表名(self, table_name: str) -> None:
pk = Base.metadata.tables[table_name].primary_key
assert pk.name == f"pk_{table_name}"
def test_唯一键以uk_开头(self) -> None:
for table in Base.metadata.tables.values():
for constraint in table.constraints:
if constraint.__class__.__name__ == "UniqueConstraint":
assert constraint.name.startswith(f"uk_{table.name}_"), constraint.name
def test_索引以idx_开头(self) -> None:
for table in Base.metadata.tables.values():
for index in table.indexes:
assert index.name.startswith(f"idx_{table.name}_"), index.name
def test_外键以fk_开头(self) -> None:
for table in Base.metadata.tables.values():
for fk in table.foreign_keys:
assert fk.constraint.name.startswith(f"fk_{table.name}_"), fk.constraint.name
class TestComments:
"""规范红线要求中文 COMMENT。"""
@pytest.mark.parametrize("table_name", sorted(EXPECTED_TABLES))
def test_表级中文注释存在(self, table_name: str) -> None:
assert Base.metadata.tables[table_name].comment, f"{table_name} 缺少表注释"
@pytest.mark.parametrize("table_name", sorted(EXPECTED_TABLES))
def test_每列都有中文注释(self, table_name: str) -> None:
table = Base.metadata.tables[table_name]
missing = [c.name for c in table.columns if not c.comment]
assert not missing, f"{table_name} 缺注释的列:{missing}"
@pytest.mark.parametrize("table_name", sorted(EXPECTED_TABLES))
def test_注释确实输出到DDL(self, table_name: str) -> None:
ddl = _ddl(table_name)
assert "COMMENT '" in ddl
assert f"COMMENT='{Base.metadata.tables[table_name].comment}'" in ddl
class TestMysqlTableOptions:
"""引擎与字符集:不显式指定则跟随服务端,跨环境可能不一致。"""
@pytest.mark.parametrize("table_name", sorted(EXPECTED_TABLES))
def test_显式指定引擎与字符集(self, table_name: str) -> None:
ddl = _ddl(table_name)
assert "ENGINE=InnoDB" in ddl
assert "CHARSET=utf8mb4" in ddl
assert "utf8mb4_general_ci" in ddl
@pytest.mark.parametrize("table_name", sorted(EXPECTED_TABLES))
def test_主键为无符号BIGINT(self, table_name: str) -> None:
assert "id BIGINT UNSIGNED NOT NULL" in _ddl(table_name)
class TestTimestampColumns:
"""更新时间必须由数据库维护 —— 这是 server_onupdate 做不到的。"""
@pytest.mark.parametrize("table_name", ["users", "roles", "permissions"])
def test_带时间戳的表含ON_UPDATE子句(self, table_name: str) -> None:
assert "ON UPDATE CURRENT_TIMESTAMP" in _ddl(table_name)
@pytest.mark.parametrize("table_name", ["login_logs"])
def test_仅追加的日志表不含ON_UPDATE子句(self, table_name: str) -> None:
"""日志是不可变记录,没有"更新时间"语义。"""
assert "ON UPDATE CURRENT_TIMESTAMP" not in _ddl(table_name)
def test_关联表保留create_time便于审计(self) -> None:
for table_name in ("user_roles", "role_permissions"):
assert "create_time" in Base.metadata.tables[table_name].columns
class TestEnumColumns:
"""状态类字段的默认值。"""
def test_用户默认启用且令牌版本为1(self) -> None:
users = Base.metadata.tables["users"]
assert users.columns["status"].server_default.arg == "1"
assert users.columns["token_version"].server_default.arg == "1"
def test_角色编码唯一(self) -> None:
ddl = _ddl("roles")
assert "uk_roles_role_code UNIQUE (role_code)" in ddl
def test_用户名唯一(self) -> None:
assert "uk_users_username UNIQUE (username)" in _ddl("users")
+67
View File
@@ -0,0 +1,67 @@
"""统一响应模型单元测试。"""
import pytest
from pydantic import ValidationError
from app.core.response import ApiResponse, PageQuery, PageResult, ok
class TestApiResponse:
"""响应信封结构。"""
def test_默认值符合规范(self) -> None:
resp = ApiResponse[None]()
assert resp.code == 200
assert resp.message == "success"
assert resp.data is None
def test_序列化字段固定为code_message_data(self) -> None:
keys = set(ApiResponse[None]().model_dump().keys())
assert keys == {"code", "message", "data"}
class TestOkHelper:
"""成功响应构造助手。"""
def test_携带业务数据(self) -> None:
resp = ok({"id": 1})
assert resp.code == 200
assert resp.data == {"id": 1}
def test_可自定义提示语(self) -> None:
assert ok(message="登录成功").message == "登录成功"
class TestPageResult:
"""分页结果:Python 蛇形属性名,序列化为规范要求的驼峰字段。"""
def test_序列化输出驼峰字段(self) -> None:
dumped = PageResult[int](records=[1, 2], total=2, page_num=1, page_size=10).model_dump(
by_alias=True
)
assert dumped == {"list": [1, 2], "total": 2, "pageNum": 1, "pageSize": 10}
def test_空结果时list为空数组而非null(self) -> None:
dumped = PageResult[int]().model_dump(by_alias=True)
assert dumped["list"] == []
class TestPageQuery:
"""分页入参。"""
def test_offset按页码换算(self) -> None:
assert PageQuery(page_num=1, page_size=10).offset == 0
assert PageQuery(page_num=3, page_size=20).offset == 40
@pytest.mark.parametrize("bad_page", [0, -1])
def test_页码小于1时拒绝(self, bad_page: int) -> None:
with pytest.raises(ValidationError):
PageQuery(page_num=bad_page)
def test_每页条数超上限时拒绝(self) -> None:
with pytest.raises(ValidationError):
PageQuery(page_size=201)
def test_接受驼峰输入(self) -> None:
query = PageQuery.model_validate({"pageNum": 2, "pageSize": 5})
assert (query.page_num, query.page_size) == (2, 5)
+66
View File
@@ -0,0 +1,66 @@
"""密码哈希单元测试。
重点覆盖 bcrypt 5.0 的 72 字节硬限制 —— 此前是静默截断,现在是抛 ValueError,
若不在边界上把关,用户设一个长密码就会在登录时炸出 500。
"""
import pytest
from app.core.constants import PASSWORD_MAX_BYTES
from app.core.security import hash_password, verify_password
class TestHashPassword:
"""哈希生成。"""
def test_输出为BCrypt格式且定长60字符(self) -> None:
hashed = hash_password("Admin@2026")
assert hashed.startswith("$2")
assert len(hashed) == 60
def test_相同明文两次哈希结果不同(self) -> None:
"""BCrypt 内含随机盐,相同明文每次结果不同 —— 这是防彩虹表的基础。"""
assert hash_password("SamePwd@1") != hash_password("SamePwd@1")
def test_恰好72字节时允许(self) -> None:
password = "a" * PASSWORD_MAX_BYTES
assert len(password.encode("utf-8")) == PASSWORD_MAX_BYTES
assert len(hash_password(password)) == 60
def test_超过72字节时抛ValueError(self) -> None:
with pytest.raises(ValueError):
hash_password("a" * (PASSWORD_MAX_BYTES + 1))
@pytest.mark.parametrize(
("text", "count", "should_pass"),
[("中", 24, True), ("中", 25, False)],
)
def test_中文按字节而非字符计算边界(self, text: str, count: int, should_pass: bool) -> None:
"""一个汉字 3 字节:24 个正好 72 字节,25 个即 75 字节超限。"""
password = text * count
if should_pass:
assert len(hash_password(password)) == 60
else:
with pytest.raises(ValueError):
hash_password(password)
class TestVerifyPassword:
"""哈希校验。"""
def test_正确密码校验通过(self) -> None:
assert verify_password("Admin@2026", hash_password("Admin@2026")) is True
def test_错误密码校验失败(self) -> None:
assert verify_password("WrongPwd@1", hash_password("Admin@2026")) is False
def test_超长密码返回False而不抛异常(self) -> None:
"""校验路径不得因输入长度报错 —— 否则会形成「密码过长」与「密码错误」
可区分的侧信道,且会让恶意长输入直接打出 500。"""
assert verify_password("a" * 200, hash_password("Admin@2026")) is False
def test_损坏的哈希串返回False而不抛异常(self) -> None:
assert verify_password("Admin@2026", "not-a-bcrypt-hash") is False
def test_空哈希串返回False(self) -> None:
assert verify_password("Admin@2026", "") is False
@@ -0,0 +1,60 @@
"""登出黑名单单元测试。
守的是:黑名单的 **TTL 必须等于令牌剩余有效期**。
写死固定值会出现「登出 N 分钟后令牌又活了」;
完全不设过期则 Redis 会随登出次数无限增长。两者都是真实事故。
"""
from app.core.constants import REDIS_KEY_TOKEN_BLACKLIST
from app.services import token_blacklist
def _ttl(jti: str) -> int:
from app.core.redis_client import get_redis
return get_redis().ttl(REDIS_KEY_TOKEN_BLACKLIST.format(jti=jti))
class TestRevoke:
"""拉黑。"""
def test_拉黑后可检出(self, fake_redis) -> None:
token_blacklist.revoke("jti-1", 600)
assert token_blacklist.is_revoked("jti-1") is True
def test_TTL等于传入的剩余秒数(self, fake_redis) -> None:
"""容忍 1 秒偏差:Redis 的 TTL 以毫秒精度存绝对过期时刻,
命令返回时向下取整,刚写入就查询必然是 N 或 N-1。
断言死等会变成偶发失败的"闪烁测试"。"""
token_blacklist.revoke("jti-1", 1234)
assert _ttl("jti-1") in (1234, 1233)
def test_键带过期时间而非永久存在(self, fake_redis) -> None:
"""永久存储会让 Redis 随登出次数无限增长。"""
token_blacklist.revoke("jti-1", 600)
assert _ttl("jti-1") > 0
def test_已过期令牌不写入(self, fake_redis) -> None:
"""剩余有效期为 0 或负数时无凭证可撤销,写入只会浪费内存。"""
token_blacklist.revoke("jti-expired", 0)
token_blacklist.revoke("jti-expired2", -100)
assert token_blacklist.is_revoked("jti-expired") is False
assert token_blacklist.is_revoked("jti-expired2") is False
def test_重复拉黑同一jti不报错且TTL更新(self, fake_redis) -> None:
token_blacklist.revoke("jti-1", 600)
token_blacklist.revoke("jti-1", 300)
assert _ttl("jti-1") in (300, 299)
class TestIsRevoked:
"""检测。"""
def test_未拉黑的令牌返回False(self, fake_redis) -> None:
assert token_blacklist.is_revoked("never-revoked") is False
def test_不同jti之间互不影响(self, fake_redis) -> None:
"""jti 是唯一键,串号会导致误踢其他用户的登录态。"""
token_blacklist.revoke("jti-1", 600)
assert token_blacklist.is_revoked("jti-1") is True
assert token_blacklist.is_revoked("jti-2") is False