Files
service_manager/backend/tests/unit/test_security.py
icamarillo 517297e89a feat: Version 1.10.0 - Refactorizacion, optimizacion UI y mejoras de seguridad
- Extraccion de helpers en backend: audit_helpers.py, helpers.py
- Modularizacion de schemas en archivos individuales por dominio
- Reduccion de audit.py en 953 lineas (74% del archivo)
- Reduccion de tickets.py en 655 lineas (60% del archivo)
- Expansion de auth.py con recuperacion de contrasenia y tokens
- Nuevos modulos: core/email.py, core/cache.py
- Reorganizacion de scripts a backend/scripts/
- Frontend: refactorizacion de audit page con array-driven components
- Frontend: correccion de 11 errores ortograficos en tickets page
- Frontend: proxy Docker corregido en vite.config.js
- Frontend: nuevas rutas forgot-password, reset-password, organization, profile
- Nuevas utilidades TS: colorUtils.ts, dateFormats.ts
- 5 nuevos archivos de tests unitarios en backend/tests/unit/
- Eliminacion de 3 scripts temporales de prueba
- Documentacion tecnica: CAMBIOS_v1.10.0.md, OPTIMIZACIONES_RENDIMIENTO.md
2026-02-19 13:48:21 -07:00

193 lines
7.8 KiB
Python

