From 3b46f48655312c979167ff90b4201015963361de Mon Sep 17 00:00:00 2001 From: icamarillo Date: Tue, 3 Mar 2026 14:02:18 -0700 Subject: [PATCH] Roles --- backend/app/api/deps.py | 5 +- backend/app/api/v1/endpoints/tickets.py | 101 +---- backend/app/api/v1/endpoints/users.py | 8 +- backend/app/api/v1/helpers.py | 4 +- backend/app/models/roles.py | 63 +++ backend/app/models/user.py | 29 +- backend/app/services/ticket_service.py | 170 ++++++++ backend/app/tests/__init__.py | 0 backend/app/tests/conftest.py | 373 ++++++++++++++++++ backend/app/tests/test_smoke.py | 137 +++++++ backend/app/tests/test_tenant_isolation.py | 123 ++++++ ...a5b6_add_client_roles_to_user_role_enum.py | 35 ++ ...a5b6c7_remove_intermediate_client_roles.py | 92 +++++ 13 files changed, 1041 insertions(+), 99 deletions(-) create mode 100644 backend/app/models/roles.py create mode 100644 backend/app/services/ticket_service.py create mode 100644 backend/app/tests/__init__.py create mode 100644 backend/app/tests/conftest.py create mode 100644 backend/app/tests/test_smoke.py create mode 100644 backend/app/tests/test_tenant_isolation.py create mode 100644 backend/migrations/versions/c1d2e3f4a5b6_add_client_roles_to_user_role_enum.py create mode 100644 backend/migrations/versions/d2e3f4a5b6c7_remove_intermediate_client_roles.py diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index c5ad00d..bd014a1 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -89,9 +89,10 @@ async def get_current_user( raise HTTPException(status_code=400, detail="Inactive user") # Enforce that tenant header (if present) matches the authenticated user's tenant. - # Prevents cross-tenant header impersonation. + # Roles globales (is_global) pueden operar en cualquier tenant → omitir chequeo. + # Roles de cliente (is_client) deben coincidir con su propio tenant. request_tenant_id = getattr(getattr(request, "state", None), "tenant_id", None) - if request_tenant_id and str(user.tenant_id) != str(request_tenant_id): + if request_tenant_id and user.role.is_client and str(user.tenant_id) != str(request_tenant_id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Tenant header does not match authenticated user", diff --git a/backend/app/api/v1/endpoints/tickets.py b/backend/app/api/v1/endpoints/tickets.py index 1539785..193460d 100644 --- a/backend/app/api/v1/endpoints/tickets.py +++ b/backend/app/api/v1/endpoints/tickets.py @@ -11,7 +11,7 @@ import uuid from app.core.database import get_db from app.api.deps import get_current_user, get_current_tenant from app.models.ticket import Ticket, TicketStatus, TicketPriority -from app.models.user import User +from app.models.user import User, UserRole from app.models.tenant import Tenant from app.models.category import Category from app.models.system import System @@ -28,98 +28,27 @@ from app.api.v1.helpers import ( safe_audit_log, generate_next_ticket_number, calculate_sla_deadlines, ticket_to_dict ) from app.services.audit_service import AuditService +from app.services.ticket_service import TicketService, get_ticket_service router = APIRouter() @router.post("/", response_model=TicketResponse, status_code=status.HTTP_201_CREATED) -async def create_ticket(ticket: TicketCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): +async def create_ticket( + ticket: TicketCreate, + current_user: User = Depends(get_current_user), + ticket_service: TicketService = Depends(get_ticket_service), +): """Crear un nuevo ticket""" - max_retries = 3 - last_error = None - - for attempt in range(max_retries): - try: - ticket_number = await generate_next_ticket_number(db, current_user.tenant_id) - category_uuid = uuid.UUID(ticket.category_id) if ticket.category_id else None - system_uuid = uuid.UUID(ticket.affected_system_id) if ticket.affected_system_id else None - - category = None - if category_uuid: - category = await db.get(Category, category_uuid) - if not category: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"La categoría con ID {ticket.category_id} no existe.") - # ✅ SECURITY: Validate category belongs to current tenant (prevents cross-tenant category injection) - if category.tenant_id != current_user.tenant_id: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"La categoría con ID {ticket.category_id} no existe.") - - if system_uuid: - system = await db.get(System, system_uuid) - if not system: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"El sistema con ID {ticket.affected_system_id} no existe.") - # ✅ SECURITY: Validate system belongs to current tenant (prevents cross-tenant system injection) - if system.tenant_id != current_user.tenant_id: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"El sistema con ID {ticket.affected_system_id} no existe.") - - sla_response_due, sla_resolution_due = calculate_sla_deadlines(category) - assigned_to_user = category.auto_assign_to if category and category.auto_assign_to else None - - db_ticket = Ticket( - id=uuid.uuid4(), tenant_id=current_user.tenant_id, ticket_number=ticket_number, - subject=ticket.subject, description=ticket.description, category_id=category_uuid, - affected_system_id=system_uuid, priority=TicketPriority[ticket.priority.upper()], - created_by=current_user.id, assigned_to=assigned_to_user, status=TicketStatus.NEW, - sla_response_due=sla_response_due, sla_resolution_due=sla_resolution_due, - created_at=datetime.utcnow(), updated_at=datetime.utcnow() - ) - - db.add(db_ticket) - await db.commit() - await db.refresh(db_ticket) - - await safe_audit_log(db=db, tenant_id=current_user.tenant_id, user_id=current_user.id, - action="ticket.create", resource_type="ticket", resource_id=db_ticket.id, - new_values={"ticket_number": db_ticket.ticket_number, "subject": db_ticket.subject, - "priority": db_ticket.priority.value, "status": db_ticket.status.value}) - - return { - "id": str(db_ticket.id), "ticket_number": db_ticket.ticket_number, "subject": db_ticket.subject, - "title": db_ticket.subject, "description": db_ticket.description, "status": db_ticket.status.value, - "priority": db_ticket.priority.value, "category_id": str(db_ticket.category_id) if db_ticket.category_id else None, - "affected_system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None, - "system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None, - "contact_email": ticket.contact_email, - "contact_phone": ticket.contact_phone, - "created_by": str(db_ticket.created_by), "assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None, - "created_at": db_ticket.created_at, "updated_at": db_ticket.updated_at, - "sla_response_due": db_ticket.sla_response_due, - "sla_resolution_due": db_ticket.sla_resolution_due, - "first_response_at": db_ticket.first_response_at, - "resolved_at": db_ticket.resolved_at, - } - - except ValueError as e: - await db.rollback() - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid UUID format: {str(e)}") - except HTTPException: - await db.rollback() - raise - except Exception as e: - await db.rollback() - last_error = e - if "duplicate key" in str(e).lower() and "ticket_number" in str(e).lower(): - if attempt < max_retries - 1: - continue - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Error creating ticket: {str(e)}") - - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"No se pudo crear el ticket después de {max_retries} intentos: {str(last_error)}") + return await ticket_service.create_ticket(ticket, current_user.tenant_id, current_user.id) @router.get("/", response_model=List[TicketResponse]) async def get_tickets(skip: int = 0, limit: int = 100, status: Optional[str] = None, priority: Optional[str] = None, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): """Obtener tickets con filtros opcionales""" query = select(Ticket).where(Ticket.tenant_id == current_user.tenant_id) - if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]: + # Solo CLIENT_USER ve únicamente sus propios tickets. + # CLIENT_ADMIN ve todos los del tenant. + if current_user.role == UserRole.CLIENT_USER: query = query.where(Ticket.created_by == current_user.id) query = apply_enum_filter(query, Ticket.status, status, TicketStatus, "status") @@ -142,14 +71,14 @@ async def get_all_tickets_admin(skip: int = 0, limit: int = 100, status_filter: assigned_to_filter: Optional[str] = None, search: Optional[str] = None, date_from: Optional[str] = None, date_to: Optional[str] = None, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): """Obtener todos los tickets del tenant del administrador (ADMIN/SUPPORT_MANAGER).""" - if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]: + if current_user.role not in (UserRole.ADMIN, UserRole.SUPPORT_MANAGER): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No tienes permisos para acceder a esta función") query = select(Ticket, Tenant, User).join(Tenant, Ticket.tenant_id == Tenant.id).join(User, Ticket.created_by == User.id) # SUPPORT_MANAGER solo ve su propio tenant. # ADMIN ve todos los tenants (es el administrador de la plataforma). - if current_user.role == "SUPPORT_MANAGER": + if current_user.role == UserRole.SUPPORT_MANAGER: query = query.where(Ticket.tenant_id == current_user.tenant_id) query = apply_enum_filter(query, Ticket.status, status_filter, TicketStatus, "status") @@ -207,7 +136,7 @@ async def get_ticket(ticket_id: str, db: AsyncSession = Depends(get_db), current """Obtener un ticket por ID""" ticket_uuid = validate_uuid_param(ticket_id, "ticket ID") query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id) - if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]: + if current_user.role.is_client: query = query.where(Ticket.created_by == current_user.id) query = query.options(selectinload(Ticket.category), selectinload(Ticket.affected_system), selectinload(Ticket.assigned_to_user)) @@ -224,7 +153,7 @@ async def update_ticket(ticket_id: str, ticket: TicketUpdate, db: AsyncSession = """Actualizar un ticket""" ticket_uuid = validate_uuid_param(ticket_id, "ticket ID") query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id) - if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]: + if current_user.role.is_client: query = query.where(Ticket.created_by == current_user.id) result = await db.execute(query) diff --git a/backend/app/api/v1/endpoints/users.py b/backend/app/api/v1/endpoints/users.py index 22caf00..3bae78d 100644 --- a/backend/app/api/v1/endpoints/users.py +++ b/backend/app/api/v1/endpoints/users.py @@ -45,8 +45,12 @@ async def read_users( - role: filtrar por rol - is_active: filtrar por estado activo """ - # ✅ CORREGIDO: Filtrar por tenant_id - query = select(User).where(User.tenant_id == current_user.tenant_id) + # ADMIN global ve todos los tenants; el resto solo ve su propio tenant + from app.models.user import UserRole as _UserRole + if current_user.role != _UserRole.ADMIN: + query = select(User).where(User.tenant_id == current_user.tenant_id) + else: + query = select(User) # Aplicar filtros opcionales if role: diff --git a/backend/app/api/v1/helpers.py b/backend/app/api/v1/helpers.py index ed7f8d5..68cb275 100644 --- a/backend/app/api/v1/helpers.py +++ b/backend/app/api/v1/helpers.py @@ -68,11 +68,11 @@ async def generate_next_ticket_number(db: AsyncSession, tenant_id: uuid.UUID) -> last_ticket_number = result.scalar_one_or_none() if last_ticket_number: - last_number = int(last_ticket_number.split('-')[1]) + last_number = int(last_ticket_number.split('-')[-1]) next_number = last_number + 1 else: next_number = 1 - + return f"TK-{next_number:06d}" diff --git a/backend/app/models/roles.py b/backend/app/models/roles.py new file mode 100644 index 0000000..337ca5e --- /dev/null +++ b/backend/app/models/roles.py @@ -0,0 +1,63 @@ +""" +Definición y helpers de roles para el sistema multi-tenant. + +Fuente única: UserRole en app.models.user. +Este módulo expone conjuntos de roles y helpers de verificación +para usarse en deps.py y en los endpoints. + +Roles globales (staff interno — alcance multi-tenant): + ADMIN → control total sobre todos los tenants + SUPPORT_MANAGER → gestiona equipos y SLAs de todos los tenants + AGENT → atiende tickets de cualquier tenant + AUDITOR → auditoría de solo lectura en todos los tenants + +Roles de cliente (alcance limitado al propio tenant): + CLIENT_ADMIN → administra organización: usuarios, configuración, tickets + CLIENT_USER → crea y sigue sus propios tickets +""" + +from app.models.user import UserRole + +# ── Conjuntos de roles ────────────────────────────────────────────────────── + +GLOBAL_ROLES: frozenset[UserRole] = frozenset({ + UserRole.ADMIN, + UserRole.SUPPORT_MANAGER, + UserRole.AGENT, + UserRole.AUDITOR, +}) + +CLIENT_ROLES: frozenset[UserRole] = frozenset({ + UserRole.CLIENT_ADMIN, + UserRole.CLIENT_USER, +}) + +# ── Permisos por rol ──────────────────────────────────────────────────────── + +ROLE_PERMISSIONS: dict[UserRole, list[str]] = { + # Staff global + UserRole.ADMIN: ["manage_all", "view_all", "audit_all"], + UserRole.SUPPORT_MANAGER: ["manage_teams", "view_all_tickets", "manage_sla"], + UserRole.AGENT: ["view_all_tickets", "update_any_ticket"], + UserRole.AUDITOR: ["view_all", "audit_all"], + # Clientes (acotados al tenant) + UserRole.CLIENT_ADMIN: ["manage_tenant", "manage_tenant_users", "view_tenant_tickets"], + UserRole.CLIENT_USER: ["create_ticket", "view_own_tickets"], +} + +# ── Helpers ───────────────────────────────────────────────────────────────── + +def is_global_staff(role: UserRole) -> bool: + """Retorna True si el rol tiene alcance global (staff interno).""" + return role.is_global + + +def is_client_role(role: UserRole) -> bool: + """Retorna True si el rol está acotado al tenant del usuario.""" + return role.is_client + + +def has_permission(role: UserRole, permission: str) -> bool: + """Verifica si un rol tiene un permiso específico.""" + return permission in ROLE_PERMISSIONS.get(role, []) + diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 878c6a4..5b5832d 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -27,6 +27,24 @@ class UserRole(str, enum.Enum): CLIENT_ADMIN = "CLIENT_ADMIN" # Admin de organización cliente CLIENT_USER = "CLIENT_USER" # Usuario final cliente + @property + def is_global(self) -> bool: + """True si el rol tiene alcance global (staff interno cross-tenant).""" + return self in ( + UserRole.ADMIN, + UserRole.SUPPORT_MANAGER, + UserRole.AGENT, + UserRole.AUDITOR, + ) + + @property + def is_client(self) -> bool: + """True si el rol está acotado al tenant del usuario.""" + return self in ( + UserRole.CLIENT_ADMIN, + UserRole.CLIENT_USER, + ) + class User(Base): """Modelo de Usuario.""" @@ -113,11 +131,8 @@ class User(Base): @property def is_client(self) -> bool: - """Check if user is a client.""" - return self.role in [ - UserRole.CLIENT_ADMIN, - UserRole.CLIENT_USER - ] + """Check if user is a client (rol acotado al propio tenant).""" + return self.role.is_client @property def can_manage_users(self) -> bool: @@ -125,7 +140,7 @@ class User(Base): return self.role in [ UserRole.ADMIN, UserRole.SUPPORT_MANAGER, - UserRole.CLIENT_ADMIN + UserRole.CLIENT_ADMIN, ] @property @@ -134,7 +149,7 @@ class User(Base): return self.role in [ UserRole.ADMIN, UserRole.SUPPORT_MANAGER, - UserRole.AGENT + UserRole.AGENT, ] @property diff --git a/backend/app/services/ticket_service.py b/backend/app/services/ticket_service.py new file mode 100644 index 0000000..bb35da0 --- /dev/null +++ b/backend/app/services/ticket_service.py @@ -0,0 +1,170 @@ +""" +Ticket Service - ServiceManagerWeb + +Lógica de negocio para creación y gestión de tickets. +Inyectable vía Depends() en los endpoints de FastAPI. +""" + +import uuid +from datetime import datetime + +from fastapi import Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.models.ticket import Ticket, TicketStatus, TicketPriority +from app.models.category import Category +from app.models.system import System +from app.api.schemas.ticket import TicketCreate +from app.api.v1.helpers import ( + generate_next_ticket_number, + calculate_sla_deadlines, + safe_audit_log, +) + + +class TicketService: + """Servicio de tickets: encapsula lógica de negocio fuera del router.""" + + def __init__(self, db: AsyncSession = Depends(get_db)): + self.db = db + + async def create_ticket( + self, + ticket: TicketCreate, + tenant_id: uuid.UUID, + user_id: uuid.UUID, + ) -> dict: + """ + Crea un ticket con validación multi-tenant, cálculo de SLA y auto-asignación. + + Args: + ticket: Datos del ticket a crear. + tenant_id: Tenant del usuario autenticado. + user_id: ID del usuario que crea el ticket. + + Returns: + dict compatible con TicketResponse. + + Raises: + HTTPException 400: UUID inválido, categoría/sistema no encontrado o de otro tenant. + HTTPException 500: Fallo persistente tras max_retries. + """ + max_retries = 3 + last_error = None + + for attempt in range(max_retries): + try: + ticket_number = await generate_next_ticket_number(self.db, tenant_id) + category_uuid = uuid.UUID(ticket.category_id) if ticket.category_id else None + system_uuid = uuid.UUID(ticket.affected_system_id) if ticket.affected_system_id else None + + category = None + if category_uuid: + category = await self.db.get(Category, category_uuid) + if not category or category.tenant_id != tenant_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"La categoría con ID {ticket.category_id} no existe.", + ) + + if system_uuid: + system = await self.db.get(System, system_uuid) + if not system or system.tenant_id != tenant_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"El sistema con ID {ticket.affected_system_id} no existe.", + ) + + sla_response_due, sla_resolution_due = calculate_sla_deadlines(category) + assigned_to_user = category.auto_assign_to if category and category.auto_assign_to else None + + db_ticket = Ticket( + id=uuid.uuid4(), + tenant_id=tenant_id, + ticket_number=ticket_number, + subject=ticket.subject, + description=ticket.description, + category_id=category_uuid, + affected_system_id=system_uuid, + priority=TicketPriority[ticket.priority.upper()], + created_by=user_id, + assigned_to=assigned_to_user, + status=TicketStatus.NEW, + sla_response_due=sla_response_due, + sla_resolution_due=sla_resolution_due, + created_at=datetime.utcnow(), + updated_at=datetime.utcnow(), + ) + + self.db.add(db_ticket) + await self.db.commit() + await self.db.refresh(db_ticket) + + await safe_audit_log( + db=self.db, + tenant_id=tenant_id, + user_id=user_id, + action="ticket.create", + resource_type="ticket", + resource_id=db_ticket.id, + new_values={ + "ticket_number": db_ticket.ticket_number, + "subject": db_ticket.subject, + "priority": db_ticket.priority.value, + "status": db_ticket.status.value, + }, + ) + + return { + "id": str(db_ticket.id), + "ticket_number": db_ticket.ticket_number, + "subject": db_ticket.subject, + "title": db_ticket.subject, + "description": db_ticket.description, + "status": db_ticket.status.value, + "priority": db_ticket.priority.value, + "category_id": str(db_ticket.category_id) if db_ticket.category_id else None, + "affected_system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None, + "system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None, + "contact_email": ticket.contact_email, + "contact_phone": ticket.contact_phone, + "created_by": str(db_ticket.created_by), + "assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None, + "created_at": db_ticket.created_at, + "updated_at": db_ticket.updated_at, + "sla_response_due": db_ticket.sla_response_due, + "sla_resolution_due": db_ticket.sla_resolution_due, + "first_response_at": db_ticket.first_response_at, + "resolved_at": db_ticket.resolved_at, + } + + except ValueError as e: + await self.db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid UUID format: {str(e)}", + ) + except HTTPException: + await self.db.rollback() + raise + except Exception as e: + await self.db.rollback() + last_error = e + if "duplicate key" in str(e).lower() and "ticket_number" in str(e).lower(): + if attempt < max_retries - 1: + continue + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Error creating ticket: {str(e)}", + ) + + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"No se pudo crear el ticket después de {max_retries} intentos: {str(last_error)}", + ) + + +def get_ticket_service(db: AsyncSession = Depends(get_db)) -> TicketService: + """Factory function para inyectar TicketService vía Depends().""" + return TicketService(db) diff --git a/backend/app/tests/__init__.py b/backend/app/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/tests/conftest.py b/backend/app/tests/conftest.py new file mode 100644 index 0000000..a58c453 --- /dev/null +++ b/backend/app/tests/conftest.py @@ -0,0 +1,373 @@ +""" +Integration Test Fixtures - ServiceManagerWeb (Docker / PostgreSQL) + +backend/app/tests/conftest.py + +Usa la BD Docker existente (servicemanager). +Los fixtures leen datos reales ya seedeados — no crean ni eliminan nada. +Los tests que inserten datos propios quedan aislados por rollback. + +Tenant de referencia : aduanasoft +Usuarios de referencia: + admin@aduanasoft.com → ADMIN + manager@aduanasoft.com → SUPPORT_MANAGER + agente@aduanasoft.com → AGENT + auditor1@test.com → AUDITOR (tenant aduanasoft) + admin-cliente@empresa-demo → CLIENT_ADMIN + test_user@aduanasoft.com → CLIENT_USER +""" + +import os +import asyncio +import pytest +from typing import AsyncGenerator, Generator + +# ============================================================ +# ENV VARS — antes de importar la app +# ============================================================ +os.environ.setdefault("ENVIRONMENT", "testing") +os.environ.setdefault("TESTING", "true") +os.environ.setdefault("DEBUG", "false") +os.environ.setdefault("SECRET_KEY", "integration-secret-key-32chars!!!!") +os.environ.setdefault("JWT_SECRET_KEY", "integration-jwt-secret-32chars!!!!") +os.environ.setdefault( + "DATABASE_URL", + "postgresql+asyncpg://servicemanager:servicemanager123@localhost:5432/servicemanager", +) +os.environ.setdefault("REDIS_URL", "redis://localhost:6379/14") +os.environ.setdefault("CELERY_BROKER_URL", "redis://localhost:6379/14") +os.environ.setdefault("CELERY_RESULT_BACKEND", "redis://localhost:6379/14") +os.environ.setdefault("CORS_ORIGINS", "http://localhost:3000") +os.environ.setdefault("ALLOWED_FILE_EXTENSIONS", "pdf,jpg,jpeg,png,doc,docx,txt") + + +# ============================================================ +# EVENT LOOP (session-scoped) +# ============================================================ + +@pytest.fixture(scope="session") +def event_loop() -> Generator: + """Event loop compartido para toda la sesión de tests.""" + policy = asyncio.get_event_loop_policy() + loop = policy.new_event_loop() + yield loop + loop.close() + + +# ============================================================ +# ENGINE (session-scoped — reutiliza el pool toda la sesión) +# ============================================================ + +@pytest.fixture(scope="session") +async def engine(): + """ + Conecta al PostgreSQL Docker existente (servicemanager). + NO crea ni destruye el schema — la BD ya está lista. + """ + from sqlalchemy.ext.asyncio import create_async_engine + import app.models # noqa: F401 — registra todos los modelos + + _engine = create_async_engine(os.environ["DATABASE_URL"], echo=False) + yield _engine + await _engine.dispose() + + +# ============================================================ +# DB (function-scoped — rollback para datos creados en el test) +# ============================================================ + +@pytest.fixture +async def db(engine) -> AsyncGenerator: + """ + Sesión con transacción por test. + Los datos seedeados son visibles (ya están committed). + Cualquier INSERT hecho en el test se revierte al finalizar. + """ + from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + + factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + async with factory() as session: + await session.begin() + yield session + await session.rollback() + + +# ============================================================ +# TENANT (function-scoped — lee el registro existente) +# ============================================================ + +@pytest.fixture +async def tenant_a(db): + """Tenant 'aduanasoft' ya existente en la BD.""" + from sqlalchemy import select + from app.models.tenant import Tenant + + result = await db.execute(select(Tenant).where(Tenant.slug == "aduanasoft")) + return result.scalar_one() + + +# ============================================================ +# USUARIOS (function-scoped — leen registros existentes) +# ============================================================ + +@pytest.fixture +async def admin_user(db, tenant_a): + """ADMIN: admin@aduanasoft.com (tenant aduanasoft).""" + from sqlalchemy import select + from app.models.user import User + + result = await db.execute( + select(User) + .where(User.email == "admin@aduanasoft.com") + .where(User.tenant_id == tenant_a.id) + ) + return result.scalar_one() + + +@pytest.fixture +async def manager_user(db, tenant_a): + """SUPPORT_MANAGER: manager@aduanasoft.com (tenant aduanasoft).""" + from sqlalchemy import select + from app.models.user import User + + result = await db.execute( + select(User) + .where(User.email == "manager@aduanasoft.com") + .where(User.tenant_id == tenant_a.id) + ) + return result.scalar_one() + + +@pytest.fixture +async def agent_user(db, tenant_a): + """AGENT: agente@aduanasoft.com (tenant aduanasoft).""" + from sqlalchemy import select + from app.models.user import User + + result = await db.execute( + select(User) + .where(User.email == "agente@aduanasoft.com") + .where(User.tenant_id == tenant_a.id) + ) + return result.scalar_one() + + +@pytest.fixture +async def user_tenant_a(db, tenant_a): + """CLIENT_USER: test_user@aduanasoft.com (tenant aduanasoft).""" + from sqlalchemy import select + from app.models.user import User + + result = await db.execute( + select(User) + .where(User.email == "test_user@aduanasoft.com") + .where(User.tenant_id == tenant_a.id) + ) + return result.scalar_one() + + +# ============================================================ +# HTTP CLIENT (function-scoped) +# ============================================================ + +@pytest.fixture +async def client(db) -> AsyncGenerator: + """ + httpx.AsyncClient contra la app FastAPI en memoria (sin red). + get_db queda sobreescrito para inyectar la sesión de test. + Los cambios del test se revierten al terminar (rollback en db). + """ + import httpx + from httpx import ASGITransport + from app.main import app + from app.core.database import get_db + + async def _override_get_db(): + yield db + + app.dependency_overrides[get_db] = _override_get_db + + async with httpx.AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + ) as ac: + yield ac + + app.dependency_overrides.pop(get_db, None) + + +# ============================================================ +# FIXTURES DE AISLAMIENTO MULTI-TENANT +# ============================================================ + +@pytest.fixture +def make_token(): + """Factory de JWT tokens para autenticar clientes HTTP en tests.""" + from app.core.security import security + + def _make(user): + return security.create_access_token(data={"sub": str(user.id)}) + + return _make + + +@pytest.fixture +async def app_with_db(db): + """ + Override de get_db compartido para todos los HTTP clients de un mismo test. + Garantiza que todos los clients usen la misma sesión (y el mismo rollback). + """ + from app.main import app as _app + from app.core.database import get_db + + async def _override(): + yield db + + _app.dependency_overrides[get_db] = _override + yield _app + _app.dependency_overrides.pop(get_db, None) + + +@pytest.fixture +async def tenant_b(db): + """Tenant 'empresa-test' creado en la transacción del test (se revierte al final).""" + from app.models.tenant import Tenant + + t = Tenant(name="Empresa Test", slug="empresa-test") + db.add(t) + await db.flush() + return t + + +@pytest.fixture +async def user_b(db, tenant_b): + """CLIENT_ADMIN en tenant_b — puede gestionar recursos de su tenant.""" + from app.models.user import User, UserRole + from app.core.security import security + + u = User( + tenant_id=tenant_b.id, + email="admin@empresa-test.com", + first_name="Admin", + last_name="Test", + password_hash=security.hash_password("Test1234!"), + role=UserRole.CLIENT_ADMIN, + is_active=True, + email_verified=True, + ) + db.add(u) + await db.flush() + return u + + +@pytest.fixture +async def client_tenant_a(app_with_db, manager_user, make_token): + """HTTP client autenticado como SUPPORT_MANAGER de tenant_a (aduanasoft). + Usa manager_user en lugar de admin_user para mantener el aislamiento de + tenant en GET /users/ (el ADMIN global bypasa el filtro de tenant). + """ + import httpx + from httpx import ASGITransport + + token = make_token(manager_user) + async with httpx.AsyncClient( + transport=ASGITransport(app=app_with_db), + base_url="http://test", + headers={"Authorization": f"Bearer {token}"}, + ) as ac: + yield ac + + +@pytest.fixture +async def client_tenant_b(app_with_db, user_b, make_token): + """HTTP client autenticado como CLIENT_ADMIN de tenant_b (empresa-test).""" + import httpx + from httpx import ASGITransport + + token = make_token(user_b) + async with httpx.AsyncClient( + transport=ASGITransport(app=app_with_db), + base_url="http://test", + headers={"Authorization": f"Bearer {token}"}, + ) as ac: + yield ac + + +@pytest.fixture +async def client_admin(app_with_db, admin_user, make_token): + """HTTP client autenticado como ADMIN global.""" + import httpx + from httpx import ASGITransport + + token = make_token(admin_user) + async with httpx.AsyncClient( + transport=ASGITransport(app=app_with_db), + base_url="http://test", + headers={"Authorization": f"Bearer {token}"}, + ) as ac: + yield ac + + +@pytest.fixture +def create_ticket_tenant_a(client_tenant_a): + """Factory: crea un ticket en tenant_a vía HTTP y retorna el JSON de respuesta.""" + async def _create(subject="Ticket Tenant A", priority="MEDIUM"): + resp = await client_tenant_a.post("/v1/tickets/", json={ + "subject": subject, + "description": "Test de aislamiento tenant A", + "priority": priority, + }) + assert resp.status_code in (200, 201), f"Error creando ticket A: {resp.text}" + return resp.json() + + return _create + + +@pytest.fixture +def create_ticket_tenant_b(client_tenant_b): + """Factory: crea un ticket en tenant_b vía HTTP y retorna el JSON de respuesta.""" + async def _create(subject="Ticket Tenant B", priority="MEDIUM"): + resp = await client_tenant_b.post("/v1/tickets/", json={ + "subject": subject, + "description": "Test de aislamiento tenant B", + "priority": priority, + }) + assert resp.status_code in (200, 201), f"Error creando ticket B: {resp.text}" + return resp.json() + + return _create + + +@pytest.fixture +def create_user_tenant_a(client_tenant_a): + """Factory: crea un usuario en tenant_a vía HTTP y retorna el JSON de respuesta.""" + async def _create(email="nuevo_user_a@test.com"): + resp = await client_tenant_a.post("/v1/users/", json={ + "email": email, + "first_name": "Usuario", + "last_name": "TenantA", + "password": "Test1234!", + "role": "CLIENT_USER", + }) + assert resp.status_code in (200, 201), f"Error creando user A: {resp.text}" + return resp.json() + + return _create + + +@pytest.fixture +def create_user_tenant_b(client_tenant_b): + """Factory: crea un usuario en tenant_b vía HTTP y retorna el JSON de respuesta.""" + async def _create(email="nuevo_user_b@test.com"): + resp = await client_tenant_b.post("/v1/users/", json={ + "email": email, + "first_name": "Usuario", + "last_name": "TenantB", + "password": "Test1234!", + "role": "CLIENT_USER", + }) + assert resp.status_code in (200, 201), f"Error creando user B: {resp.text}" + return resp.json() + + return _create diff --git a/backend/app/tests/test_smoke.py b/backend/app/tests/test_smoke.py new file mode 100644 index 0000000..89bd74b --- /dev/null +++ b/backend/app/tests/test_smoke.py @@ -0,0 +1,137 @@ +""" +Smoke Tests - ServiceManagerWeb + +Verifican que el stack completo funciona: + - Conexión a BD Docker + - Fixtures de tenant y usuarios + - Login vía HTTP (httpx + FastAPI en memoria) + - Endpoint protegido con token +""" + +import pytest + + +# ============================================================ +# BD + FIXTURES +# ============================================================ + +@pytest.mark.asyncio +async def test_db_connected(db): + """La sesión de BD está activa y responde.""" + from sqlalchemy import text + result = await db.execute(text("SELECT 1")) + assert result.scalar() == 1 + + +@pytest.mark.asyncio +async def test_tenant_a_existe(tenant_a): + """El tenant 'aduanasoft' existe y tiene datos válidos.""" + assert tenant_a.slug == "aduanasoft" + assert tenant_a.name is not None + + +@pytest.mark.asyncio +async def test_admin_user_existe(admin_user): + """El usuario ADMIN existe y pertenece al tenant correcto.""" + from app.models.user import UserRole + assert admin_user.email == "admin@aduanasoft.com" + assert admin_user.role == UserRole.ADMIN + assert admin_user.is_active is True + + +@pytest.mark.asyncio +async def test_manager_user_existe(manager_user): + """El usuario SUPPORT_MANAGER existe.""" + from app.models.user import UserRole + assert manager_user.email == "manager@aduanasoft.com" + assert manager_user.role == UserRole.SUPPORT_MANAGER + + +@pytest.mark.asyncio +async def test_agent_user_existe(agent_user): + """El usuario AGENT existe.""" + from app.models.user import UserRole + assert agent_user.email == "agente@aduanasoft.com" + assert agent_user.role == UserRole.AGENT + + +@pytest.mark.asyncio +async def test_client_user_existe(user_tenant_a): + """El CLIENT_USER existe.""" + from app.models.user import UserRole + assert user_tenant_a.email == "test_user@aduanasoft.com" + assert user_tenant_a.role == UserRole.CLIENT_USER + + +# ============================================================ +# HTTP — LOGIN +# ============================================================ + +@pytest.mark.asyncio +async def test_login_admin_ok(client): + """Login con credenciales de admin devuelve access_token.""" + response = await client.post( + "/v1/auth/login", + json={ + "email": "admin@aduanasoft.com", + "password": "admin123", + "tenant_slug": "aduanasoft", + }, + ) + assert response.status_code == 200 + data = response.json() + assert "access_token" in data + assert data["token_type"] == "bearer" + + +@pytest.mark.asyncio +async def test_login_credenciales_invalidas(client): + """Login con contraseña incorrecta devuelve 401.""" + response = await client.post( + "/v1/auth/login", + json={ + "email": "admin@aduanasoft.com", + "password": "wrongpassword", + "tenant_slug": "aduanasoft", + }, + ) + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_endpoint_sin_token_devuelve_401(client): + """Acceder a un endpoint protegido sin token devuelve 401.""" + response = await client.get( + "/v1/users/me", + headers={"X-Tenant-Slug": "aduanasoft"}, + ) + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_login_y_me(client): + """Login exitoso → /users/me devuelve el usuario correcto.""" + # Login + login = await client.post( + "/v1/auth/login", + json={ + "email": "admin@aduanasoft.com", + "password": "admin123", + "tenant_slug": "aduanasoft", + }, + ) + assert login.status_code == 200 + token = login.json()["access_token"] + + # Endpoint protegido + me = await client.get( + "/v1/users/me", + headers={ + "Authorization": f"Bearer {token}", + "X-Tenant-Slug": "aduanasoft", + }, + ) + assert me.status_code == 200 + data = me.json() + assert data["email"] == "admin@aduanasoft.com" + assert data["role"] == "ADMIN" diff --git a/backend/app/tests/test_tenant_isolation.py b/backend/app/tests/test_tenant_isolation.py new file mode 100644 index 0000000..cff99cf --- /dev/null +++ b/backend/app/tests/test_tenant_isolation.py @@ -0,0 +1,123 @@ +""" +Pruebas de aislamiento multi-tenant para tickets y usuarios. +Usa solo los fixtures definidos en conftest.py. + +Roles en juego: + client_tenant_a → SUPPORT_MANAGER (aduanasoft) — restringido a su tenant + client_tenant_b → CLIENT_ADMIN (empresa-test) — restringido a su tenant + client_admin → ADMIN global (aduanasoft) — acceso a todos los tenants +""" +import pytest + + +@pytest.mark.asyncio +async def test_tenant_a_cannot_see_tenant_b_tickets( + client_tenant_a, create_ticket_tenant_b +): + """ + El usuario del tenant B crea un ticket. + El usuario del tenant A (SUPPORT_MANAGER) lista sus tickets. + El ticket de tenant B NO debe aparecer en la respuesta. + """ + # El usuario del tenant B crea un ticket + ticket_b = await create_ticket_tenant_b() + ticket_b_id = ticket_b["id"] + + # El usuario del tenant A lista sus tickets + response = await client_tenant_a.get("/v1/tickets/") + assert response.status_code == 200 + + ids_visibles = {t["id"] for t in response.json()} + + # El ticket de tenant B no debe ser visible para tenant A + assert ticket_b_id not in ids_visibles, ( + f"Fallo de aislamiento: ticket de tenant B ({ticket_b_id}) " + f"visible para usuario de tenant A" + ) + + +@pytest.mark.asyncio +async def test_tenant_b_cannot_edit_tenant_a_ticket( + create_ticket_tenant_a, client_tenant_b +): + """ + El usuario del tenant A crea un ticket. + El usuario del tenant B intenta editar ese ticket vía PATCH. + Debe recibir 403 (prohibido) o 404 (no encontrado). + """ + # El usuario del tenant A crea un ticket + ticket_a = await create_ticket_tenant_a() + ticket_a_id = ticket_a["id"] + + # El usuario del tenant B intenta editar el ticket de tenant A + response = await client_tenant_b.patch( + f"/v1/tickets/{ticket_a_id}", + json={"status": "CLOSED"}, + ) + + # Debe recibir 403 o 404 — nunca 200 + assert response.status_code in (403, 404), ( + f"Fallo de aislamiento: tenant B pudo editar ticket de tenant A " + f"(HTTP {response.status_code})" + ) + + +@pytest.mark.asyncio +async def test_tenant_a_cannot_see_tenant_b_users( + client_tenant_a, create_user_tenant_b +): + """ + El usuario del tenant B crea un usuario nuevo. + El usuario del tenant A (SUPPORT_MANAGER) lista los usuarios. + El usuario de tenant B NO debe aparecer en la respuesta. + """ + # El usuario del tenant B crea un usuario + user_b = await create_user_tenant_b() + user_b_id = user_b["id"] + + # El usuario del tenant A lista los usuarios de su tenant + response = await client_tenant_a.get("/v1/users/") + assert response.status_code == 200 + + ids_visibles = {u["id"] for u in response.json()} + + # El usuario de tenant B no debe ser visible para tenant A + assert user_b_id not in ids_visibles, ( + f"Fallo de aislamiento: usuario de tenant B ({user_b_id}) " + f"visible para usuario de tenant A" + ) + + +@pytest.mark.asyncio +async def test_admin_sees_all_tenant_data( + client_admin, + create_ticket_tenant_a, + create_ticket_tenant_b, + create_user_tenant_a, + create_user_tenant_b, +): + """ + El ADMIN global debe poder ver tickets y usuarios de TODOS los tenants. + - Tickets: vía /v1/tickets/admin/all (endpoint multi-tenant). + - Usuarios: vía /v1/users/ (ADMIN bypasa el filtro de tenant). + """ + # Crear datos en ambos tenants + ticket_a = await create_ticket_tenant_a() + ticket_b = await create_ticket_tenant_b() + user_a = await create_user_tenant_a() + user_b = await create_user_tenant_b() + + # El admin lista todos los tickets (endpoint multi-tenant) + resp_tickets = await client_admin.get("/v1/tickets/admin/all") + assert resp_tickets.status_code == 200 + ids_tickets = {t["id"] for t in resp_tickets.json()} + assert ticket_a["id"] in ids_tickets, "El ADMIN no ve el ticket de tenant A" + assert ticket_b["id"] in ids_tickets, "El ADMIN no ve el ticket de tenant B" + + # El admin lista todos los usuarios (ADMIN bypasa filtro de tenant) + resp_users = await client_admin.get("/v1/users/") + assert resp_users.status_code == 200 + ids_users = {u["id"] for u in resp_users.json()} + assert user_a["id"] in ids_users, "El ADMIN no ve el usuario de tenant A" + assert user_b["id"] in ids_users, "El ADMIN no ve el usuario de tenant B" + diff --git a/backend/migrations/versions/c1d2e3f4a5b6_add_client_roles_to_user_role_enum.py b/backend/migrations/versions/c1d2e3f4a5b6_add_client_roles_to_user_role_enum.py new file mode 100644 index 0000000..d861892 --- /dev/null +++ b/backend/migrations/versions/c1d2e3f4a5b6_add_client_roles_to_user_role_enum.py @@ -0,0 +1,35 @@ +"""Add CLIENT_MANAGER, CLIENT_AGENT, CLIENT_AUDITOR to user_role_enum + +Revision ID: c1d2e3f4a5b6 +Revises: b7c8d9e0f1a2 +Create Date: 2026-03-03 10:00:00.000000 + +Agrega tres nuevos roles de cliente al enum PostgreSQL: + - CLIENT_MANAGER → gestiona tickets y usuarios del tenant + - CLIENT_AGENT → atiende tickets del tenant + - CLIENT_AUDITOR → auditoría de solo lectura del tenant +""" +from alembic import op + + +# revision identifiers, used by Alembic. +revision = 'c1d2e3f4a5b6' +down_revision = 'b7c8d9e0f1a2' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # PostgreSQL permite agregar valores a un enum con ADD VALUE. + # IF NOT EXISTS evita error si la migración se aplica dos veces. + op.execute("ALTER TYPE user_role_enum ADD VALUE IF NOT EXISTS 'CLIENT_MANAGER'") + op.execute("ALTER TYPE user_role_enum ADD VALUE IF NOT EXISTS 'CLIENT_AGENT'") + op.execute("ALTER TYPE user_role_enum ADD VALUE IF NOT EXISTS 'CLIENT_AUDITOR'") + + +def downgrade() -> None: + # PostgreSQL no permite eliminar valores de un enum con ALTER TYPE DROP VALUE. + # Para revertir habría que recrear el tipo completo desde cero, lo que requiere + # actualizar todas las columnas que lo usan. Se documenta como no reversible + # automáticamente — usar con precaución. + pass diff --git a/backend/migrations/versions/d2e3f4a5b6c7_remove_intermediate_client_roles.py b/backend/migrations/versions/d2e3f4a5b6c7_remove_intermediate_client_roles.py new file mode 100644 index 0000000..1862aa8 --- /dev/null +++ b/backend/migrations/versions/d2e3f4a5b6c7_remove_intermediate_client_roles.py @@ -0,0 +1,92 @@ +"""Remove CLIENT_MANAGER, CLIENT_AGENT, CLIENT_AUDITOR from user_role_enum + +Revision ID: d2e3f4a5b6c7 +Revises: c1d2e3f4a5b6 +Create Date: 2026-03-03 14:00:00.000000 + +Consolida 9 roles → 6 roles migrando datos primero y luego recreando +el tipo enum de PostgreSQL (única forma de eliminar valores en PG). + +Mapeo de datos: + CLIENT_MANAGER → CLIENT_ADMIN (conserva nivel de gestión) + CLIENT_AGENT → CLIENT_USER (acceso básico de cliente) + CLIENT_AUDITOR → CLIENT_USER (acceso básico de cliente) + +ADVERTENCIA DOWNGRADE: La migración inversa restaura los valores del +enum pero NO puede recuperar la distinción original entre CLIENT_AGENT +y CLIENT_AUDITOR (ambos quedaron como CLIENT_USER). El downgrade es +seguro a nivel de integridad de datos, pero irreversible en semántica. +""" +from alembic import op + + +# revision identifiers, used by Alembic. +revision = 'd2e3f4a5b6c7' +down_revision = 'c1d2e3f4a5b6' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ── Paso 1: Migrar datos ANTES de modificar el tipo ──────────────────── + # CLIENT_MANAGER → CLIENT_ADMIN (conserva acceso de gestión) + op.execute("UPDATE users SET role = 'CLIENT_ADMIN' WHERE role = 'CLIENT_MANAGER'") + # CLIENT_AGENT → CLIENT_USER (acceso básico de cliente) + op.execute("UPDATE users SET role = 'CLIENT_USER' WHERE role = 'CLIENT_AGENT'") + # CLIENT_AUDITOR → CLIENT_USER (acceso básico de cliente) + op.execute("UPDATE users SET role = 'CLIENT_USER' WHERE role = 'CLIENT_AUDITOR'") + + # ── Paso 2: Soltar la restricción de tipo para poder recrear el enum ─── + # PostgreSQL no permite DROP VALUE en un enum; hay que recrear el tipo. + op.execute("ALTER TABLE users ALTER COLUMN role TYPE TEXT") + + # ── Paso 3: Eliminar tipo actual y recrearlo solo con los 6 roles ────── + op.execute("DROP TYPE user_role_enum") + op.execute(""" + CREATE TYPE user_role_enum AS ENUM ( + 'ADMIN', + 'SUPPORT_MANAGER', + 'AGENT', + 'AUDITOR', + 'CLIENT_ADMIN', + 'CLIENT_USER' + ) + """) + + # ── Paso 4: Restaurar columna al tipo enum ────────────────────────────── + op.execute( + "ALTER TABLE users ALTER COLUMN role TYPE user_role_enum " + "USING role::user_role_enum" + ) + + +def downgrade() -> None: + # ── Paso 1: Soltar la restricción de tipo para recrear el enum ───────── + op.execute("ALTER TABLE users ALTER COLUMN role TYPE TEXT") + + # ── Paso 2: Recrear enum con los 9 valores originales ────────────────── + op.execute("DROP TYPE user_role_enum") + op.execute(""" + CREATE TYPE user_role_enum AS ENUM ( + 'ADMIN', + 'SUPPORT_MANAGER', + 'AGENT', + 'AUDITOR', + 'CLIENT_ADMIN', + 'CLIENT_MANAGER', + 'CLIENT_AGENT', + 'CLIENT_AUDITOR', + 'CLIENT_USER' + ) + """) + + # ── Paso 3: Restaurar columna al tipo enum ────────────────────────────── + op.execute( + "ALTER TABLE users ALTER COLUMN role TYPE user_role_enum " + "USING role::user_role_enum" + ) + + # ── Nota sobre pérdida de datos ───────────────────────────────────────── + # Los usuarios que eran CLIENT_MANAGER ahora son CLIENT_ADMIN. + # Los usuarios que eran CLIENT_AGENT o CLIENT_AUDITOR ahora son CLIENT_USER. + # No es posible restaurar la distinción original automáticamente.