From 609499543e8d9daf13bf039f460e35c5f51c726d Mon Sep 17 00:00:00 2001 From: Nora Date: Mon, 22 Jun 2026 14:53:16 -0600 Subject: [PATCH] test(auth): login rate limit --- backend/tests/test_auth_login_rate_limit.py | 81 +++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 backend/tests/test_auth_login_rate_limit.py diff --git a/backend/tests/test_auth_login_rate_limit.py b/backend/tests/test_auth_login_rate_limit.py new file mode 100644 index 0000000..aa1252f --- /dev/null +++ b/backend/tests/test_auth_login_rate_limit.py @@ -0,0 +1,81 @@ +"""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(): + 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