"""
Unit Tests - Security Utils - ServiceManagerWeb
Tests para app.core.security: hash de passwords, JWT tokens y TOTP.
No requieren base de datos ni red.
"""
import pytest
from datetime import timedelta
# ============================================================
# PASSWORD HASHING
# ============================================================
class TestPasswordHashing:
"""Tests para hash y verificación de contraseñas."""
def test_hash_password_returns_string(self):
"""El hash debe retornar un string."""
from app.core.security import SecurityUtils
result = SecurityUtils.hash_password("MyPassword123!")
assert isinstance(result, str)
def test_hash_is_not_plain_password(self):
"""El hash no debe ser igual al password original."""
from app.core.security import SecurityUtils
password = "MyPassword123!"
hashed = SecurityUtils.hash_password(password)
assert hashed != password
def test_verify_correct_password(self):
"""Verificar password correcto debe retornar True."""
from app.core.security import SecurityUtils
password = "CorrectPassword99!"
hashed = SecurityUtils.hash_password(password)
assert SecurityUtils.verify_password(password, hashed) is True
def test_verify_wrong_password(self):
"""Verificar password incorrecto debe retornar False."""
from app.core.security import SecurityUtils
password = "CorrectPassword99!"
hashed = SecurityUtils.hash_password(password)
assert SecurityUtils.verify_password("WrongPassword!", hashed) is False
def test_two_hashes_of_same_password_are_different(self):
"""Cada hash debe ser único (salt diferente)."""
from app.core.security import SecurityUtils
password = "SamePassword123!"
hash1 = SecurityUtils.hash_password(password)
hash2 = SecurityUtils.hash_password(password)
assert hash1 != hash2
def test_verify_empty_password_against_hash(self):
"""Verificar string vacío contra hash de otra contraseña debe fallar."""
from app.core.security import SecurityUtils
hashed = SecurityUtils.hash_password("SomePassword!")
assert SecurityUtils.verify_password("", hashed) is False
# ============================================================
# JWT ACCESS TOKENS
# ============================================================
class TestAccessTokens:
"""Tests para creación y verificación de JWT access tokens."""
def test_create_access_token_returns_string(self):
"""create_access_token debe retornar un string."""
from app.core.security import SecurityUtils
token = SecurityUtils.create_access_token(data={"sub": "user-123"})
assert isinstance(token, str)
assert len(token) > 20
def test_verify_valid_access_token(self):
"""Un token válido debe retornar el payload."""
from app.core.security import SecurityUtils
payload_in = {"sub": "user-abc", "role": "AGENT"}
token = SecurityUtils.create_access_token(data=payload_in)
payload_out = SecurityUtils.verify_token(token)
assert payload_out is not None
assert payload_out["sub"] == "user-abc"
assert payload_out["role"] == "AGENT"
def test_verify_invalid_token_returns_none(self):
"""Un token inválido debe retornar None."""
from app.core.security import SecurityUtils
result = SecurityUtils.verify_token("this.is.not.a.valid.token")
assert result is None
def test_verify_tampered_token_returns_none(self):
"""Un token modificado debe retornar None."""
from app.core.security import SecurityUtils
token = SecurityUtils.create_access_token(data={"sub": "user-123"})
# Modificar el token
parts = token.split(".")
tampered = parts[0] + "." + parts[1] + "XXXXX." + parts[2]
assert SecurityUtils.verify_token(tampered) is None
def test_create_token_with_custom_expiry(self):
"""Token con expiración personalizada debe ser verificable."""
from app.core.security import SecurityUtils
token = SecurityUtils.create_access_token(
data={"sub": "user-xyz"},
expires_delta=timedelta(minutes=30)
)
payload = SecurityUtils.verify_token(token)
assert payload is not None
assert payload["sub"] == "user-xyz"
def test_expired_token_returns_none(self):
"""Token expirado debe retornar None."""
from app.core.security import SecurityUtils
token = SecurityUtils.create_access_token(
data={"sub": "user-exp"},
expires_delta=timedelta(seconds=-1) # Expirado en el pasado
)
result = SecurityUtils.verify_token(token)
assert result is None
# ============================================================
# JWT REFRESH TOKENS
# ============================================================
class TestRefreshTokens:
"""Tests para creación de refresh tokens."""
def test_create_refresh_token_returns_string(self):
"""create_refresh_token debe retornar un string."""
from app.core.security import SecurityUtils
token = SecurityUtils.create_refresh_token(data={"sub": "user-456"})
assert isinstance(token, str)
def test_refresh_token_has_type_field(self):
"""El refresh token debe contener el campo type=refresh."""
from app.core.security import SecurityUtils
token = SecurityUtils.create_refresh_token(data={"sub": "user-456"})
payload = SecurityUtils.verify_token(token)
assert payload is not None
assert payload.get("type") == "refresh"
def test_refresh_token_preserves_subject(self):
"""El refresh token debe preservar el campo sub."""
from app.core.security import SecurityUtils
token = SecurityUtils.create_refresh_token(data={"sub": "user-999"})
payload = SecurityUtils.verify_token(token)
assert payload["sub"] == "user-999"
# ============================================================
# TOTP / 2FA
# ============================================================
class TestTOTP:
"""Tests para generación y verificación de TOTP."""
def test_generate_totp_secret_returns_string(self):
"""generate_totp_secret debe retornar un string base32."""
from app.core.security import SecurityUtils
secret = SecurityUtils.generate_totp_secret()
assert isinstance(secret, str)
assert len(secret) > 0
def test_two_secrets_are_different(self):
"""Dos secrets consecutivos deben ser distintos."""
from app.core.security import SecurityUtils
secret1 = SecurityUtils.generate_totp_secret()
secret2 = SecurityUtils.generate_totp_secret()
assert secret1 != secret2
def test_verify_valid_totp_code(self):
"""Un código TOTP válido debe verificarse correctamente."""
import pyotp
from app.core.security import SecurityUtils
secret = SecurityUtils.generate_totp_secret()
totp = pyotp.TOTP(secret)
valid_code = totp.now()
assert SecurityUtils.verify_totp(secret, valid_code) is True
def test_verify_invalid_totp_code(self):
"""Un código TOTP inválido debe retornar False."""
from app.core.security import SecurityUtils
secret = SecurityUtils.generate_totp_secret()
assert SecurityUtils.verify_totp(secret, "000000") is False
def test_generate_totp_uri_contains_email(self):
"""El URI de TOTP debe contener el email del usuario."""
from app.core.security import SecurityUtils
secret = SecurityUtils.generate_totp_secret()
uri = SecurityUtils.generate_totp_uri(secret, "user@test.com")
assert "user%40test.com" in uri or "user@test.com" in uri