# tests/api/test_auth_password_reset.py """ Tests for password reset endpoints. """ from unittest.mock import patch import pytest from fastapi import status from sqlalchemy import select from app.models.user import User from app.utils.security import create_password_reset_token # Disable rate limiting for tests @pytest.fixture(autouse=True) def disable_rate_limit(): """Disable rate limiting for all tests in this module.""" with patch("app.api.routes.auth.limiter.enabled", False): yield class TestPasswordResetRequest: """Tests for POST /auth/password-reset/request endpoint.""" @pytest.mark.asyncio async def test_password_reset_request_valid_email(self, client, async_test_user): """Test password reset request with valid email.""" with patch( "app.api.routes.auth.email_service.send_password_reset_email" ) as mock_send: mock_send.return_value = True response = await client.post( "/api/v1/auth/password-reset/request", json={"email": async_test_user.email}, ) assert response.status_code == status.HTTP_200_OK data = response.json() assert data["success"] is True assert "reset link" in data["message"].lower() # Verify email was sent mock_send.assert_called_once() call_args = mock_send.call_args assert call_args.kwargs["to_email"] == async_test_user.email assert call_args.kwargs["user_name"] == async_test_user.first_name assert "reset_token" in call_args.kwargs @pytest.mark.asyncio async def test_password_reset_request_nonexistent_email(self, client): """Test password reset request with non-existent email.""" with patch( "app.api.routes.auth.email_service.send_password_reset_email" ) as mock_send: response = await client.post( "/api/v1/auth/password-reset/request", json={"email": "nonexistent@example.com"}, ) # Should still return success to prevent email enumeration assert response.status_code == status.HTTP_200_OK data = response.json() assert data["success"] is True # Email should not be sent mock_send.assert_not_called() @pytest.mark.asyncio async def test_password_reset_request_inactive_user( self, client, async_test_db, async_test_user ): """Test password reset request with inactive user.""" # Deactivate user _test_engine, AsyncTestingSessionLocal = async_test_db async with AsyncTestingSessionLocal() as session: result = await session.execute( select(User).where(User.id == async_test_user.id) ) user_in_session = result.scalar_one_or_none() user_in_session.is_active = False await session.commit() with patch( "app.api.routes.auth.email_service.send_password_reset_email" ) as mock_send: response = await client.post( "/api/v1/auth/password-reset/request", json={"email": async_test_user.email}, ) # Should still return success to prevent email enumeration assert response.status_code == status.HTTP_200_OK data = response.json() assert data["success"] is True # Email should not be sent to inactive user mock_send.assert_not_called() @pytest.mark.asyncio async def test_password_reset_request_invalid_email_format(self, client): """Test password reset request with invalid email format.""" response = await client.post( "/api/v1/auth/password-reset/request", json={"email": "not-an-email"} ) assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY @pytest.mark.asyncio async def test_password_reset_request_missing_email(self, client): """Test password reset request without email.""" response = await client.post("/api/v1/auth/password-reset/request", json={}) assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY @pytest.mark.asyncio async def test_password_reset_request_email_service_error( self, client, async_test_user ): """Test password reset when email service fails.""" with patch( "app.api.routes.auth.email_service.send_password_reset_email" ) as mock_send: mock_send.side_effect = Exception("SMTP Error") response = await client.post( "/api/v1/auth/password-reset/request", json={"email": async_test_user.email}, ) # Should still return success even if email fails assert response.status_code == status.HTTP_200_OK data = response.json() assert data["success"] is True @pytest.mark.asyncio async def test_password_reset_request_rate_limiting(self, client, async_test_user): """Test that password reset requests are rate limited.""" with patch( "app.api.routes.auth.email_service.send_password_reset_email" ) as mock_send: mock_send.return_value = True # Make multiple requests quickly (3/minute limit) for _ in range(3): response = await client.post( "/api/v1/auth/password-reset/request", json={"email": async_test_user.email}, ) assert response.status_code == status.HTTP_200_OK class TestPasswordResetConfirm: """Tests for POST /auth/password-reset/confirm endpoint.""" @pytest.mark.asyncio async def test_password_reset_confirm_valid_token( self, client, async_test_user, async_test_db ): """Test password reset confirmation with valid token.""" # Generate valid token token = create_password_reset_token(async_test_user.email) new_password = "NewSecure123!" response = await client.post( "/api/v1/auth/password-reset/confirm", json={"token": token, "new_password": new_password}, ) assert response.status_code == status.HTTP_200_OK data = response.json() assert data["success"] is True assert "successfully" in data["message"].lower() # Verify user can login with new password _test_engine, AsyncTestingSessionLocal = async_test_db async with AsyncTestingSessionLocal() as session: result = await session.execute( select(User).where(User.id == async_test_user.id) ) updated_user = result.scalar_one_or_none() from app.core.auth import verify_password assert verify_password(new_password, updated_user.password_hash) is True @pytest.mark.asyncio async def test_password_reset_confirm_expired_token(self, client, async_test_user): """Test password reset confirmation with expired token.""" import asyncio # Create token that expires at current second (expires_in=0) # Token expires when exp < current_time, so we need to cross a second boundary token = create_password_reset_token(async_test_user.email, expires_in=0) # Wait for token to expire (need to cross second boundary) await asyncio.sleep(1.1) response = await client.post( "/api/v1/auth/password-reset/confirm", json={"token": token, "new_password": "NewSecure123!"}, ) assert response.status_code == status.HTTP_400_BAD_REQUEST data = response.json() # Check custom error format assert data["success"] is False error_msg = data["errors"][0]["message"].lower() if "errors" in data else "" assert "invalid" in error_msg or "expired" in error_msg @pytest.mark.asyncio async def test_password_reset_confirm_invalid_token(self, client): """Test password reset confirmation with invalid token.""" response = await client.post( "/api/v1/auth/password-reset/confirm", json={"token": "invalid_token_xyz", "new_password": "NewSecure123!"}, ) assert response.status_code == status.HTTP_400_BAD_REQUEST data = response.json() assert data["success"] is False error_msg = data["errors"][0]["message"].lower() if "errors" in data else "" assert "invalid" in error_msg or "expired" in error_msg @pytest.mark.asyncio async def test_password_reset_confirm_tampered_token(self, client, async_test_user): """Test password reset confirmation with tampered token.""" import base64 import json # Create valid token and tamper with it token = create_password_reset_token(async_test_user.email) decoded = base64.urlsafe_b64decode(token.encode("utf-8")).decode("utf-8") token_data = json.loads(decoded) token_data["payload"]["email"] = "hacker@example.com" # Re-encode tampered token tampered = base64.urlsafe_b64encode( json.dumps(token_data).encode("utf-8") ).decode("utf-8") response = await client.post( "/api/v1/auth/password-reset/confirm", json={"token": tampered, "new_password": "NewSecure123!"}, ) assert response.status_code == status.HTTP_400_BAD_REQUEST @pytest.mark.asyncio async def test_password_reset_confirm_nonexistent_user(self, client): """Test password reset confirmation for non-existent user.""" # Create token for email that doesn't exist token = create_password_reset_token("nonexistent@example.com") response = await client.post( "/api/v1/auth/password-reset/confirm", json={"token": token, "new_password": "NewSecure123!"}, ) assert response.status_code == status.HTTP_404_NOT_FOUND data = response.json() assert data["success"] is False error_msg = data["errors"][0]["message"].lower() if "errors" in data else "" assert "not found" in error_msg @pytest.mark.asyncio async def test_password_reset_confirm_inactive_user( self, client, async_test_user, async_test_db ): """Test password reset confirmation for inactive user.""" # Deactivate user _test_engine, AsyncTestingSessionLocal = async_test_db async with AsyncTestingSessionLocal() as session: result = await session.execute( select(User).where(User.id == async_test_user.id) ) user_in_session = result.scalar_one_or_none() user_in_session.is_active = False await session.commit() token = create_password_reset_token(async_test_user.email) response = await client.post( "/api/v1/auth/password-reset/confirm", json={"token": token, "new_password": "NewSecure123!"}, ) assert response.status_code == status.HTTP_400_BAD_REQUEST data = response.json() assert data["success"] is False error_msg = data["errors"][0]["message"].lower() if "errors" in data else "" assert "inactive" in error_msg @pytest.mark.asyncio async def test_password_reset_confirm_weak_password(self, client, async_test_user): """Test password reset confirmation with weak password.""" token = create_password_reset_token(async_test_user.email) # Test various weak passwords weak_passwords = [ "short1", # Too short "NoDigitsHere", # No digits "no_uppercase123", # No uppercase ] for weak_password in weak_passwords: response = await client.post( "/api/v1/auth/password-reset/confirm", json={"token": token, "new_password": weak_password}, ) assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY @pytest.mark.asyncio async def test_password_reset_confirm_missing_fields(self, client): """Test password reset confirmation with missing fields.""" # Missing token response = await client.post( "/api/v1/auth/password-reset/confirm", json={"new_password": "NewSecure123!"}, ) assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY # Missing password token = create_password_reset_token("test@example.com") response = await client.post( "/api/v1/auth/password-reset/confirm", json={"token": token} ) assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY @pytest.mark.asyncio async def test_password_reset_confirm_database_error(self, client, async_test_user): """Test password reset confirmation with database error.""" token = create_password_reset_token(async_test_user.email) # Mock the database commit to raise an exception with patch("app.api.routes.auth.user_crud.get_by_email") as mock_get: mock_get.side_effect = Exception("Database error") response = await client.post( "/api/v1/auth/password-reset/confirm", json={"token": token, "new_password": "NewSecure123!"}, ) assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR data = response.json() assert data["success"] is False error_msg = data["errors"][0]["message"].lower() if "errors" in data else "" assert "error" in error_msg or "resetting" in error_msg @pytest.mark.asyncio async def test_password_reset_full_flow( self, client, async_test_user, async_test_db ): """Test complete password reset flow.""" original_password = async_test_user.password_hash new_password = "BrandNew123!" # Step 1: Request password reset with patch( "app.api.routes.auth.email_service.send_password_reset_email" ) as mock_send: mock_send.return_value = True response = await client.post( "/api/v1/auth/password-reset/request", json={"email": async_test_user.email}, ) assert response.status_code == status.HTTP_200_OK # Extract token from mock call call_args = mock_send.call_args reset_token = call_args.kwargs["reset_token"] # Step 2: Confirm password reset response = await client.post( "/api/v1/auth/password-reset/confirm", json={"token": reset_token, "new_password": new_password}, ) assert response.status_code == status.HTTP_200_OK # Step 3: Verify old password doesn't work _test_engine, AsyncTestingSessionLocal = async_test_db async with AsyncTestingSessionLocal() as session: result = await session.execute( select(User).where(User.id == async_test_user.id) ) updated_user = result.scalar_one_or_none() assert updated_user.password_hash != original_password # Step 4: Verify new password works response = await client.post( "/api/v1/auth/login", json={"email": async_test_user.email, "password": new_password}, ) assert response.status_code == status.HTTP_200_OK assert "access_token" in response.json()