- 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
193 lines
7.8 KiB
Python
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
|