""" 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]