355 lines
11 KiB
Python
355 lines
11 KiB
Python
"""
|
|
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.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]
|