"""登录失败限流单元测试。 守的是:锁定阈值与**滑动窗口**语义。若记录失败时不刷新 TTL, 「4 次失败 + 等 14 分钟 + 1 次失败」就不会触发锁定, 正好给慢速撞库留出缝隙。 """ import pytest 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 TestAtomicWrite: """计数和 TTL 必须同事务生效。""" def test_EXEC失败时不留下无TTL计数(self, fake_redis, monkeypatch) -> None: key = REDIS_KEY_LOGIN_FAIL.format(username="u") pipeline = fake_redis.pipeline(transaction=True) def fail_execute(): raise RuntimeError("EXEC 失败") monkeypatch.setattr(pipeline, "execute", fail_execute) monkeypatch.setattr(fake_redis, "pipeline", lambda **_: pipeline) with pytest.raises(RuntimeError, match="EXEC 失败"): login_limiter.record_failure("u") assert fake_redis.get(key) is None 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