feat: Implementar suite completa de tests de integración v1.9.0
- 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
This commit is contained in:
0
backend/tests/integration/__init__.py
Normal file
0
backend/tests/integration/__init__.py
Normal file
364
backend/tests/integration/test_auth_integration.py
Normal file
364
backend/tests/integration/test_auth_integration.py
Normal file
@@ -0,0 +1,364 @@
|
||||
"""
|
||||
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
|
||||
357
backend/tests/integration/test_multitenant_integration.py
Normal file
357
backend/tests/integration/test_multitenant_integration.py
Normal file
@@ -0,0 +1,357 @@
|
||||
"""
|
||||
Multi-Tenancy Integration Tests - ServiceManagerWeb
|
||||
|
||||
Tests críticos para verificar el aislamiento de datos entre tenants.
|
||||
Estos tests son ESENCIALES para seguridad B2B.
|
||||
"""
|
||||
|
||||
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
|
||||
from app.models.ticket import Ticket, TicketStatus, TicketPriority
|
||||
from app.core.security import SecurityUtils
|
||||
|
||||
pytest_plugins = ['tests.conftest_integration']
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
class TestTenantIsolation:
|
||||
"""Tests de aislamiento de datos entre tenants."""
|
||||
|
||||
async def test_user_cannot_see_other_tenant_tickets(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
db_session: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_tenant_2: Tenant,
|
||||
test_admin_user: User,
|
||||
test_category,
|
||||
auth_headers_admin: dict
|
||||
):
|
||||
"""Test crítico: Usuario de tenant A no puede ver tickets de tenant B."""
|
||||
|
||||
# Crear usuario en tenant 2
|
||||
user_tenant_2 = User(
|
||||
tenant_id=test_tenant_2.id,
|
||||
email="admin@tenant2.com",
|
||||
first_name="Admin",
|
||||
last_name="Tenant2",
|
||||
password_hash=SecurityUtils.hash_password("Password123!"),
|
||||
role=UserRole.ADMIN,
|
||||
is_active=True,
|
||||
email_verified=True
|
||||
)
|
||||
db_session.add(user_tenant_2)
|
||||
await db_session.commit()
|
||||
|
||||
# Crear ticket en tenant 2
|
||||
ticket_tenant_2 = Ticket(
|
||||
tenant_id=test_tenant_2.id,
|
||||
title="Ticket privado de Tenant 2",
|
||||
description="Este ticket NO debe ser visible para tenant 1",
|
||||
status=TicketStatus.NEW,
|
||||
priority=TicketPriority.HIGH,
|
||||
created_by=user_tenant_2.id,
|
||||
category_id=test_category.id
|
||||
)
|
||||
db_session.add(ticket_tenant_2)
|
||||
await db_session.commit()
|
||||
|
||||
# Usuario de tenant 1 intenta listar tickets
|
||||
response = await client.get(
|
||||
"/v1/tickets/",
|
||||
headers={
|
||||
**auth_headers_admin,
|
||||
"X-Tenant-ID": str(test_tenant.id)
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
tickets = response.json()
|
||||
|
||||
# NO debe contener el ticket de tenant 2
|
||||
ticket_ids = [t["id"] for t in tickets]
|
||||
assert str(ticket_tenant_2.id) not in ticket_ids
|
||||
|
||||
async def test_user_cannot_access_other_tenant_ticket_directly(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
db_session: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_tenant_2: Tenant,
|
||||
test_admin_user: User,
|
||||
test_category,
|
||||
auth_headers_admin: dict
|
||||
):
|
||||
"""Test: Usuario no puede acceder a ticket de otro tenant por ID directo."""
|
||||
|
||||
# Crear usuario en tenant 2
|
||||
user_tenant_2 = User(
|
||||
tenant_id=test_tenant_2.id,
|
||||
email="user@tenant2.com",
|
||||
first_name="User",
|
||||
last_name="Tenant2",
|
||||
password_hash=SecurityUtils.hash_password("Password123!"),
|
||||
role=UserRole.ADMIN,
|
||||
is_active=True,
|
||||
email_verified=True
|
||||
)
|
||||
db_session.add(user_tenant_2)
|
||||
await db_session.commit()
|
||||
|
||||
# Crear ticket en tenant 2
|
||||
ticket_tenant_2 = Ticket(
|
||||
tenant_id=test_tenant_2.id,
|
||||
title="Ticket secreto",
|
||||
description="Información confidencial",
|
||||
status=TicketStatus.NEW,
|
||||
priority=TicketPriority.URGENT,
|
||||
created_by=user_tenant_2.id,
|
||||
category_id=test_category.id
|
||||
)
|
||||
db_session.add(ticket_tenant_2)
|
||||
await db_session.commit()
|
||||
|
||||
# Usuario de tenant 1 intenta acceder con ID directo
|
||||
response = await client.get(
|
||||
f"/v1/tickets/{ticket_tenant_2.id}",
|
||||
headers={
|
||||
**auth_headers_admin,
|
||||
"X-Tenant-ID": str(test_tenant.id)
|
||||
}
|
||||
)
|
||||
|
||||
# Debe devolver 404 (no 403 para no revelar existencia)
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_user_cannot_update_other_tenant_ticket(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
db_session: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_tenant_2: Tenant,
|
||||
test_category,
|
||||
auth_headers_admin: dict
|
||||
):
|
||||
"""Test: Usuario no puede modificar ticket de otro tenant."""
|
||||
|
||||
# Crear usuario y ticket en tenant 2
|
||||
user_tenant_2 = User(
|
||||
tenant_id=test_tenant_2.id,
|
||||
email="user@tenant2.com",
|
||||
first_name="User",
|
||||
last_name="Tenant2",
|
||||
password_hash=SecurityUtils.hash_password("Password123!"),
|
||||
role=UserRole.ADMIN,
|
||||
is_active=True,
|
||||
email_verified=True
|
||||
)
|
||||
db_session.add(user_tenant_2)
|
||||
await db_session.commit()
|
||||
|
||||
ticket_tenant_2 = Ticket(
|
||||
tenant_id=test_tenant_2.id,
|
||||
title="Original title",
|
||||
description="Original description",
|
||||
status=TicketStatus.NEW,
|
||||
priority=TicketPriority.MEDIUM,
|
||||
created_by=user_tenant_2.id,
|
||||
category_id=test_category.id
|
||||
)
|
||||
db_session.add(ticket_tenant_2)
|
||||
await db_session.commit()
|
||||
|
||||
original_title = ticket_tenant_2.title
|
||||
|
||||
# Usuario de tenant 1 intenta modificar
|
||||
response = await client.patch(
|
||||
f"/v1/tickets/{ticket_tenant_2.id}",
|
||||
headers={
|
||||
**auth_headers_admin,
|
||||
"X-Tenant-ID": str(test_tenant.id)
|
||||
},
|
||||
json={
|
||||
"title": "HACKED TITLE",
|
||||
"status": "CLOSED"
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
# Verificar que el ticket NO fue modificado
|
||||
await db_session.refresh(ticket_tenant_2)
|
||||
assert ticket_tenant_2.title == original_title
|
||||
assert ticket_tenant_2.status == TicketStatus.NEW
|
||||
|
||||
async def test_middleware_validates_tenant_header(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
test_tenant: Tenant,
|
||||
auth_headers_admin: dict
|
||||
):
|
||||
"""Test que el middleware valida el X-Tenant-ID header."""
|
||||
|
||||
# Sin header de tenant
|
||||
response = await client.get(
|
||||
"/v1/tickets/",
|
||||
headers=auth_headers_admin
|
||||
)
|
||||
|
||||
# Debe requerir tenant header
|
||||
assert response.status_code in [400, 401]
|
||||
|
||||
async def test_middleware_rejects_invalid_tenant_uuid(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
auth_headers_admin: dict
|
||||
):
|
||||
"""Test que el middleware rechaza UUIDs inválidos."""
|
||||
|
||||
response = await client.get(
|
||||
"/v1/tickets/",
|
||||
headers={
|
||||
**auth_headers_admin,
|
||||
"X-Tenant-ID": "not-a-uuid"
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
async def test_middleware_rejects_nonexistent_tenant(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
auth_headers_admin: dict
|
||||
):
|
||||
"""Test que el middleware rechaza tenants inexistentes."""
|
||||
|
||||
import uuid
|
||||
fake_tenant_id = str(uuid.uuid4())
|
||||
|
||||
response = await client.get(
|
||||
"/v1/tickets/",
|
||||
headers={
|
||||
**auth_headers_admin,
|
||||
"X-Tenant-ID": fake_tenant_id
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
class TestTenantAdminEndpoints:
|
||||
"""Tests de endpoints administrativos de tenants."""
|
||||
|
||||
async def test_admin_can_list_tenants(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
test_tenant: Tenant,
|
||||
test_tenant_2: Tenant,
|
||||
auth_headers_admin: dict
|
||||
):
|
||||
"""Test que admin puede listar tenants."""
|
||||
|
||||
response = await client.get(
|
||||
"/v1/tenants/",
|
||||
headers={
|
||||
**auth_headers_admin,
|
||||
"X-Tenant-ID": str(test_tenant.id)
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
tenants = response.json()
|
||||
assert len(tenants) >= 2
|
||||
|
||||
async def test_non_admin_cannot_list_tenants(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
test_tenant: Tenant,
|
||||
auth_headers_client: dict
|
||||
):
|
||||
"""Test que usuario no-admin no puede listar tenants."""
|
||||
|
||||
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_admin_can_create_tenant(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
test_tenant: Tenant,
|
||||
auth_headers_admin: dict
|
||||
):
|
||||
"""Test que admin puede crear nuevos tenants."""
|
||||
|
||||
response = await client.post(
|
||||
"/v1/tenants/",
|
||||
headers={
|
||||
**auth_headers_admin,
|
||||
"X-Tenant-ID": str(test_tenant.id)
|
||||
},
|
||||
json={
|
||||
"name": "New Test Company",
|
||||
"slug": "new-test-company",
|
||||
"domain": "new.test.com",
|
||||
"email": "admin@new.test.com",
|
||||
"phone": "+1111111111"
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == "New Test Company"
|
||||
assert data["slug"] == "new-test-company"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
class TestCrossTenantuserAccess:
|
||||
"""Tests de acceso de usuarios entre tenants."""
|
||||
|
||||
async def test_user_belongs_to_only_one_tenant(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
test_admin_user: User,
|
||||
test_tenant: Tenant
|
||||
):
|
||||
"""Test que cada usuario pertenece a exactamente un tenant."""
|
||||
|
||||
assert test_admin_user.tenant_id == test_tenant.id
|
||||
|
||||
# Verificar que no puede tener múltiples tenant_ids
|
||||
# (esto es a nivel de modelo, pero importante documentar)
|
||||
|
||||
async def test_user_from_tenant_a_cannot_impersonate_tenant_b(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
test_tenant: Tenant,
|
||||
test_tenant_2: Tenant,
|
||||
auth_headers_admin: dict
|
||||
):
|
||||
"""Test que usuario autenticado no puede cambiar de tenant."""
|
||||
|
||||
# Usuario de tenant 1 intenta usar header de tenant 2
|
||||
response = await client.get(
|
||||
"/v1/tickets/",
|
||||
headers={
|
||||
**auth_headers_admin,
|
||||
"X-Tenant-ID": str(test_tenant_2.id) # Intento de suplantación
|
||||
}
|
||||
)
|
||||
|
||||
# La request debe fallar (el token pertenece a tenant 1)
|
||||
# El comportamiento específico depende de tu implementación,
|
||||
# pero NO debe permitir acceso a datos de tenant 2
|
||||
assert response.status_code in [403, 404, 401]
|
||||
613
backend/tests/integration/test_tickets_integration.py
Normal file
613
backend/tests/integration/test_tickets_integration.py
Normal file
@@ -0,0 +1,613 @@
|
||||
"""
|
||||
Tickets Integration Tests - ServiceManagerWeb
|
||||
|
||||
Tests completos del CRUD de tickets y funcionalidad relacionada.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import uuid
|
||||
|
||||
from app.models.user import User
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.ticket import Ticket, TicketStatus, TicketPriority
|
||||
from app.models.system import System
|
||||
from app.models.category import Category
|
||||
|
||||
pytest_plugins = ['tests.conftest_integration']
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
class TestTicketCreation:
|
||||
"""Tests de creación de tickets."""
|
||||
|
||||
async def test_create_ticket_success(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
test_tenant: Tenant,
|
||||
test_category: Category,
|
||||
auth_headers_client: dict
|
||||
):
|
||||
"""Test crear ticket con datos válidos."""
|
||||
|
||||
response = await client.post(
|
||||
"/v1/tickets/",
|
||||
headers={
|
||||
**auth_headers_client,
|
||||
"X-Tenant-ID": str(test_tenant.id)
|
||||
},
|
||||
json={
|
||||
"title": "Test ticket",
|
||||
"description": "This is a test ticket description",
|
||||
"priority": "MEDIUM",
|
||||
"category_id": str(test_category.id)
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
|
||||
assert data["title"] == "Test ticket"
|
||||
assert data["description"] == "This is a test ticket description"
|
||||
assert data["priority"] == "MEDIUM"
|
||||
assert data["status"] == "NEW"
|
||||
assert data["category_id"] == str(test_category.id)
|
||||
|
||||
async def test_create_ticket_with_all_fields(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
test_tenant: Tenant,
|
||||
test_category: Category,
|
||||
test_system: System,
|
||||
auth_headers_admin: dict
|
||||
):
|
||||
"""Test crear ticket con todos los campos opcionales."""
|
||||
|
||||
response = await client.post(
|
||||
"/v1/tickets/",
|
||||
headers={
|
||||
**auth_headers_admin,
|
||||
"X-Tenant-ID": str(test_tenant.id)
|
||||
},
|
||||
json={
|
||||
"title": "Complete ticket",
|
||||
"description": "Full ticket with all fields",
|
||||
"priority": "HIGH",
|
||||
"category_id": str(test_category.id),
|
||||
"system_id": str(test_system.id),
|
||||
"contact_email": "contact@test.com",
|
||||
"contact_phone": "+1234567890"
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
|
||||
assert data["priority"] == "HIGH"
|
||||
assert data["system_id"] == str(test_system.id)
|
||||
assert data["contact_email"] == "contact@test.com"
|
||||
|
||||
async def test_create_ticket_missing_required_fields(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
test_tenant: Tenant,
|
||||
auth_headers_client: dict
|
||||
):
|
||||
"""Test crear ticket sin campos requeridos."""
|
||||
|
||||
response = await client.post(
|
||||
"/v1/tickets/",
|
||||
headers={
|
||||
**auth_headers_client,
|
||||
"X-Tenant-ID": str(test_tenant.id)
|
||||
},
|
||||
json={
|
||||
"description": "Missing title"
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 422 # Validation error
|
||||
|
||||
async def test_create_ticket_invalid_priority(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
test_tenant: Tenant,
|
||||
test_category: Category,
|
||||
auth_headers_client: dict
|
||||
):
|
||||
"""Test crear ticket con prioridad inválida."""
|
||||
|
||||
response = await client.post(
|
||||
"/v1/tickets/",
|
||||
headers={
|
||||
**auth_headers_client,
|
||||
"X-Tenant-ID": str(test_tenant.id)
|
||||
},
|
||||
json={
|
||||
"title": "Test ticket",
|
||||
"description": "Description",
|
||||
"priority": "SUPER_URGENT", # Inválido
|
||||
"category_id": str(test_category.id)
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
class TestTicketRetrieval:
|
||||
"""Tests de consulta de tickets."""
|
||||
|
||||
async def test_list_tickets_empty(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
test_tenant: Tenant,
|
||||
auth_headers_admin: dict
|
||||
):
|
||||
"""Test listar tickets cuando no hay ninguno."""
|
||||
|
||||
response = await client.get(
|
||||
"/v1/tickets/",
|
||||
headers={
|
||||
**auth_headers_admin,
|
||||
"X-Tenant-ID": str(test_tenant.id)
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
tickets = response.json()
|
||||
assert isinstance(tickets, list)
|
||||
|
||||
async def test_list_tickets_with_data(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
db_session: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_admin_user: User,
|
||||
test_category: Category,
|
||||
auth_headers_admin: dict
|
||||
):
|
||||
"""Test listar tickets cuando existen."""
|
||||
|
||||
# Crear algunos tickets
|
||||
for i in range(3):
|
||||
ticket = Ticket(
|
||||
tenant_id=test_tenant.id,
|
||||
title=f"Test ticket {i+1}",
|
||||
description=f"Description {i+1}",
|
||||
status=TicketStatus.NEW,
|
||||
priority=TicketPriority.MEDIUM,
|
||||
created_by=test_admin_user.id,
|
||||
category_id=test_category.id
|
||||
)
|
||||
db_session.add(ticket)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.get(
|
||||
"/v1/tickets/",
|
||||
headers={
|
||||
**auth_headers_admin,
|
||||
"X-Tenant-ID": str(test_tenant.id)
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
tickets = response.json()
|
||||
assert len(tickets) == 3
|
||||
|
||||
async def test_get_ticket_by_id(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
db_session: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_admin_user: User,
|
||||
test_category: Category,
|
||||
auth_headers_admin: dict
|
||||
):
|
||||
"""Test obtener ticket específico por ID."""
|
||||
|
||||
ticket = Ticket(
|
||||
tenant_id=test_tenant.id,
|
||||
title="Specific ticket",
|
||||
description="Get this ticket",
|
||||
status=TicketStatus.NEW,
|
||||
priority=TicketPriority.HIGH,
|
||||
created_by=test_admin_user.id,
|
||||
category_id=test_category.id
|
||||
)
|
||||
db_session.add(ticket)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(ticket)
|
||||
|
||||
response = await client.get(
|
||||
f"/v1/tickets/{ticket.id}",
|
||||
headers={
|
||||
**auth_headers_admin,
|
||||
"X-Tenant-ID": str(test_tenant.id)
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == str(ticket.id)
|
||||
assert data["title"] == "Specific ticket"
|
||||
|
||||
async def test_get_nonexistent_ticket(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
test_tenant: Tenant,
|
||||
auth_headers_admin: dict
|
||||
):
|
||||
"""Test obtener ticket inexistente."""
|
||||
|
||||
fake_id = str(uuid.uuid4())
|
||||
|
||||
response = await client.get(
|
||||
f"/v1/tickets/{fake_id}",
|
||||
headers={
|
||||
**auth_headers_admin,
|
||||
"X-Tenant-ID": str(test_tenant.id)
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
class TestTicketUpdate:
|
||||
"""Tests de actualización de tickets."""
|
||||
|
||||
async def test_update_ticket_status(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
db_session: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_admin_user: User,
|
||||
test_category: Category,
|
||||
auth_headers_admin: dict
|
||||
):
|
||||
"""Test actualizar status de ticket."""
|
||||
|
||||
ticket = Ticket(
|
||||
tenant_id=test_tenant.id,
|
||||
title="Ticket to update",
|
||||
description="Description",
|
||||
status=TicketStatus.NEW,
|
||||
priority=TicketPriority.MEDIUM,
|
||||
created_by=test_admin_user.id,
|
||||
category_id=test_category.id
|
||||
)
|
||||
db_session.add(ticket)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(ticket)
|
||||
|
||||
response = await client.patch(
|
||||
f"/v1/tickets/{ticket.id}",
|
||||
headers={
|
||||
**auth_headers_admin,
|
||||
"X-Tenant-ID": str(test_tenant.id)
|
||||
},
|
||||
json={
|
||||
"status": "IN_PROGRESS"
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "IN_PROGRESS"
|
||||
|
||||
async def test_update_ticket_priority(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
db_session: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_admin_user: User,
|
||||
test_category: Category,
|
||||
auth_headers_admin: dict
|
||||
):
|
||||
"""Test actualizar prioridad de ticket."""
|
||||
|
||||
ticket = Ticket(
|
||||
tenant_id=test_tenant.id,
|
||||
title="Ticket priority test",
|
||||
description="Description",
|
||||
status=TicketStatus.NEW,
|
||||
priority=TicketPriority.LOW,
|
||||
created_by=test_admin_user.id,
|
||||
category_id=test_category.id
|
||||
)
|
||||
db_session.add(ticket)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(ticket)
|
||||
|
||||
response = await client.patch(
|
||||
f"/v1/tickets/{ticket.id}",
|
||||
headers={
|
||||
**auth_headers_admin,
|
||||
"X-Tenant-ID": str(test_tenant.id)
|
||||
},
|
||||
json={
|
||||
"priority": "URGENT"
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["priority"] == "URGENT"
|
||||
|
||||
async def test_update_ticket_assignment(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
db_session: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_admin_user: User,
|
||||
test_agent_user: User,
|
||||
test_category: Category,
|
||||
auth_headers_admin: dict
|
||||
):
|
||||
"""Test asignar ticket a un agente."""
|
||||
|
||||
ticket = Ticket(
|
||||
tenant_id=test_tenant.id,
|
||||
title="Ticket to assign",
|
||||
description="Description",
|
||||
status=TicketStatus.NEW,
|
||||
priority=TicketPriority.MEDIUM,
|
||||
created_by=test_admin_user.id,
|
||||
category_id=test_category.id
|
||||
)
|
||||
db_session.add(ticket)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(ticket)
|
||||
|
||||
response = await client.patch(
|
||||
f"/v1/tickets/{ticket.id}",
|
||||
headers={
|
||||
**auth_headers_admin,
|
||||
"X-Tenant-ID": str(test_tenant.id)
|
||||
},
|
||||
json={
|
||||
"assigned_to": str(test_agent_user.id)
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["assigned_to"] == str(test_agent_user.id)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
class TestTicketFilters:
|
||||
"""Tests de filtros de tickets."""
|
||||
|
||||
async def test_filter_by_status(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
db_session: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_admin_user: User,
|
||||
test_category: Category,
|
||||
auth_headers_admin: dict
|
||||
):
|
||||
"""Test filtrar tickets por status."""
|
||||
|
||||
# Crear tickets con diferentes status
|
||||
ticket_new = Ticket(
|
||||
tenant_id=test_tenant.id,
|
||||
title="New ticket",
|
||||
description="Description",
|
||||
status=TicketStatus.NEW,
|
||||
priority=TicketPriority.MEDIUM,
|
||||
created_by=test_admin_user.id,
|
||||
category_id=test_category.id
|
||||
)
|
||||
ticket_progress = Ticket(
|
||||
tenant_id=test_tenant.id,
|
||||
title="In progress ticket",
|
||||
description="Description",
|
||||
status=TicketStatus.IN_PROGRESS,
|
||||
priority=TicketPriority.MEDIUM,
|
||||
created_by=test_admin_user.id,
|
||||
category_id=test_category.id
|
||||
)
|
||||
db_session.add_all([ticket_new, ticket_progress])
|
||||
await db_session.commit()
|
||||
|
||||
# Filtrar por status NEW
|
||||
response = await client.get(
|
||||
"/v1/tickets/?status=NEW",
|
||||
headers={
|
||||
**auth_headers_admin,
|
||||
"X-Tenant-ID": str(test_tenant.id)
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
tickets = response.json()
|
||||
assert all(t["status"] == "NEW" for t in tickets)
|
||||
|
||||
async def test_filter_by_priority(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
db_session: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_admin_user: User,
|
||||
test_category: Category,
|
||||
auth_headers_admin: dict
|
||||
):
|
||||
"""Test filtrar tickets por prioridad."""
|
||||
|
||||
# Crear tickets con diferentes prioridades
|
||||
ticket_low = Ticket(
|
||||
tenant_id=test_tenant.id,
|
||||
title="Low priority",
|
||||
description="Description",
|
||||
status=TicketStatus.NEW,
|
||||
priority=TicketPriority.LOW,
|
||||
created_by=test_admin_user.id,
|
||||
category_id=test_category.id
|
||||
)
|
||||
ticket_urgent = Ticket(
|
||||
tenant_id=test_tenant.id,
|
||||
title="Urgent priority",
|
||||
description="Description",
|
||||
status=TicketStatus.NEW,
|
||||
priority=TicketPriority.URGENT,
|
||||
created_by=test_admin_user.id,
|
||||
category_id=test_category.id
|
||||
)
|
||||
db_session.add_all([ticket_low, ticket_urgent])
|
||||
await db_session.commit()
|
||||
|
||||
# Filtrar por URGENT
|
||||
response = await client.get(
|
||||
"/v1/tickets/?priority=URGENT",
|
||||
headers={
|
||||
**auth_headers_admin,
|
||||
"X-Tenant-ID": str(test_tenant.id)
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
tickets = response.json()
|
||||
assert all(t["priority"] == "URGENT" for t in tickets)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
class TestTicketPermissions:
|
||||
"""Tests de permisos en tickets."""
|
||||
|
||||
async def test_client_can_create_ticket(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
test_tenant: Tenant,
|
||||
test_category: Category,
|
||||
auth_headers_client: dict
|
||||
):
|
||||
"""Test que cliente puede crear tickets."""
|
||||
|
||||
response = await client.post(
|
||||
"/v1/tickets/",
|
||||
headers={
|
||||
**auth_headers_client,
|
||||
"X-Tenant-ID": str(test_tenant.id)
|
||||
},
|
||||
json={
|
||||
"title": "Client ticket",
|
||||
"description": "Created by client",
|
||||
"priority": "MEDIUM",
|
||||
"category_id": str(test_category.id)
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
async def test_client_can_only_see_own_tickets(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
db_session: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_client_user: User,
|
||||
test_admin_user: User,
|
||||
test_category: Category,
|
||||
auth_headers_client: dict
|
||||
):
|
||||
"""Test que cliente solo ve sus propios tickets."""
|
||||
|
||||
# Ticket del cliente
|
||||
ticket_own = Ticket(
|
||||
tenant_id=test_tenant.id,
|
||||
title="My ticket",
|
||||
description="Description",
|
||||
status=TicketStatus.NEW,
|
||||
priority=TicketPriority.MEDIUM,
|
||||
created_by=test_client_user.id,
|
||||
category_id=test_category.id
|
||||
)
|
||||
|
||||
# Ticket de otro usuario
|
||||
ticket_other = Ticket(
|
||||
tenant_id=test_tenant.id,
|
||||
title="Other ticket",
|
||||
description="Description",
|
||||
status=TicketStatus.NEW,
|
||||
priority=TicketPriority.MEDIUM,
|
||||
created_by=test_admin_user.id,
|
||||
category_id=test_category.id
|
||||
)
|
||||
|
||||
db_session.add_all([ticket_own, ticket_other])
|
||||
await db_session.commit()
|
||||
|
||||
# Cliente lista tickets
|
||||
response = await client.get(
|
||||
"/v1/tickets/",
|
||||
headers={
|
||||
**auth_headers_client,
|
||||
"X-Tenant-ID": str(test_tenant.id)
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
tickets = response.json()
|
||||
|
||||
# Solo debe ver su propio ticket
|
||||
ticket_ids = [t["id"] for t in tickets]
|
||||
assert str(ticket_own.id) in ticket_ids
|
||||
assert str(ticket_other.id) not in ticket_ids
|
||||
|
||||
async def test_agent_can_see_all_tenant_tickets(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
db_session: AsyncSession,
|
||||
test_tenant: Tenant,
|
||||
test_agent_user: User,
|
||||
test_admin_user: User,
|
||||
test_category: Category,
|
||||
auth_headers_agent: dict
|
||||
):
|
||||
"""Test que agente ve todos los tickets del tenant."""
|
||||
|
||||
# Crear tickets de diferentes usuarios
|
||||
ticket_1 = Ticket(
|
||||
tenant_id=test_tenant.id,
|
||||
title="Ticket 1",
|
||||
description="Description",
|
||||
status=TicketStatus.NEW,
|
||||
priority=TicketPriority.MEDIUM,
|
||||
created_by=test_agent_user.id,
|
||||
category_id=test_category.id
|
||||
)
|
||||
ticket_2 = Ticket(
|
||||
tenant_id=test_tenant.id,
|
||||
title="Ticket 2",
|
||||
description="Description",
|
||||
status=TicketStatus.NEW,
|
||||
priority=TicketPriority.MEDIUM,
|
||||
created_by=test_admin_user.id,
|
||||
category_id=test_category.id
|
||||
)
|
||||
|
||||
db_session.add_all([ticket_1, ticket_2])
|
||||
await db_session.commit()
|
||||
|
||||
# Agente lista tickets
|
||||
response = await client.get(
|
||||
"/v1/tickets/",
|
||||
headers={
|
||||
**auth_headers_agent,
|
||||
"X-Tenant-ID": str(test_tenant.id)
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
tickets = response.json()
|
||||
|
||||
# Debe ver ambos tickets
|
||||
assert len(tickets) >= 2
|
||||
Reference in New Issue
Block a user