""" Tests for authentication rate limiting (login and registration endpoints). Tests that brute force protection is working correctly. """ import shutil import tempfile from pathlib import Path import pytest class TestAuthRateLimiting: """Test rate limiting on authentication endpoints.""" @pytest.fixture def temp_data_dir(self): """Create a temporary data directory for testing.""" temp_dir = tempfile.mkdtemp() yield Path(temp_dir) shutil.rmtree(temp_dir, ignore_errors=True) @pytest.fixture def app(self, temp_data_dir, monkeypatch): """Create a test Flask app with rate limiting.""" monkeypatch.setenv("LDR_DATA_DIR", str(temp_data_dir)) from local_deep_research.database.auth_db import init_auth_database from local_deep_research.database.encrypted_db import db_manager from local_deep_research.web.app_factory import create_app from local_deep_research.security.rate_limiter import limiter # Reset db_manager state db_manager.close_all_databases() db_manager.data_dir = temp_data_dir / "encrypted_databases" db_manager.data_dir.mkdir(parents=True, exist_ok=True) # Initialize auth database init_auth_database() app, _ = create_app() app.config["TESTING"] = True app.config["WTF_CSRF_ENABLED"] = False # Disable CSRF for testing # In CI, rate limiting is disabled at startup so init_app() returns # early without initializing storage or registering error handlers. # Re-call init_app() with RATELIMIT_ENABLED=True to fully initialize. app.config["RATELIMIT_ENABLED"] = True limiter.enabled = True limiter.init_app(app) # Reset limiter storage between tests to prevent cross-test pollution limiter.reset() yield app # Restore original state limiter.enabled = True db_manager.close_all_databases() @pytest.fixture def client(self, app): """Create a test client.""" return app.test_client() def test_login_rate_limit_allows_5_attempts(self, client): """Test that login allows 5 attempts before rate limiting.""" # Make 5 login attempts - should all be allowed # audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: tighten to assert status_code == 401 (invalid creds), not membership of 3 codes). for i in range(5): response = client.post( "/auth/login", data={"username": f"testuser{i}", "password": "wrongpassword"}, follow_redirects=False, ) # Should get 401 (invalid credentials), NOT 429 (rate limit). # Tightened from `status_code in [200, 401, 400]` (PUNCHLIST # H8_STATUS_OR) — that broad list masked bugs where the auth # path silently returned 400 / 200 for wrong credentials. assert response.status_code == 401, ( f"Attempt {i + 1}: wrong-credential login must return 401, " f"got {response.status_code}" ) def test_login_rate_limit_blocks_6th_attempt(self, client): """Test that login blocks the 6th attempt within 15 minutes.""" # Make 5 login attempts for i in range(5): client.post( "/auth/login", data={"username": f"testuser{i}", "password": "wrongpassword"}, ) # 6th attempt should be rate limited response = client.post( "/auth/login", data={"username": "testuser6", "password": "wrongpassword"}, ) assert response.status_code == 429, "6th attempt should be rate limited" def test_login_rate_limit_returns_proper_error(self, client): """Test that rate limit returns proper JSON error response.""" # Trigger rate limit # audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: remove conditional — assert response.status_code == 429 unconditionally before checking body). for i in range(6): response = client.post( "/auth/login", data={"username": f"testuser{i}", "password": "wrongpassword"}, ) # Confirm rate limit actually fired before inspecting the body. # Previously the body checks were gated by `if response.status_code == 429`, # which silently passed when rate limiting didn't fire — masking real bugs. assert response.status_code == 429, ( "6th attempt must be rate-limited; " f"got status {response.status_code}" ) data = response.get_json() assert "error" in data assert "message" in data assert "Too many" in data["message"] or "Too many" in data["error"] def test_login_rate_limit_includes_retry_after_header(self, client): """Test that 429 response includes Retry-After header.""" # Trigger rate limit # audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: remove conditional and assert 429 first). for i in range(6): response = client.post( "/auth/login", data={"username": f"testuser{i}", "password": "wrongpassword"}, ) # Confirm rate limit actually fired before inspecting headers. # Previously the header check was gated by `if response.status_code == 429`, # which silently passed when rate limiting didn't fire. assert response.status_code == 429, ( "6th attempt must be rate-limited; " f"got status {response.status_code}" ) assert ( "Retry-After" in response.headers or "X-RateLimit-Reset" in response.headers ), "Rate limit response should include retry timing header" def test_registration_rate_limit_allows_3_attempts(self, client): """Test that registration allows 3 attempts before rate limiting.""" # Make 3 registration attempts - should all be allowed for i in range(3): response = client.post( "/auth/register", data={ "username": f"newuser{i}", "password": "TestPass123", "confirm_password": "TestPass123", "acknowledge": "true", }, follow_redirects=False, ) # Should get 200/302 (success/redirect) or 400 (validation error) # but not 429 (rate limit) assert response.status_code in [ 200, 302, 400, ], f"Attempt {i + 1} should not be rate limited" def test_registration_rate_limit_blocks_4th_attempt(self, client): """Test that registration blocks the 4th attempt within 1 hour.""" # Make 3 registration attempts for i in range(3): client.post( "/auth/register", data={ "username": f"newuser{i}", "password": "TestPass123", "confirm_password": "TestPass123", "acknowledge": "true", }, ) # 4th attempt should be rate limited response = client.post( "/auth/register", data={ "username": "newuser4", "password": "TestPass123", "confirm_password": "TestPass123", "acknowledge": "true", }, ) assert response.status_code == 429, "4th attempt should be rate limited" def test_password_change_rate_limit_blocks_6th_attempt(self, client, app): """Test that password change blocks the 6th attempt.""" # Disable exception propagation so template rendering errors # (e.g. missing 'research.index' endpoint) return 500 instead # of crashing the test. Rate limiting fires before the handler, # so non-429 responses still count toward the limit. app.config["PROPAGATE_EXCEPTIONS"] = False app.config["TESTING"] = False with client.session_transaction() as sess: sess["username"] = "testuser" for i in range(5): response = client.post( "/auth/change-password", data={ "current_password": "", "new_password": "NewStrongP4ss!", "confirm_password": "NewStrongP4ss!", }, ) assert response.status_code != 429, ( f"Attempt {i + 1} should not be rate limited" ) # 6th attempt should be rate limited response = client.post( "/auth/change-password", data={ "current_password": "", "new_password": "NewStrongP4ss!", "confirm_password": "NewStrongP4ss!", }, ) assert response.status_code == 429, ( "6th password change attempt should be rate limited" ) def test_password_change_has_separate_limit_from_login(self, client, app): """Test that login and password change have independent rate limits.""" app.config["PROPAGATE_EXCEPTIONS"] = False app.config["TESTING"] = False # Exhaust login limit (5 attempts) for i in range(5): client.post( "/auth/login", data={"username": f"testuser{i}", "password": "wrongpassword"}, ) # Verify login is rate limited response = client.post( "/auth/login", data={"username": "testuser6", "password": "wrongpassword"}, ) assert response.status_code == 429, "Login should be rate limited" # Password change should still work (separate scope) with client.session_transaction() as sess: sess["username"] = "testuser" response = client.post( "/auth/change-password", data={ "current_password": "", "new_password": "NewStrongP4ss!", "confirm_password": "NewStrongP4ss!", }, ) assert response.status_code != 429, ( "Password change should have separate rate limit from login" ) def test_different_endpoints_have_separate_limits(self, client): """Test that login and registration have independent rate limits.""" # Exhaust login limit (5 attempts) for i in range(5): client.post( "/auth/login", data={"username": f"testuser{i}", "password": "wrongpassword"}, ) # Registration should still work (separate limit) response = client.post( "/auth/register", data={ "username": "newuser1", "password": "TestPass123", "confirm_password": "TestPass123", "acknowledge": "true", }, ) assert response.status_code != 429, ( "Registration should have separate rate limit from login" ) def test_rate_limit_is_per_ip(self, client, app): """Test that rate limiting is applied per IP address.""" # Make 5 requests from "IP 1" for i in range(5): with app.test_request_context( "/auth/login", method="POST", environ_base={"REMOTE_ADDR": "192.168.1.1"}, ): client.post( "/auth/login", data={ "username": f"testuser{i}", "password": "wrongpassword", }, environ_base={"REMOTE_ADDR": "192.168.1.1"}, ) # 6th request from "IP 1" should be rate limited response1 = client.post( "/auth/login", data={"username": "testuser6", "password": "wrongpassword"}, environ_base={"REMOTE_ADDR": "192.168.1.1"}, ) # Request from "IP 2" should still work (different IP) response2 = client.post( "/auth/login", data={"username": "testuser7", "password": "wrongpassword"}, environ_base={"REMOTE_ADDR": "192.168.1.2"}, ) assert response1.status_code == 429, "IP 1 should be rate limited" assert response2.status_code != 429, ( "IP 2 should not be rate limited (different IP)" ) def test_proxy_headers_are_respected(self, client): """Test that X-Forwarded-For headers are used for rate limiting.""" # Make 5 requests with same X-Forwarded-For header for i in range(5): client.post( "/auth/login", data={"username": f"testuser{i}", "password": "wrongpassword"}, headers={"X-Forwarded-For": "10.0.0.1"}, ) # 6th request with same X-Forwarded-For should be rate limited response = client.post( "/auth/login", data={"username": "testuser6", "password": "wrongpassword"}, headers={"X-Forwarded-For": "10.0.0.1"}, ) assert response.status_code == 429, ( "Requests from same X-Forwarded-For IP should be rate limited" ) def test_successful_login_still_counts_toward_limit(self, client): """Test that successful logins also count toward rate limit.""" # This prevents attackers from resetting the limit with valid credentials # Create a test user first (this uses the programmatic API, not the web endpoint) from local_deep_research.database.encrypted_db import db_manager test_username = "ratelimituser" test_password = "TestPass123" # Create user if doesn't exist if not db_manager.user_exists(test_username): db_manager.create_user_database(test_username, test_password) # Make 5 successful login attempts for i in range(5): client.post( "/auth/login", data={"username": test_username, "password": test_password}, ) # 6th attempt should still be rate limited, even with valid credentials response = client.post( "/auth/login", data={"username": test_username, "password": test_password}, ) assert response.status_code == 429, ( "Even successful logins should count toward rate limit" ) # Clean up: delete test user db_manager.close_user_database(test_username) def test_account_enumeration_prevented(self, client): """Test that registration errors don't reveal username existence.""" # First, register a real user so the second attempt below collides. # The `app` fixture scope is `function`, so the DB is fresh; the user # this test relies on must be created here rather than depending on # any other test's side effects. # audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: unconditionally assert both responses == 400 before content checks). existing_username = "enum_target_user" setup_response = client.post( "/auth/register", data={ "username": existing_username, "password": "TestPass123", "confirm_password": "TestPass123", "acknowledge": "true", }, ) # Registration must succeed for the rest of the test to be meaningful. # Accept 200 or 302 (some flows redirect after success). assert setup_response.status_code in (200, 201, 302), ( "Setup user registration must succeed; " f"got {setup_response.status_code}" ) # Try to register with a username that definitely doesn't exist # but with an invalid password so validation rejects it. response1 = client.post( "/auth/register", data={ "username": "definitelynonexistentuser12345", "password": "short", # Will fail validation "confirm_password": "short", "acknowledge": "true", }, ) # Try to register with the username that now exists (collision) response2 = client.post( "/auth/register", data={ "username": existing_username, "password": "TestPass123", "confirm_password": "TestPass123", "acknowledge": "true", }, ) # Both should return generic errors, not revealing if username exists. # Previously the body checks were gated by # `if response1.status_code == 400 and response2.status_code == 400`, # which silently passed if either response was anything else — masking # account-enumeration regressions (the whole point of this test). Worse, # the test relied on a leftover user from a sibling test, which never # existed because the fixture is function-scoped, so response2 was # always a successful registration and the body checks were always # skipped. assert response1.status_code == 400, ( "Short-password registration must return 400; " f"got {response1.status_code}" ) assert response2.status_code == 400, ( "Duplicate-username registration must return 400; " f"got {response2.status_code}" ) # Check that error messages are generic data2 = response2.get_data(as_text=True) # Should NOT contain "Username already exists" assert "Username already exists" not in data2, ( "Error should not reveal username existence" ) # Should contain generic message assert ( "Registration failed" in data2 or "try a different username" in data2 ), "Should use generic error message" class TestRateLimitReset: """Test that rate limits reset after the time window.""" @pytest.fixture def temp_data_dir(self): """Create a temporary data directory for testing.""" temp_dir = tempfile.mkdtemp() yield Path(temp_dir) shutil.rmtree(temp_dir, ignore_errors=True) @pytest.fixture def app(self, temp_data_dir, monkeypatch): """Create a test Flask app with rate limiting.""" monkeypatch.setenv("LDR_DATA_DIR", str(temp_data_dir)) from local_deep_research.database.auth_db import init_auth_database from local_deep_research.database.encrypted_db import db_manager from local_deep_research.web.app_factory import create_app from local_deep_research.security.rate_limiter import limiter db_manager.close_all_databases() db_manager.data_dir = temp_data_dir / "encrypted_databases" db_manager.data_dir.mkdir(parents=True, exist_ok=True) init_auth_database() app, _ = create_app() app.config["TESTING"] = True app.config["WTF_CSRF_ENABLED"] = False # Enable rate limiting AFTER init_app (CI sets LDR_DISABLE_RATE_LIMITING=true) app.config["RATELIMIT_ENABLED"] = True limiter.enabled = True limiter.init_app(app) limiter.reset() yield app db_manager.close_all_databases() @pytest.fixture def client(self, app): """Create a test client.""" return app.test_client() @pytest.mark.slow def test_rate_limit_resets_after_time_window(self, client): """Test that rate limit resets after 15 minutes (for login).""" # Note: This test would take 15 minutes to run in real time # In practice, you'd mock time or use a shorter limit for testing pytest.skip( "This test requires mocking time or waiting 15 minutes - " "implement with time mocking if needed" ) # Implementation would look like: # 1. Make 5 login attempts # 2. Verify 6th attempt is blocked # 3. Fast-forward time by 15 minutes (using time mocking) # 4. Verify new attempt is allowed