"""Login rate limit: 5 fails / 5 min per username.""" from __future__ import annotations import pytest from fastapi.testclient import TestClient from sqlalchemy import delete from cyclone.api import app from cyclone.auth import rate_limit, users from cyclone.db import Session as DbSession from cyclone.db import SessionLocal, User @pytest.fixture(autouse=True) def _clear(monkeypatch): # conftest sets AUTH_DISABLED=True so pre-auth tests keep passing # without a login. The auth-login-rate-limit tests need the real # auth path exercised (the rate limiter sits in front of /login), # so flip it back off here. monkeypatch.setattr("cyclone.auth.deps.AUTH_DISABLED", False) with SessionLocal()() as db: db.execute(delete(DbSession)) db.execute(delete(User)) db.commit() # Reset the in-memory rate-limit counter so a previous test's 5 # failures don't poison this test. The plan recipe calls this out. rate_limit.reset("victim") yield with SessionLocal()() as db: db.execute(delete(DbSession)) db.execute(delete(User)) db.commit() rate_limit.reset("victim") def test_5_fails_then_429(): with SessionLocal()() as db: users.create(db, username="victim", password="hunter2hunter2", role="user") client = TestClient(app) for _ in range(5): r = client.post( "/api/auth/login", json={"username": "victim", "password": "WRONG"}, ) assert r.status_code == 401 # 6th should be 429. r = client.post( "/api/auth/login", json={"username": "victim", "password": "WRONG"}, ) assert r.status_code == 429 assert "Retry-After" in r.headers # And the module-level helper confirms we're now over the threshold. assert rate_limit.check("victim") > 0 def test_successful_login_resets_counter(): with SessionLocal()() as db: users.create(db, username="victim", password="hunter2hunter2", role="user") client = TestClient(app) for _ in range(4): r = client.post( "/api/auth/login", json={"username": "victim", "password": "WRONG"}, ) assert r.status_code == 401 # Successful login — should reset the counter. r = client.post( "/api/auth/login", json={"username": "victim", "password": "hunter2hunter2"}, ) assert r.status_code == 200 # Counter reset — 5 more fails allowed. for _ in range(5): r = client.post( "/api/auth/login", json={"username": "victim", "password": "WRONG"}, ) assert r.status_code == 401 # 6th is now throttled. r = client.post( "/api/auth/login", json={"username": "victim", "password": "WRONG"}, ) assert r.status_code == 429