test_login_lockout.py 2.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172
  1. """登录失败锁定策略纯逻辑测试(不连接数据库)。"""
  2. from __future__ import annotations
  3. from datetime import datetime, timedelta, timezone
  4. from dms.models import User
  5. from dms.services.login_lockout import (
  6. LOGIN_LOCK_DURATION,
  7. LOGIN_MAX_FAILURES,
  8. apply_failed_attempt,
  9. clear_failed_attempts,
  10. is_locked,
  11. )
  12. # 与 login_lockout._utc_now 同口径取当前 UTC;在每个用例内调用(而非模块级常量),
  13. # 避免长批次下收集与执行的时间差让 ±1 秒边界断言失效。
  14. def _now() -> datetime:
  15. return datetime.now(timezone.utc).replace(tzinfo=None)
  16. def _user(*, failed_login_count=0, locked_until=None) -> User:
  17. user = User(username="u1", password_hash="x", real_name="用户一")
  18. user.failed_login_count = failed_login_count
  19. user.locked_until = locked_until
  20. return user
  21. def test_failures_below_threshold_do_not_lock():
  22. user = _user()
  23. for _ in range(LOGIN_MAX_FAILURES - 1):
  24. apply_failed_attempt(user, now=_now())
  25. assert user.failed_login_count == LOGIN_MAX_FAILURES - 1
  26. assert user.locked_until is None
  27. assert not is_locked(user)
  28. def test_fifth_consecutive_failure_locks_and_resets_count():
  29. now = _now()
  30. user = _user(failed_login_count=LOGIN_MAX_FAILURES - 1)
  31. apply_failed_attempt(user, now=now)
  32. assert user.locked_until == now + LOGIN_LOCK_DURATION
  33. assert user.failed_login_count == 0
  34. assert is_locked(user)
  35. def test_lock_window_is_fifteen_minutes():
  36. assert LOGIN_MAX_FAILURES == 5
  37. assert LOGIN_LOCK_DURATION == timedelta(minutes=15)
  38. def test_is_locked_expired_or_absent_returns_false():
  39. now = _now()
  40. assert not is_locked(_user(locked_until=None))
  41. assert not is_locked(_user(locked_until=now - timedelta(seconds=1)))
  42. assert is_locked(_user(locked_until=now + timedelta(seconds=1)))
  43. def test_clear_failed_attempts_zeroes_state():
  44. user = _user(failed_login_count=3, locked_until=_now() + timedelta(minutes=5))
  45. clear_failed_attempts(user)
  46. assert user.failed_login_count == 0
  47. assert user.locked_until is None
  48. def test_count_resets_after_lock_expiry_window():
  49. """锁定到期后视为未锁定,重新计数从零开始。"""
  50. user = _user(locked_until=_now() - timedelta(minutes=1))
  51. assert not is_locked(user)
  52. apply_failed_attempt(user, now=_now())
  53. assert user.failed_login_count == 1