后端 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>
92 lines
4.1 KiB
Python
92 lines
4.1 KiB
Python
"""图形验证码单元测试。
|
||
|
||
守的是:**验证码的一次性语义**。若 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)
|