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)
|