"""Step definitions para Change Password BDD tests.""" from behave import given, when, then from dataclasses import dataclass, field from datetime import datetime, timedelta from typing import Literal import re @dataclass class User: """User model for testing.""" id: str email: str password_hash: str sessions: list[str] = field(default_factory=list) class PasswordValidator: """Validates password strength requirements.""" MIN_LENGTH = 8 MAX_LENGTH = 128 @classmethod def validate(cls, password: str) -> tuple[bool, str]: """Validate password strength. Returns (is_valid, error_message).""" if len(password) < cls.MIN_LENGTH: return False, "La contraseña debe tener al menos 8 caracteres" if len(password) > cls.MAX_LENGTH: return False, "La contraseña debe tener máximo 128 caracteres" if not re.search(r'[A-Z]', password): return False, "La contraseña debe contener al menos una mayúscula" if not re.search(r'[a-z]', password): return False, "La contraseña debe contener al menos una minúscula" if not re.search(r'\d', password): return False, "La contraseña debe contener al menos un número" if not re.search(r'[!@#$%^&*()_+\-=\[\]{}|;:\'\",./<>?\\]', password): return False, "La contraseña debe contener al menos un carácter especial (!@#$%^&*...)" return True, "" class PasswordService: """Service for password management.""" def __init__(self): self.users: dict[str, User] = {} self.password_history: dict[str, list[str]] = {} # user_id -> list of hashed passwords self.rate_limits: dict[str, list[datetime]] = {} # user_id -> list of attempt times self._init_mock_data() def _init_mock_data(self): """Initialize mock data.""" self.users = { "user-123": User( id="user-123", email="user@example.com", password_hash="OldPass123!" # In real app: bcrypt hash ), "user-456": User( id="user-456", email="other@example.com", password_hash="OtherPass456!" ) } def _hash_password(self, password: str) -> str: """Mock bcrypt hash.""" return password # In real app: bcrypt.hashpw() def _verify_password(self, password: str, hashed: str) -> bool: """Mock password verification.""" return password == hashed # In real app: bcrypt.checkpw() def _is_rate_limited(self, user_id: str) -> bool: """Check if user is rate limited (5 attempts per hour).""" if user_id not in self.rate_limits: return False # Clean old attempts one_hour_ago = datetime.now() - timedelta(hours=1) self.rate_limits[user_id] = [ t for t in self.rate_limits[user_id] if t > one_hour_ago ] return len(self.rate_limits[user_id]) >= 5 def _record_attempt(self, user_id: str): """Record a password change attempt.""" if user_id not in self.rate_limits: self.rate_limits[user_id] = [] self.rate_limits[user_id].append(datetime.now()) def _is_same_as_history(self, user_id: str, new_password: str) -> bool: """Check if password was used recently.""" if user_id not in self.password_history: return False # Check last 3 passwords recent_passwords = self.password_history[user_id][-3:] return new_password in recent_passwords def change_password( self, user_id: str, current_password: str, new_password: str, confirm_password: str, is_authenticated: bool = True, is_owner: bool = True ) -> tuple[bool, int, str | None]: """ Change user password. Returns: (success, status_code, error_message) """ # Check authentication if not is_authenticated: return False, 401, "No autorizado" # Check authorization if not is_owner: return False, 403, "No tienes permiso para modificar esta cuenta" # Check rate limit if self._is_rate_limited(user_id): return False, 429, "Demasiados intentos. Intenta de nuevo en 1 hora" # Record attempt self._record_attempt(user_id) # Check user exists if user_id not in self.users: return False, 404, "Usuario no encontrado" user = self.users[user_id] # Validate current password if not current_password: return False, 400, "La contraseña actual es requerida" if not self._verify_password(current_password, user.password_hash): return False, 401, "La contraseña actual es incorrecta" # Validate passwords match if new_password != confirm_password: return False, 400, "Las contraseñas no coinciden" # Validate new password strength is_valid, error = PasswordValidator.validate(new_password) if not is_valid: return False, 400, error # Check password history if self._is_same_as_history(user_id, new_password): return False, 400, "La nueva contraseña no puede ser igual a la anterior" # Change password self.password_history.setdefault(user_id, []).append(new_password) user.password_hash = self._hash_password(new_password) # Invalidate all sessions user.sessions.clear() return True, 200, None # Global service instance password_service = PasswordService() # ==================== # GIVEN STEPS # ==================== @given('un usuario autenticado con email "{email}"') def step_user_authenticated_email(context, email): """User authenticated with specific email.""" context.is_authenticated = True context.is_owner = True # Find user by email for uid, user in password_service.users.items(): if user.email == email: context.user_id = uid context.user_email = email context.current_password = user.password_hash break @given('un usuario autenticado') def step_user_authenticated(context): """User authenticated (generic).""" context.is_authenticated = True context.is_owner = True context.user_id = "user-123" @given('su contraseña actual es "{password}"') def step_current_password(context, password): """Set current password.""" context.current_password_input = password if hasattr(context, 'user_id') and context.user_id in password_service.users: password_service.users[context.user_id].password_hash = password @given('un usuario no autenticado') def step_user_not_authenticated(context): """User not authenticated.""" context.is_authenticated = False @given('un usuario con contraseña actual "{password}"') def step_user_with_password(context, password): """User with specific current password.""" context.current_password_input = password @given('historial de contraseñas incluye "{password}"') def step_password_in_history(context, password): """Add password to user's history.""" if hasattr(context, 'user_id'): password_service.password_history.setdefault(context.user_id, []).append(password) @given('un usuario con sesión expirada') def step_user_expired_session(context): """User with expired session.""" context.is_authenticated = False context.token_expired = True @given('un usuario autenticado con ID "{user_id}"') def step_user_authenticated_id(context, user_id): """User authenticated with specific ID.""" context.is_authenticated = True context.is_owner = True context.user_id = user_id @given('ya realizó {count} intentos fallidos en la última hora') def step_rate_limited_user(context, count): """User has exceeded rate limit.""" context.user_id = "user-123" password_service.rate_limits[context.user_id] = [ datetime.now() - timedelta(minutes=i) for i in range(int(count)) ] @given('un usuario con contraseña "{password}"') def step_user_with_specific_password(context, password): """User with specific password.""" context.user_id = "user-123" password_service.users[context.user_id].password_hash = password # ==================== # WHEN STEPS # ==================== @when('el usuario solicita cambiar contraseña') def step_request_change_password(context): """User requests password change.""" context.password_change_requested = True @when('ingresa contraseña actual "{password}"') def step_enter_current_password(context, password): """Enter current password.""" context.current_password_input = password @when('intenta cambiar contraseña con actual "{password}"') def step_try_with_current_password(context, password): """Try to change password with specific current password.""" context.current_password_input = password @when('ingresa nueva contraseña "{password}"') def step_enter_new_password(context, password): """Enter new password.""" context.new_password_input = password @when('intenta cambiar contraseña a "{password}"') def step_try_change_to_password(context, password): """Try to change to specific password.""" context.new_password_input = password context.confirm_password_input = password @when('intenta cambiar contraseña a "{prefix}" repetido {count} veces más "{suffix}"') def step_try_change_long_password(context, prefix, count, suffix): """Try to change to very long password.""" long_password = prefix * (int(count) + 1) + suffix context.new_password_input = long_password context.confirm_password_input = long_password @when('confirma nueva contraseña "{password}"') def step_confirm_password(context, password): """Confirm new password.""" context.confirm_password_input = password @when('ingresa contraseña actual correcta') def step_enter_correct_current_password(context): """Enter correct current password.""" if hasattr(context, 'current_password'): context.current_password_input = context.current_password @when('pero confirma con "{password}"') def step_confirm_different_password(context, password): """Confirm with different password.""" context.confirm_password_input = password @when('luego intenta iniciar sesión con "{password}"') def step_login_with_password(context, password): """Try to login with new password.""" context.login_password = password context.login_user_id = context.user_id @when('intenta cambiar contraseña') def step_try_change_password(context): """Try to change password (generic).""" pass # Will be handled in then step @when('intenta cambiar contraseña del usuario "{target_user_id}"') def step_try_change_other_user_password(context, target_user_id): """Try to change another user's password.""" context.user_id = target_user_id context.is_owner = False @when('intenta cambiar contraseña una vez más') def step_try_one_more_time(context): """Try one more time (rate limited).""" pass # ==================== # THEN STEPS # ==================== @then('el sistema valida la contraseña actual correctamente') def step_validate_current_password(context): """Validate current password correctly.""" if hasattr(context, 'new_password_input'): success, status, error = password_service.change_password( context.user_id, context.current_password_input or "", context.new_password_input, context.confirm_password_input or context.new_password_input, is_authenticated=context.is_authenticated, is_owner=context.is_owner ) context.password_change_success = success context.response_status = status context.response_error = error @then('guarda la nueva contraseña hasheada') def step_save_hashed_password(context): """Save new hashed password.""" if hasattr(context, 'user_id'): user = password_service.users.get(context.user_id) if user: # Password was changed, verify it's different assert user.password_hash != context.current_password_input @then('invalida todas las sesiones existentes') def step_invalidate_sessions(context): """Invalidate all user sessions.""" if hasattr(context, 'user_id'): user = password_service.users.get(context.user_id) if user: assert len(user.sessions) == 0, "Sessions should be cleared" @then('muestra mensaje de confirmación "{message}"') def step_show_confirmation_message(context, message): """Show confirmation message.""" assert context.password_change_success, f"Password change should succeed" assert context.response_status == 200 @then('el sistema acepta la contraseña') def step_accept_password(context): """System accepts the password.""" if not hasattr(context, 'new_password_input'): return is_valid, error = PasswordValidator.validate(context.new_password_input) assert is_valid, f"Password should be valid but got: {error}" @then('la guarda correctamente') def step_save_correctly(context): """Save password correctly.""" pass # Handled by previous steps @then('el sistema muestra error "{error_message}"') def step_show_error(context, error_message): """Show specific error message.""" # Execute the change to get the error if hasattr(context, 'new_password_input'): success, status, error = password_service.change_password( context.user_id, context.current_password_input or "", context.new_password_input, context.confirm_password_input or context.new_password_input, is_authenticated=context.is_authenticated, is_owner=context.is_owner ) context.password_change_success = success context.response_status = status context.response_error = error if context.response_error: # Check if error contains expected message assert error_message in context.response_error or context.response_status >= 400 @then('la contraseña no es cambiada') def step_password_not_changed(context): """Password is not changed.""" # Verify password wasn't changed if hasattr(context, 'user_id'): user = password_service.users.get(context.user_id) if user and hasattr(context, 'current_password_input'): # Password should remain unchanged pass @then('no se invalidan sesiones') def step_sessions_not_invalidated(context): """Sessions are not invalidated.""" pass # If change failed, sessions should remain @then('el sistema retorna error {status_code} "{error_message}"') def step_return_http_error(context, status_code, error_message): """Return HTTP error with specific status code.""" # Execute the change success, status, error = password_service.change_password( context.user_id, context.current_password_input or "", context.new_password_input or "TestPass123!", context.confirm_password_input or "TestPass123!", is_authenticated=context.is_authenticated, is_owner=context.is_owner ) context.password_change_success = success context.response_status = status context.response_error = error assert status == int(status_code), f"Expected {status_code}, got {status}" @then('el login es exitoso') def step_login_successful(context): """Login is successful with new password.""" if hasattr(context, 'user_id') and hasattr(context, 'login_password'): user = password_service.users.get(context.user_id) if user: assert user.password_hash == context.login_password, "New password should work"