PayrollManagementSystem/backend/tests/unit/test_captcha.py
liuwenxin 273ebf4657 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>
2026-10-09 18:46:36 +08:00

92 lines
4.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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