"""TDD gate: durable rate limiting. Rate limiting must be durable (survive restarts) and tenant-scoped so login / admin mutation spikes cannot bypass limits. This gate proves a sliding-window counter that rejects when a key exceeds the configured threshold. """ from datetime import timedelta import pytest from sqlalchemy import create_engine from app.db import Base, create_session_factory from app.services.rate_limiter import RateLimiter @pytest.fixture() def session_factory(): engine = create_engine("sqlite+pysqlite:///:memory:") Base.metadata.create_all(engine) factory = create_session_factory(engine) try: yield factory finally: engine.dispose() def test_rate_limiter_allows_under_threshold(session_factory): session = session_factory() try: limiter = RateLimiter(session, window=timedelta(minutes=15), limit=5) for _ in range(4): assert limiter.check_and_record("login", key="user-a@example.com") is True finally: session.close() def test_rate_limiter_rejects_over_threshold(session_factory): session = session_factory() try: limiter = RateLimiter(session, window=timedelta(minutes=15), limit=3) for _ in range(3): assert limiter.check_and_record("login", key="user-b@example.com") is True assert limiter.check_and_record("login", key="user-b@example.com") is False finally: session.close() def test_rate_limiter_is_per_key(session_factory): session = session_factory() try: limiter = RateLimiter(session, window=timedelta(minutes=15), limit=2) assert limiter.check_and_record("login", key="a@example.com") is True assert limiter.check_and_record("login", key="a@example.com") is True assert limiter.check_and_record("login", key="a@example.com") is False # A different key is unaffected. assert limiter.check_and_record("login", key="b@example.com") is True finally: session.close() def test_rate_limiter_prevents_reset_by_restart(session_factory): # Two sessions/factories against the SAME engine prove durability. from app.services.rate_limiter import RateLimitEvent engine = create_engine("sqlite+pysqlite:///:memory:") Base.metadata.create_all(engine) factory = create_session_factory(engine) try: s1 = factory() limiter1 = RateLimiter(s1, window=timedelta(minutes=15), limit=2) assert limiter1.check_and_record("login", key="c@example.com") is True assert limiter1.check_and_record("login", key="c@example.com") is True assert limiter1.check_and_record("login", key="c@example.com") is False s1.close() s2 = factory() limiter2 = RateLimiter(s2, window=timedelta(minutes=15), limit=2) assert limiter2.check_and_record("login", key="c@example.com") is False s2.close() finally: engine.dispose() def test_rate_limit_migration_round_trip(tmp_path, monkeypatch): from pathlib import Path from alembic import command from alembic.config import Config as AlembicConfig from sqlalchemy import inspect from app.db import create_database_engine database_url = f"sqlite+pysqlite:///{tmp_path / 'rl-roundtrip.db'}" monkeypatch.setenv("DATABASE_URL", database_url) alembic_config = AlembicConfig(str(Path(__file__).resolve().parents[1] / "alembic.ini")) alembic_config.set_main_option("sqlalchemy.url", database_url) command.upgrade(alembic_config, "head") engine = create_database_engine(database_url) try: inspector = inspect(engine) assert "rate_limit_events" in set(inspector.get_table_names()) cols = {c["name"] for c in inspector.get_columns("rate_limit_events")} assert {"operation", "key", "organization_id", "created_at"}.issubset(cols) finally: engine.dispose() command.downgrade(alembic_config, "0008_platform_settings") engine = create_database_engine(database_url) try: assert "rate_limit_events" not in set(inspect(engine).get_table_names()) finally: engine.dispose() command.upgrade(alembic_config, "head") command.check(alembic_config) def test_login_endpoint_rate_limits_excessive_attempts(monkeypatch, tmp_path, session_factory): """Login POST is rate-limited: after the threshold, returns 429.""" import secrets from flask import Flask, jsonify from app.api.auth import auth_bp from app.services.identity import IdentityRepository, PasswordService from app.utils.api_errors import ApiError from app.utils.locale import t engine = create_engine("sqlite+pysqlite:///:memory:") Base.metadata.create_all(engine) factory = create_session_factory(engine) app = Flask(__name__) app.config.update(TESTING=True, SESSION_COOKIE_SECURE=False) app.config["SECRET_KEY"] = secrets.token_hex(32) app.extensions["crowdsight_session_factory"] = factory @app.errorhandler(ApiError) def handle_api_error(error): return jsonify(error.to_payload(t)), error.status_code app.register_blueprint(auth_bp, url_prefix="/api/auth") rate_email = "rate-limit@example.com" with factory() as session: repo = IdentityRepository(session) org = repo.create_organization(name="Rate Org", slug="rate-org") user = repo.create_user( email=rate_email, password_hash=PasswordService.hash_password("correct-horse"), ) repo.create_membership(user.id, org.id, "user") session.commit() client = app.test_client() # Exceed the login threshold (5) with wrong password. statuses = [] for _ in range(5): r = client.post( "/api/auth/login", json={"email": rate_email, "password": "wrong"}, ) statuses.append(r.status_code) # The 6th attempt is rejected by the rate limiter before credential check. r6 = client.post( "/api/auth/login", json={"email": rate_email, "password": "correct-horse"}, ) assert r6.status_code == 429 engine.dispose()