PayrollManagementSystem/backend/tests/unit/test_login_limiter.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

83 lines
3.2 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.

"""登录失败限流单元测试。
守的是:锁定阈值与**滑动窗口**语义。若记录失败时不刷新 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