- Agregar 46 tests de integración (auth, multi-tenancy, tickets) - Crear estructura organizada tests/integration/ y tests/unit/ - Implementar fixtures completas para testing con BD separada - Agregar conftest_integration.py con setup async - Mover scripts PowerShell de testing a tests/scripts/ - Actualizar pytest.ini con markers y configuración - Crear run_tests.sh script ejecutable para testing - Documentación completa en README_TESTS.md - Fix: Remover opciones obsoletas de TypeScript (importsNotUsedAsValues) Tests implementados: - Authentication: 15 tests (login, refresh, permisos, seguridad) - Multi-tenancy: 13 tests (aislamiento, validaciones, seguridad B2B) - Tickets: 18 tests (CRUD, filtros, permisos por rol) - Unit: 10 tests básicos - Verificación: 8 tests de setup Base de datos de testing: servicemanager_test (separada de producción) Cobertura estimada: ~40% (desde 5%) Próximos pasos: Agregar tests de SLA, attachments, auditoría
365 lines
10 KiB
Python
365 lines
10 KiB
Python
"""
|
|
Authentication Integration Tests - ServiceManagerWeb
|
|
|
|
Tests completos del flujo de autenticación incluyendo:
|
|
- Login
|
|
- Refresh tokens
|
|
- Logout
|
|
- Permisos y roles
|
|
"""
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.models.user import User, UserRole
|
|
from app.models.tenant import Tenant
|
|
|
|
# Importar fixtures desde conftest_integration
|
|
pytest_plugins = ['tests.conftest_integration']
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.auth
|
|
class TestAuthentication:
|
|
"""Tests de autenticación básica."""
|
|
|
|
async def test_login_success(
|
|
self,
|
|
client: AsyncClient,
|
|
test_admin_user: User,
|
|
test_tenant: Tenant
|
|
):
|
|
"""Test login exitoso con credenciales válidas."""
|
|
response = await client.post(
|
|
"/v1/auth/login",
|
|
json={
|
|
"email": "admin@test.com",
|
|
"password": "AdminPass123!",
|
|
"tenant_slug": test_tenant.slug
|
|
}
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
assert "access_token" in data
|
|
assert "refresh_token" in data
|
|
assert data["token_type"] == "bearer"
|
|
assert data["expires_in"] > 0
|
|
assert data["user"]["email"] == "admin@test.com"
|
|
assert data["user"]["role"] == "ADMIN"
|
|
|
|
async def test_login_invalid_password(
|
|
self,
|
|
client: AsyncClient,
|
|
test_admin_user: User,
|
|
test_tenant: Tenant
|
|
):
|
|
"""Test login con contraseña incorrecta."""
|
|
response = await client.post(
|
|
"/v1/auth/login",
|
|
json={
|
|
"email": "admin@test.com",
|
|
"password": "WrongPassword123!",
|
|
"tenant_slug": test_tenant.slug
|
|
}
|
|
)
|
|
|
|
assert response.status_code == 401
|
|
assert "Invalid credentials" in response.json()["detail"]
|
|
|
|
async def test_login_invalid_tenant_slug(
|
|
self,
|
|
client: AsyncClient,
|
|
test_admin_user: User
|
|
):
|
|
"""Test login con tenant slug inexistente."""
|
|
response = await client.post(
|
|
"/v1/auth/login",
|
|
json={
|
|
"email": "admin@test.com",
|
|
"password": "AdminPass123!",
|
|
"tenant_slug": "nonexistent-tenant"
|
|
}
|
|
)
|
|
|
|
assert response.status_code == 404
|
|
|
|
async def test_login_user_not_found(
|
|
self,
|
|
client: AsyncClient,
|
|
test_tenant: Tenant
|
|
):
|
|
"""Test login con email inexistente."""
|
|
response = await client.post(
|
|
"/v1/auth/login",
|
|
json={
|
|
"email": "notfound@test.com",
|
|
"password": "SomePassword123!",
|
|
"tenant_slug": test_tenant.slug
|
|
}
|
|
)
|
|
|
|
assert response.status_code == 401
|
|
|
|
async def test_login_inactive_user(
|
|
self,
|
|
client: AsyncClient,
|
|
db_session: AsyncSession,
|
|
test_admin_user: User,
|
|
test_tenant: Tenant
|
|
):
|
|
"""Test login con usuario desactivado."""
|
|
# Desactivar usuario
|
|
test_admin_user.is_active = False
|
|
await db_session.commit()
|
|
|
|
response = await client.post(
|
|
"/v1/auth/login",
|
|
json={
|
|
"email": "admin@test.com",
|
|
"password": "AdminPass123!",
|
|
"tenant_slug": test_tenant.slug
|
|
}
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.auth
|
|
class TestRefreshToken:
|
|
"""Tests de refresh tokens."""
|
|
|
|
async def test_refresh_token_success(
|
|
self,
|
|
client: AsyncClient,
|
|
test_admin_user: User,
|
|
test_tenant: Tenant
|
|
):
|
|
"""Test refresh token exitoso."""
|
|
# Login para obtener tokens
|
|
login_response = await client.post(
|
|
"/v1/auth/login",
|
|
json={
|
|
"email": "admin@test.com",
|
|
"password": "AdminPass123!",
|
|
"tenant_slug": test_tenant.slug
|
|
}
|
|
)
|
|
|
|
assert login_response.status_code == 200
|
|
refresh_token = login_response.json()["refresh_token"]
|
|
|
|
# Usar refresh token
|
|
refresh_response = await client.post(
|
|
"/v1/auth/refresh",
|
|
json={"refresh_token": refresh_token}
|
|
)
|
|
|
|
assert refresh_response.status_code == 200
|
|
data = refresh_response.json()
|
|
|
|
assert "access_token" in data
|
|
assert data["token_type"] == "bearer"
|
|
assert data["expires_in"] > 0
|
|
|
|
async def test_refresh_token_invalid(self, client: AsyncClient):
|
|
"""Test refresh con token inválido."""
|
|
response = await client.post(
|
|
"/v1/auth/refresh",
|
|
json={"refresh_token": "invalid-token"}
|
|
)
|
|
|
|
assert response.status_code == 401
|
|
|
|
async def test_refresh_token_after_logout(
|
|
self,
|
|
client: AsyncClient,
|
|
test_admin_user: User,
|
|
test_tenant: Tenant,
|
|
admin_token: str
|
|
):
|
|
"""Test que refresh token no funciona después de logout."""
|
|
# Login
|
|
login_response = await client.post(
|
|
"/v1/auth/login",
|
|
json={
|
|
"email": "admin@test.com",
|
|
"password": "AdminPass123!",
|
|
"tenant_slug": test_tenant.slug
|
|
}
|
|
)
|
|
|
|
refresh_token = login_response.json()["refresh_token"]
|
|
|
|
# Logout
|
|
logout_response = await client.post(
|
|
"/v1/auth/logout",
|
|
headers={"Authorization": f"Bearer {admin_token}"}
|
|
)
|
|
|
|
assert logout_response.status_code == 200
|
|
|
|
# Intentar usar refresh token después de logout
|
|
refresh_response = await client.post(
|
|
"/v1/auth/refresh",
|
|
json={"refresh_token": refresh_token}
|
|
)
|
|
|
|
assert refresh_response.status_code == 401
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.auth
|
|
class TestAuthorization:
|
|
"""Tests de autorización y permisos."""
|
|
|
|
async def test_admin_can_access_admin_endpoint(
|
|
self,
|
|
client: AsyncClient,
|
|
test_tenant: Tenant,
|
|
auth_headers_admin: dict
|
|
):
|
|
"""Test que admin puede acceder a endpoints de admin."""
|
|
response = await client.get(
|
|
"/v1/tenants/",
|
|
headers={
|
|
**auth_headers_admin,
|
|
"X-Tenant-ID": str(test_tenant.id)
|
|
}
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
|
|
async def test_agent_cannot_access_admin_endpoint(
|
|
self,
|
|
client: AsyncClient,
|
|
test_tenant: Tenant,
|
|
auth_headers_agent: dict
|
|
):
|
|
"""Test que agent no puede acceder a endpoints de admin."""
|
|
response = await client.get(
|
|
"/v1/tenants/",
|
|
headers={
|
|
**auth_headers_agent,
|
|
"X-Tenant-ID": str(test_tenant.id)
|
|
}
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
|
|
async def test_client_cannot_access_admin_endpoint(
|
|
self,
|
|
client: AsyncClient,
|
|
test_tenant: Tenant,
|
|
auth_headers_client: dict
|
|
):
|
|
"""Test que client no puede acceder a endpoints de admin."""
|
|
response = await client.get(
|
|
"/v1/tenants/",
|
|
headers={
|
|
**auth_headers_client,
|
|
"X-Tenant-ID": str(test_tenant.id)
|
|
}
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
|
|
async def test_protected_endpoint_without_token(
|
|
self,
|
|
client: AsyncClient,
|
|
test_tenant: Tenant
|
|
):
|
|
"""Test que endpoints protegidos requieren token."""
|
|
response = await client.get(
|
|
"/v1/tickets/",
|
|
headers={"X-Tenant-ID": str(test_tenant.id)}
|
|
)
|
|
|
|
assert response.status_code == 401
|
|
|
|
async def test_protected_endpoint_with_invalid_token(
|
|
self,
|
|
client: AsyncClient,
|
|
test_tenant: Tenant
|
|
):
|
|
"""Test con token inválido."""
|
|
response = await client.get(
|
|
"/v1/tickets/",
|
|
headers={
|
|
"Authorization": "Bearer invalid-token",
|
|
"X-Tenant-ID": str(test_tenant.id)
|
|
}
|
|
)
|
|
|
|
assert response.status_code == 401
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.auth
|
|
class TestUserProfile:
|
|
"""Tests del perfil de usuario."""
|
|
|
|
async def test_get_current_user_profile(
|
|
self,
|
|
client: AsyncClient,
|
|
test_admin_user: User,
|
|
auth_headers_admin: dict
|
|
):
|
|
"""Test obtener perfil del usuario actual."""
|
|
response = await client.get(
|
|
"/v1/users/me",
|
|
headers=auth_headers_admin
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
assert data["email"] == "admin@test.com"
|
|
assert data["role"] == "ADMIN"
|
|
assert data["first_name"] == "Admin"
|
|
assert data["last_name"] == "User"
|
|
assert "password_hash" not in data # No debe exponer password
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.auth
|
|
class TestPasswordSecurity:
|
|
"""Tests de seguridad de contraseñas."""
|
|
|
|
async def test_password_hashing(self):
|
|
"""Test que las contraseñas se hashean correctamente."""
|
|
from app.core.security import SecurityUtils
|
|
|
|
password = "TestPassword123!"
|
|
hashed = SecurityUtils.hash_password(password)
|
|
|
|
# Debe ser diferente del original
|
|
assert hashed != password
|
|
|
|
# Debe poder verificarse
|
|
assert SecurityUtils.verify_password(password, hashed)
|
|
|
|
# Contraseña incorrecta no debe verificar
|
|
assert not SecurityUtils.verify_password("WrongPassword", hashed)
|
|
|
|
async def test_password_not_exposed_in_response(
|
|
self,
|
|
client: AsyncClient,
|
|
test_admin_user: User,
|
|
auth_headers_admin: dict
|
|
):
|
|
"""Test que el password hash nunca se expone en las respuestas."""
|
|
response = await client.get(
|
|
"/v1/users/me",
|
|
headers=auth_headers_admin
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
assert "password" not in data
|
|
assert "password_hash" not in data
|