"""Tests for M4-T02: login throttle service and endpoint integration.""" from __future__ import annotations from datetime import UTC, datetime, timedelta from pathlib import Path import pytest from alembic import command from alembic.config import Config from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker, Session from app.db import reset_db_caches from app.config import get_settings from app.main import create_app from app.models.auth_throttle import LoginThrottle from app.services import login_throttle as svc from app.services.login_throttle import ( N_FREE, BACKOFF_BASE_S, BACKOFF_CAP_S, _compute_next_allowed_at, ) # --------------------------------------------------------------------------- # Fixtures — throttle-aware variants (throttle enabled) # --------------------------------------------------------------------------- def _make_app_alembic_config(database_url: str) -> Config: config = Config("alembic_app.ini") config.set_main_option("sqlalchemy.url", database_url) return config @pytest.fixture() def throttle_db(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): """Fresh migrated DB with throttle enabled (AUTH_LOGIN_THROTTLE_ENABLED=true).""" app_database_path = tmp_path / "throttle_test.db" app_database_url = f"sqlite:///{app_database_path}" monkeypatch.setenv("APP_DATABASE_URL", app_database_url) monkeypatch.setenv("AUTH_BOOTSTRAP_USERNAME", "admin") monkeypatch.setenv("AUTH_BOOTSTRAP_PASSWORD", "test-password") monkeypatch.setenv("AUTH_COOKIE_SECURE_OVERRIDE", "false") monkeypatch.setenv("AUTH_LOGIN_THROTTLE_ENABLED", "true") get_settings.cache_clear() reset_db_caches() command.upgrade(_make_app_alembic_config(app_database_url), "head") reset_db_caches() yield app_database_url get_settings.cache_clear() reset_db_caches() @pytest.fixture() def throttle_session(throttle_db: str) -> Session: """SQLAlchemy session for the throttle-enabled test DB.""" engine = create_engine(throttle_db, connect_args={"check_same_thread": False}) SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, class_=Session) session = SessionLocal() try: yield session finally: session.close() engine.dispose() @pytest.fixture() def throttle_client(throttle_db: str): """TestClient wired to a throttle-enabled DB.""" app = create_app() with TestClient(app) as client: yield client # --------------------------------------------------------------------------- # Unit tests — service internals # --------------------------------------------------------------------------- class TestComputeNextAllowedAt: """Verify the exponential formula independently of DB I/O.""" def test_no_delay_for_first_n_free_failures(self): now = datetime.now(UTC) for f in range(1, N_FREE + 1): result = _compute_next_allowed_at(f, now) assert result is None, f"Expected None for failures={f}" def test_delay_starts_after_n_free(self): now = datetime.now(UTC) result = _compute_next_allowed_at(N_FREE + 1, now) assert result is not None expected_wait = BACKOFF_BASE_S * (2 ** (N_FREE + 1 - N_FREE)) # base * 2^1 assert abs((result - now).total_seconds() - expected_wait) < 0.1 def test_exponential_growth(self): now = datetime.now(UTC) prev_wait = 0.0 for failures in range(N_FREE + 1, N_FREE + 8): result = _compute_next_allowed_at(failures, now) assert result is not None wait = (result - now).total_seconds() if prev_wait > 0: # Each step should double (until cap) if wait < BACKOFF_CAP_S: assert abs(wait - prev_wait * 2) < 0.1, ( f"Expected doubling at failures={failures}: {prev_wait} → {wait}" ) prev_wait = wait def test_cap_is_respected(self): now = datetime.now(UTC) # Use a very high failure count to guarantee we're past the cap threshold. result = _compute_next_allowed_at(N_FREE + 20, now) assert result is not None wait = (result - now).total_seconds() assert wait <= BACKOFF_CAP_S + 0.1 class TestRegisterFailureAndCheck: """Service functions against an in-memory DB.""" def test_single_failure_within_free_window_no_wait(self, throttle_session: Session): svc.register_failure(throttle_session, ip="1.2.3.4", username="alice") wait = svc.check_and_get_wait(throttle_session, ip="1.2.3.4", username="alice") assert wait == 0, "First failure should not produce any wait" def test_free_failures_all_zero_wait(self, throttle_session: Session): for _ in range(N_FREE): svc.register_failure(throttle_session, ip="10.0.0.1", username="bob") wait = svc.check_and_get_wait(throttle_session, ip="10.0.0.1", username="bob") assert wait == 0 def test_fourth_failure_produces_wait(self, throttle_session: Session): """failures = N_FREE + 1 should produce a non-zero wait.""" for _ in range(N_FREE + 1): svc.register_failure(throttle_session, ip="10.0.0.2", username="carol") wait = svc.check_and_get_wait(throttle_session, ip="10.0.0.2", username="carol") assert wait > 0 def test_wait_grows_with_more_failures(self, throttle_session: Session): ip, user = "10.0.0.3", "dave" # Accumulate past the free window for _ in range(N_FREE + 1): svc.register_failure(throttle_session, ip=ip, username=user) wait1 = svc.check_and_get_wait(throttle_session, ip=ip, username=user) svc.register_failure(throttle_session, ip=ip, username=user) wait2 = svc.check_and_get_wait(throttle_session, ip=ip, username=user) assert wait2 > wait1, "Wait should increase with more failures" def test_wait_is_capped(self, throttle_session: Session): ip, user = "10.0.0.4", "eve" # Simulate a high failure count by calling register_failure many times. for _ in range(N_FREE + 15): svc.register_failure(throttle_session, ip=ip, username=user) wait = svc.check_and_get_wait(throttle_session, ip=ip, username=user) assert wait <= BACKOFF_CAP_S, f"Wait {wait} exceeds cap {BACKOFF_CAP_S}" def test_clear_removes_rows(self, throttle_session: Session): ip, user = "10.0.0.5", "frank" for _ in range(N_FREE + 2): svc.register_failure(throttle_session, ip=ip, username=user) assert svc.check_and_get_wait(throttle_session, ip=ip, username=user) > 0 svc.clear(throttle_session, ip=ip, username=user) assert svc.check_and_get_wait(throttle_session, ip=ip, username=user) == 0 def test_clear_is_idempotent(self, throttle_session: Session): """Calling clear when no rows exist must not raise.""" svc.clear(throttle_session, ip="192.168.1.1", username="nobody") assert svc.check_and_get_wait(throttle_session, ip="192.168.1.1", username="nobody") == 0 def test_dual_key_max_is_used(self, throttle_session: Session): """If only the username key is in backoff, check returns its wait time.""" # Register enough failures for username but use a fresh IP. user = "grace" for _ in range(N_FREE + 1): svc.register_failure(throttle_session, ip="172.16.0.1", username=user) # Fresh IP, same username — username key should dominate. wait = svc.check_and_get_wait(throttle_session, ip="172.16.0.2", username=user) assert wait > 0, "Username key should dominate even with a fresh IP" def test_expired_window_is_zero(self, throttle_session: Session): """A row whose next_allowed_at is in the past should yield 0.""" now = datetime.now(UTC) past = now - timedelta(seconds=10) row = LoginThrottle( scope="ip", key="192.168.99.1", failures=N_FREE + 2, first_failed_at=past, last_failed_at=past, next_allowed_at=past, ) throttle_session.add(row) throttle_session.commit() wait = svc.check_and_get_wait(throttle_session, ip="192.168.99.1", username="nobody") assert wait == 0 # --------------------------------------------------------------------------- # Integration tests — POST /api/auth/login endpoint # --------------------------------------------------------------------------- def _bad_login(client: TestClient, *, username: str = "admin", password: str = "wrong") -> int: resp = client.post("/api/auth/login", json={"username": username, "password": password}) return resp.status_code def _good_login(client: TestClient) -> int: resp = client.post( "/api/auth/login", json={"username": "admin", "password": "test-password"} ) return resp.status_code class TestLoginEndpointThrottle: def test_first_few_failures_return_401(self, throttle_client: TestClient): """First N_FREE failures should still return 401, not 429.""" for _ in range(N_FREE): code = _bad_login(throttle_client) assert code == 401, f"Expected 401 within free window, got {code}" def test_429_after_enough_failures(self, throttle_client: TestClient): """After N_FREE+1 failures the endpoint must return 429.""" for _ in range(N_FREE + 1): _bad_login(throttle_client) resp = throttle_client.post( "/api/auth/login", json={"username": "admin", "password": "wrong"} ) assert resp.status_code == 429 def test_429_includes_retry_after_header(self, throttle_client: TestClient): """429 response must carry a Retry-After header with a positive integer.""" for _ in range(N_FREE + 1): _bad_login(throttle_client) resp = throttle_client.post( "/api/auth/login", json={"username": "admin", "password": "wrong"} ) assert resp.status_code == 429 retry_after = resp.headers.get("retry-after") assert retry_after is not None, "Retry-After header missing" assert int(retry_after) > 0, "Retry-After must be positive" def test_correct_password_blocked_in_window(self, throttle_client: TestClient): """Even a correct password must be rejected (429) while inside backoff window.""" for _ in range(N_FREE + 1): _bad_login(throttle_client) resp = throttle_client.post( "/api/auth/login", json={"username": "admin", "password": "test-password"} ) assert resp.status_code == 429, ( "Correct password should still be blocked while in backoff window" ) def test_success_clears_throttle(self, throttle_client: TestClient): """A successful login must clear the backoff so the next attempt is free.""" # Register some failures (but not enough to trigger 429). for _ in range(N_FREE): _bad_login(throttle_client) # Successful login clears state. assert _good_login(throttle_client) == 200 # Subsequent bad attempts should be counted fresh (no residual backoff). code = _bad_login(throttle_client) assert code == 401, "After clear, first new failure should be 401 not 429" def test_401_uses_generic_message(self, throttle_client: TestClient): """401 detail must be generic — must not reveal whether the user exists.""" resp = throttle_client.post( "/api/auth/login", json={"username": "nonexistent", "password": "wrong"} ) assert resp.status_code == 401 detail = resp.json().get("detail", "") assert "invalid username or password" == detail assert "user" not in detail.lower() or "username" in detail.lower() # Must NOT say things like "user not found" or "user does not exist" assert "not found" not in detail assert "does not exist" not in detail def test_unknown_user_401_has_same_message(self, throttle_client: TestClient): """Unknown username and wrong password for known user give identical 401 detail.""" resp_known = throttle_client.post( "/api/auth/login", json={"username": "admin", "password": "wrong"} ) resp_unknown = throttle_client.post( "/api/auth/login", json={"username": "nobody", "password": "wrong"} ) assert resp_known.status_code == 401 assert resp_unknown.status_code == 401 assert resp_known.json()["detail"] == resp_unknown.json()["detail"] def test_retry_after_grows_with_more_failures(self, throttle_client: TestClient): """Retry-After should be strictly larger after more failures (exponential).""" # Accumulate past the free window. for _ in range(N_FREE + 1): _bad_login(throttle_client) resp1 = throttle_client.post( "/api/auth/login", json={"username": "admin", "password": "wrong"} ) assert resp1.status_code == 429 wait1 = int(resp1.headers["retry-after"]) # One more failure → longer wait. _bad_login(throttle_client) resp2 = throttle_client.post( "/api/auth/login", json={"username": "admin", "password": "wrong"} ) assert resp2.status_code == 429 wait2 = int(resp2.headers["retry-after"]) assert wait2 >= wait1, "Retry-After should not decrease with more failures" def test_xff_header_used_when_trust_enabled( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ): """When AUTH_TRUST_FORWARDED_FOR=true the leftmost XFF IP is used as the IP key.""" app_database_path = tmp_path / "xff_test.db" app_database_url = f"sqlite:///{app_database_path}" monkeypatch.setenv("APP_DATABASE_URL", app_database_url) monkeypatch.setenv("AUTH_BOOTSTRAP_USERNAME", "admin") monkeypatch.setenv("AUTH_BOOTSTRAP_PASSWORD", "test-password") monkeypatch.setenv("AUTH_COOKIE_SECURE_OVERRIDE", "false") monkeypatch.setenv("AUTH_LOGIN_THROTTLE_ENABLED", "true") monkeypatch.setenv("AUTH_TRUST_FORWARDED_FOR", "true") get_settings.cache_clear() reset_db_caches() command.upgrade(_make_app_alembic_config(app_database_url), "head") reset_db_caches() app = create_app() with TestClient(app) as client: # Accumulate failures from XFF IP "5.5.5.5". for _ in range(N_FREE + 1): client.post( "/api/auth/login", json={"username": "admin", "password": "wrong"}, headers={"X-Forwarded-For": "5.5.5.5, 10.0.0.1"}, ) # Same XFF IP should now be in backoff. resp = client.post( "/api/auth/login", json={"username": "admin", "password": "wrong"}, headers={"X-Forwarded-For": "5.5.5.5, 10.0.0.1"}, ) assert resp.status_code == 429 # Different XFF IP should NOT be in backoff (username might be, so use fresh one). resp2 = client.post( "/api/auth/login", json={"username": "admin", "password": "wrong"}, headers={"X-Forwarded-For": "6.6.6.6"}, ) # This IP has no failures, but username "admin" DOES have N_FREE+1 failures. # The username key is in backoff, so this should still 429. # To cleanly test IP isolation we'd need a different username — but single admin # means we can only confirm the XFF IP is being read (not defaulting to socket). # The important thing: the request was processed with XFF logic (no crash). assert resp2.status_code in (401, 429) get_settings.cache_clear() reset_db_caches() # --------------------------------------------------------------------------- # Throttle disabled — no-op behaviour # --------------------------------------------------------------------------- class TestThrottleDisabled: """When AUTH_LOGIN_THROTTLE_ENABLED=false the endpoint behaves exactly as before.""" def test_many_failures_still_return_401_not_429(self, client: TestClient): """With throttle off, no number of failures should produce 429.""" # The default `client` fixture has throttle disabled (AUTH_LOGIN_THROTTLE_ENABLED=false). for _ in range(N_FREE + 5): resp = client.post( "/api/auth/login", json={"username": "admin", "password": "wrong"} ) assert resp.status_code == 401, f"Expected 401 with throttle off, got {resp.status_code}" def test_no_throttle_rows_written_when_disabled( self, auth_database, monkeypatch: pytest.MonkeyPatch ): """With throttle disabled, no LoginThrottle rows are created.""" db_url = auth_database["app_url"] app = create_app() with TestClient(app) as client: for _ in range(N_FREE + 2): client.post( "/api/auth/login", json={"username": "admin", "password": "wrong"} ) engine = create_engine(db_url, connect_args={"check_same_thread": False}) SessionLocal = sessionmaker( bind=engine, autoflush=False, autocommit=False, class_=Session ) with SessionLocal() as session: count = session.query(LoginThrottle).count() engine.dispose() assert count == 0, f"Expected 0 throttle rows with throttle disabled, found {count}"