"""登录失败锁定策略纯逻辑测试(不连接数据库)。""" from __future__ import annotations from datetime import datetime, timedelta, timezone from dms.models import User from dms.services.login_lockout import ( LOGIN_LOCK_DURATION, LOGIN_MAX_FAILURES, apply_failed_attempt, clear_failed_attempts, is_locked, ) # 与 login_lockout._utc_now 同口径取当前 UTC;在每个用例内调用(而非模块级常量), # 避免长批次下收集与执行的时间差让 ±1 秒边界断言失效。 def _now() -> datetime: return datetime.now(timezone.utc).replace(tzinfo=None) def _user(*, failed_login_count=0, locked_until=None) -> User: user = User(username="u1", password_hash="x", real_name="用户一") user.failed_login_count = failed_login_count user.locked_until = locked_until return user def test_failures_below_threshold_do_not_lock(): user = _user() for _ in range(LOGIN_MAX_FAILURES - 1): apply_failed_attempt(user, now=_now()) assert user.failed_login_count == LOGIN_MAX_FAILURES - 1 assert user.locked_until is None assert not is_locked(user) def test_fifth_consecutive_failure_locks_and_resets_count(): now = _now() user = _user(failed_login_count=LOGIN_MAX_FAILURES - 1) apply_failed_attempt(user, now=now) assert user.locked_until == now + LOGIN_LOCK_DURATION assert user.failed_login_count == 0 assert is_locked(user) def test_lock_window_is_fifteen_minutes(): assert LOGIN_MAX_FAILURES == 5 assert LOGIN_LOCK_DURATION == timedelta(minutes=15) def test_is_locked_expired_or_absent_returns_false(): now = _now() assert not is_locked(_user(locked_until=None)) assert not is_locked(_user(locked_until=now - timedelta(seconds=1))) assert is_locked(_user(locked_until=now + timedelta(seconds=1))) def test_clear_failed_attempts_zeroes_state(): user = _user(failed_login_count=3, locked_until=_now() + timedelta(minutes=5)) clear_failed_attempts(user) assert user.failed_login_count == 0 assert user.locked_until is None def test_count_resets_after_lock_expiry_window(): """锁定到期后视为未锁定,重新计数从零开始。""" user = _user(locked_until=_now() - timedelta(minutes=1)) assert not is_locked(user) apply_failed_attempt(user, now=_now()) assert user.failed_login_count == 1