diff --git a/backend/.coverage b/backend/.coverage index 3d03f1a..9f4fb64 100644 Binary files a/backend/.coverage and b/backend/.coverage differ diff --git a/backend/README.md b/backend/README.md index dcbb26d..a8e81ac 100644 --- a/backend/README.md +++ b/backend/README.md @@ -56,6 +56,22 @@ backend/ - [x] TOTP 2FA implementation - [x] Validation con Pydantic v2 +### Rate limiting (login) + +El endpoint `/{API_VERSION}/auth/login` incluye rate limiting (best-effort) usando Redis: + +- Por IP: limita intentos totales por ventana +- Por identidad: limita por `(tenant_id, email)` por ventana + +Responde `429 Too Many Requests` con header `Retry-After`. + +Variables de entorno (ver `app/core/config.py`): + +- `RATE_LIMIT_ENABLED` (default: `true`) +- `LOGIN_RATE_LIMIT_WINDOW_SECONDS` (default: `300`) +- `LOGIN_RATE_LIMIT_IP_MAX_ATTEMPTS` (default: `30`) +- `LOGIN_RATE_LIMIT_ID_MAX_ATTEMPTS` (default: `10`) + ## Quick Start ```bash @@ -157,8 +173,8 @@ Ver `.env.example` para todas las variables disponibles. - [x] CORS restrictivo - [x] Input validation con Pydantic - [x] SQL injection protection (SQLAlchemy) -- [x] Rate limiting (TODO: implementar) -- [x] File upload validation (TODO: implementar) +- [x] Rate limiting (login) +- [x] File upload validation (extensión + firma básica + tamaño + streaming) - [x] XSS protection (headers en nginx) ## Próximos pasos diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 2e8ff7a..88074ba 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -1,4 +1,5 @@ from fastapi import Depends, HTTPException, status +from starlette.requests import Request from fastapi.security import OAuth2PasswordBearer from jose import jwt, JWTError from sqlalchemy.ext.asyncio import AsyncSession @@ -17,6 +18,7 @@ settings = get_settings() oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"/{settings.API_VERSION}/auth/login") async def get_current_user( + request: Request, token: str = Depends(oauth2_scheme), db: AsyncSession = Depends(get_db) ) -> User: @@ -43,6 +45,15 @@ async def get_current_user( if not user.is_active: 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. + request_tenant_id = getattr(getattr(request, "state", None), "tenant_id", None) + if request_tenant_id 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", + ) return user diff --git a/backend/app/api/schemas/ticket.py b/backend/app/api/schemas/ticket.py index df66add..fe3cce6 100644 --- a/backend/app/api/schemas/ticket.py +++ b/backend/app/api/schemas/ticket.py @@ -4,8 +4,8 @@ Ticket Schemas - ServiceManagerWeb Pydantic schemas para gestión de tickets y comentarios. """ -from pydantic import BaseModel, ConfigDict -from typing import Optional +from pydantic import BaseModel, ConfigDict, model_validator +from typing import Optional, Literal from datetime import datetime @@ -15,7 +15,23 @@ class TicketCreate(BaseModel): description: str category_id: Optional[str] = None affected_system_id: Optional[str] = None - priority: str = "MEDIUM" + priority: Literal["LOW", "MEDIUM", "HIGH", "URGENT"] = "MEDIUM" + contact_email: Optional[str] = None + contact_phone: Optional[str] = None + + @model_validator(mode="before") + @classmethod + def _accept_legacy_fields(cls, data): + if not isinstance(data, dict): + return data + + if "subject" not in data and "title" in data: + data["subject"] = data["title"] + + if "affected_system_id" not in data and "system_id" in data: + data["affected_system_id"] = data["system_id"] + + return data class TicketUpdate(BaseModel): @@ -39,9 +55,15 @@ class TicketResponse(BaseModel): status: str priority: str category_id: Optional[str] = None + category_name: Optional[str] = None affected_system_id: Optional[str] = None + system_id: Optional[str] = None + affected_system_name: Optional[str] = None + contact_email: Optional[str] = None + contact_phone: Optional[str] = None created_by: str assigned_to: Optional[str] = None + assigned_to_name: Optional[str] = None created_at: datetime updated_at: datetime sla_response_due: Optional[datetime] = None diff --git a/backend/app/api/v1/endpoints/audit.py b/backend/app/api/v1/endpoints/audit.py index d713dfd..5567c3d 100644 --- a/backend/app/api/v1/endpoints/audit.py +++ b/backend/app/api/v1/endpoints/audit.py @@ -208,7 +208,7 @@ async def get_security_analysis(all_tenants: bool = Query(False), current_user: recommended_actions.append("Continuar monitoreando actividad del sistema") # Calcular IPs sospechosas (más de 5 intentos fallidos) - suspicious_ips = len(set([log.ip_address for log in logs if log.ip_address and log.action == 'auth.login.failed'])) + suspicious_ips = len(set([log.ip_address for log in logs if log.ip_address and log.action == 'user.login_failed'])) # Contar acciones críticas (delete, privilege changes, etc) critical_actions = mass_deletions + privilege_changes diff --git a/backend/app/api/v1/endpoints/auth.py b/backend/app/api/v1/endpoints/auth.py index 3755992..6ac425c 100644 --- a/backend/app/api/v1/endpoints/auth.py +++ b/backend/app/api/v1/endpoints/auth.py @@ -4,7 +4,7 @@ Authentication Endpoints - ServiceManagerWeb Endpoints para autenticación y autorización """ -from fastapi import APIRouter, HTTPException, status, Depends +from fastapi import APIRouter, HTTPException, status, Depends, Request from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select @@ -18,7 +18,9 @@ from app.core.config import get_settings from app.models.user import User from app.models.tenant import Tenant from app.services.audit_service import AuditService +from app.services.token_service import TokenService from app.api.deps import oauth2_scheme, get_current_user +from app.core.cache import cache, cache_key from app.api.schemas.auth import ( LoginRequest, LoginResponse, RefreshTokenRequest, TokenResponse, TwoFactorStatusResponse, TwoFactorSetupResponse, @@ -38,6 +40,7 @@ settings = get_settings() @router.post("/login", response_model=LoginResponse) async def login( login_data: LoginRequest, + request: Request, db: AsyncSession = Depends(get_db) ): """ @@ -58,13 +61,85 @@ async def login( email=login_data.email, tenant_slug=login_data.tenant_slug ) + + # Rate limiting (best-effort): by IP before any tenant/user lookup. + if settings.RATE_LIMIT_ENABLED and not settings.TESTING: + client_ip = request.client.host if request.client else "unknown" + ip_key = cache_key("rl", "login", "ip", client_ip) + ip_count = await cache.incr(ip_key, 1) + if ip_count == 1: + await cache.expire(ip_key, settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS) + + if ip_count is not None and ip_count > settings.LOGIN_RATE_LIMIT_IP_MAX_ATTEMPTS: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Too many login attempts. Try again later.", + headers={"Retry-After": str(settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)}, + ) - # 1. Buscar usuario en base de datos - query = select(User).where(User.email == login_data.email) + # 1. Validar tenant + tenant_result = await db.execute( + select(Tenant).where(Tenant.slug == login_data.tenant_slug) + ) + tenant = tenant_result.scalar_one_or_none() + if tenant is None: + logger.warning( + "Login failed - tenant not found", + email=login_data.email, + tenant_slug=login_data.tenant_slug, + ) + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tenant not found", + ) + + # Rate limiting (best-effort): by (tenant,email) to slow brute force. + ident_key = None + if settings.RATE_LIMIT_ENABLED and not settings.TESTING: + email_norm = login_data.email.strip().lower() + ident_key = cache_key("rl", "login", "id", str(tenant.id), email_norm) + ident_count = await cache.incr(ident_key, 1) + if ident_count == 1: + await cache.expire(ident_key, settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS) + + if ident_count is not None and ident_count > settings.LOGIN_RATE_LIMIT_ID_MAX_ATTEMPTS: + try: + await AuditService.log( + db=db, + tenant_id=tenant.id, + user_id=None, + action="user.login_rate_limited", + resource_type="user", + resource_id=None, + metadata={ + "email": email_norm, + "tenant_slug": login_data.tenant_slug, + "ip": request.client.host if request.client else None, + "scope": "tenant_email", + "window_seconds": settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS, + "max_attempts": settings.LOGIN_RATE_LIMIT_ID_MAX_ATTEMPTS, + }, + request=request, + ) + await db.commit() + except Exception as e: + logger.warning("Failed to log rate limit audit entry", error=str(e)) + + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Too many login attempts. Try again later.", + headers={"Retry-After": str(settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)}, + ) + + # 2. Buscar usuario en base de datos (aislado por tenant) + query = select(User).where( + User.email == login_data.email, + User.tenant_id == tenant.id, + ) result = await db.execute(query) user = result.scalar_one_or_none() - # 2. Verificar usuario y contraseña + # 3. Verificar usuario y contraseña if not user or not security.verify_password(login_data.password, user.password_hash): logger.warning( "Login failed - invalid credentials", @@ -89,21 +164,21 @@ async def login( raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Credenciales inválidas" + detail="Invalid credentials", ) - # 3. Verificar si está activo + # 4. Verificar si está activo if not user.is_active: logger.warning( "Login failed - user inactive", email=login_data.email ) raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Usuario inactivo" + status_code=status.HTTP_403_FORBIDDEN, + detail="User inactive", ) - # 4. Verificar 2FA si está habilitado + # 5. Verificar 2FA si está habilitado if user.totp_enabled: if not login_data.totp_code: # Indicar al frontend que debe pedir el código TOTP @@ -128,6 +203,24 @@ async def login( access_token = security.create_access_token(token_data) refresh_token = security.create_refresh_token(token_data) + + # Persist refresh token so it can be revoked/validated later + try: + await TokenService.create_refresh_token( + db=db, + user=user, + refresh_token=refresh_token, + user_agent=request.headers.get("user-agent"), + ip_address=request.client.host if request.client else None, + ) + await db.commit() + except Exception as e: + # If persistence fails, do not leak tokens + logger.error("Failed to persist refresh token", error=str(e), user_id=str(user.id)) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Service temporarily unavailable", + ) # Registrar login exitoso en auditoría try: @@ -150,6 +243,10 @@ async def login( tenant_slug=login_data.tenant_slug, user_id=str(user.id) ) + + # Best-effort: clear per-identity limiter on success. + if ident_key: + await cache.delete(ident_key) return LoginResponse( access_token=access_token, @@ -198,7 +295,20 @@ async def refresh_token( detail="Invalid refresh token" ) - # TODO: Check if refresh token exists in database and is not revoked + # Check token exists in database and is not revoked/expired + db_token = await TokenService.verify_refresh_token(db=db, refresh_token=refresh_data.refresh_token) + if db_token is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid refresh token", + ) + + # Defensive: ensure DB token belongs to same subject + if str(db_token.user_id) != str(payload.get("sub")): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid refresh token", + ) # Create new access token token_data = { @@ -243,7 +353,19 @@ async def logout( detail="Invalid token" ) - # TODO: Revoke refresh token in database + # Revoke all active refresh tokens for this user (logout invalidates refresh) + try: + import uuid + + user_id = uuid.UUID(payload["sub"]) + await TokenService.revoke_all_user_tokens( + db=db, + user_id=user_id, + revoked_by_user_id=user_id, + ) + await db.commit() + except Exception as e: + logger.warning("Failed to revoke refresh tokens on logout", error=str(e)) # Registrar logout en auditoría try: diff --git a/backend/app/api/v1/endpoints/tickets.py b/backend/app/api/v1/endpoints/tickets.py index fa5a7be..a06fdfe 100644 --- a/backend/app/api/v1/endpoints/tickets.py +++ b/backend/app/api/v1/endpoints/tickets.py @@ -80,8 +80,15 @@ async def create_ticket(ticket: TicketCreate, db: AsyncSession = Depends(get_db) "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 + "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: @@ -111,35 +118,36 @@ async def get_tickets(skip: int = 0, limit: int = 100, status: Optional[str] = N query = apply_enum_filter(query, Ticket.status, status, TicketStatus, "status") query = apply_enum_filter(query, Ticket.priority, priority, TicketPriority, "priority") + query = query.options( + selectinload(Ticket.category), + selectinload(Ticket.affected_system), + selectinload(Ticket.assigned_to_user) + ) query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit) result = await db.execute(query) tickets = result.scalars().all() - return [ - {"id": str(t.id), "ticket_number": t.ticket_number, "subject": t.subject, "title": t.subject, - "description": t.description, "status": t.status.value, "priority": t.priority.value, - "category_id": str(t.category_id) if t.category_id else None, - "affected_system_id": str(t.affected_system_id) if t.affected_system_id else None, - "created_by": str(t.created_by), "assigned_to": str(t.assigned_to) if t.assigned_to else None, - "created_at": t.created_at, "updated_at": t.updated_at, "sla_response_due": t.sla_response_due, - "sla_resolution_due": t.sla_resolution_due, "first_response_at": t.first_response_at, "resolved_at": t.resolved_at} - for t in tickets - ] + return [ticket_to_dict(t) for t in tickets] @router.get("/admin/all", response_model=List[dict]) async def get_all_tickets_admin(skip: int = 0, limit: int = 100, status_filter: Optional[str] = None, priority_filter: Optional[str] = None, tenant_id_filter: Optional[str] = None, category_filter: Optional[str] = None, 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 de todos los tenants (solo para administradores)""" + """Obtener todos los tickets del tenant del administrador (ADMIN/SUPPORT_MANAGER).""" if current_user.role not in ["ADMIN", "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": + query = query.where(Ticket.tenant_id == current_user.tenant_id) + query = apply_enum_filter(query, Ticket.status, status_filter, TicketStatus, "status") - query = apply_enum_filter(query, Ticket.priority, priority_filter, TicketPriority, "priority") + query = apply_enum_filter(query, Ticket.priority, priority_filter, TicketPriority, "priority") if tenant_id_filter: query = query.where(Ticket.tenant_id == validate_uuid_param(tenant_id_filter, "tenant ID")) if category_filter: @@ -166,10 +174,20 @@ async def get_all_tickets_admin(skip: int = 0, limit: int = 100, status_filter: result = await db.execute(query) rows = result.all() + # Cargar categorías en un solo query para evitar N+1 + category_ids = list({ticket.category_id for ticket, _, _ in rows if ticket.category_id}) + categories_map = {} + if category_ids: + from app.models.category import Category as CategoryModel + cat_result = await db.execute(select(CategoryModel).where(CategoryModel.id.in_(category_ids))) + categories_map = {c.id: c.name for c in cat_result.scalars().all()} + return [ {"id": str(ticket.id), "ticket_number": ticket.ticket_number, "subject": ticket.subject, "description": ticket.description, "status": ticket.status.value, "priority": ticket.priority.value, "tenant_id": str(ticket.tenant_id), "tenant_name": tenant.name, "tenant_slug": tenant.slug, + "category_id": str(ticket.category_id) if ticket.category_id else None, + "category_name": categories_map.get(ticket.category_id) if ticket.category_id else None, "created_by": str(ticket.created_by), "creator_name": f"{creator.first_name} {creator.last_name}", "creator_email": creator.email, "assigned_to": str(ticket.assigned_to) if ticket.assigned_to else None, "created_at": ticket.created_at, "updated_at": ticket.updated_at, "sla_response_due": ticket.sla_response_due, @@ -358,6 +376,9 @@ async def get_ticket_attachments(ticket_id: str, db: AsyncSession = Depends(get_ if not ticket: raise HTTPException(status_code=404, detail="Ticket no encontrado") + + if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"] and ticket.created_by != current_user.id: + raise HTTPException(status_code=404, detail="Ticket no encontrado") result = await db.execute(select(TicketAttachment).where(TicketAttachment.ticket_id == ticket_uuid).options(selectinload(TicketAttachment.uploaded_by_user)).order_by(TicketAttachment.created_at.desc())) attachments = result.scalars().all() @@ -382,6 +403,9 @@ async def upload_attachment(ticket_id: str, file: UploadFile = File(...), db: As if not ticket: raise HTTPException(status_code=404, detail="Ticket no encontrado") + + if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"] and ticket.created_by != current_user.id: + raise HTTPException(status_code=404, detail="Ticket no encontrado") file_metadata = await file_handler.save_upload(file, current_tenant.id, ticket_uuid) @@ -425,6 +449,9 @@ async def download_attachment(ticket_id: str, attachment_id: str, db: AsyncSessi if not ticket: logger.error(f"Ticket not found - ticket_id: {ticket_id}") raise HTTPException(status_code=404, detail="Ticket no encontrado") + + if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"] and ticket.created_by != current_user.id: + raise HTTPException(status_code=404, detail="Ticket no encontrado") result = await db.execute(select(TicketAttachment).where(TicketAttachment.id == attachment_uuid, TicketAttachment.ticket_id == ticket_uuid)) attachment = result.scalar_one_or_none() diff --git a/backend/app/api/v1/endpoints/users.py b/backend/app/api/v1/endpoints/users.py index d36ccec..22caf00 100644 --- a/backend/app/api/v1/endpoints/users.py +++ b/backend/app/api/v1/endpoints/users.py @@ -19,6 +19,14 @@ router = APIRouter() # ENDPOINTS # =================================== + +@router.get("/me", response_model=UserResponse) +async def read_current_user( + current_user: User = Depends(deps.get_current_user), +): + """Obtener el perfil del usuario actual.""" + return current_user + @router.get("/", response_model=List[UserResponse]) async def read_users( skip: int = 0, diff --git a/backend/app/api/v1/helpers.py b/backend/app/api/v1/helpers.py index fba8f81..ed7f8d5 100644 --- a/backend/app/api/v1/helpers.py +++ b/backend/app/api/v1/helpers.py @@ -100,7 +100,10 @@ def ticket_to_dict(ticket: Ticket) -> dict: "category_id": str(ticket.category_id) if ticket.category_id else None, "category_name": ticket.category.name if ticket.category else None, "affected_system_id": str(ticket.affected_system_id) if ticket.affected_system_id else None, + "system_id": str(ticket.affected_system_id) if ticket.affected_system_id else None, "affected_system_name": ticket.affected_system.name if ticket.affected_system else None, + "contact_email": None, + "contact_phone": None, "created_by": str(ticket.created_by), "assigned_to": str(ticket.assigned_to) if ticket.assigned_to else None, "assigned_to_name": f"{ticket.assigned_to_user.first_name} {ticket.assigned_to_user.last_name}" if ticket.assigned_to_user else None, diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 576ada2..f971989 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -24,6 +24,7 @@ class Settings(BaseSettings): # GENERAL # =================================== ENVIRONMENT: str = Field(default="development") + TESTING: bool = Field(default=False) DEBUG: bool = Field(default=False) SECRET_KEY: str = Field(...) API_VERSION: str = Field(default="v1") @@ -86,6 +87,9 @@ class Settings(BaseSettings): # SECURITY # =================================== RATE_LIMIT_ENABLED: bool = Field(default=True) + LOGIN_RATE_LIMIT_WINDOW_SECONDS: int = Field(default=300) + LOGIN_RATE_LIMIT_IP_MAX_ATTEMPTS: int = Field(default=30) + LOGIN_RATE_LIMIT_ID_MAX_ATTEMPTS: int = Field(default=10) PASSWORD_MIN_LENGTH: int = Field(default=8) # Argon2 settings diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 6b36c7c..70afba5 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -6,7 +6,9 @@ SQLAlchemy 2.0 async setup con PostgreSQL from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column -from sqlalchemy import String, DateTime, func +from sqlalchemy import String, DateTime, func, text +from sqlalchemy.types import TypeDecorator, CHAR +from sqlalchemy.dialects.postgresql import UUID as PG_UUID from typing import AsyncGenerator import uuid from datetime import datetime @@ -34,18 +36,46 @@ AsyncSessionLocal = async_sessionmaker( autoflush=True, autocommit=False ) +class GUID(TypeDecorator): + """UUID portable: UUID nativo en Postgres, CHAR(36) en otros dialectos (SQLite para tests).""" + + impl = CHAR + cache_ok = True + + def load_dialect_impl(self, dialect): + if dialect.name == "postgresql": + return dialect.type_descriptor(PG_UUID(as_uuid=True)) + return dialect.type_descriptor(CHAR(36)) + + def process_bind_param(self, value, dialect): + if value is None: + return None + + if dialect.name == "postgresql": + return value + + if isinstance(value, uuid.UUID): + return str(value) + return str(uuid.UUID(str(value))) + + def process_result_value(self, value, dialect): + if value is None: + return None + if isinstance(value, uuid.UUID): + return value + return uuid.UUID(str(value)) class Base(DeclarativeBase): """Base class para todos los modelos SQLAlchemy.""" - + # Columnas comunes para auditoría - id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), + DateTime(timezone=True), server_default=func.now(), - onupdate=func.now() + onupdate=func.now(), ) @@ -89,7 +119,7 @@ async def check_database_health() -> bool: """ try: async with AsyncSessionLocal() as session: - await session.execute("SELECT 1") + await session.execute(text("SELECT 1")) return True except Exception: return False \ No newline at end of file diff --git a/backend/app/core/file_handler.py b/backend/app/core/file_handler.py index cd71c77..ccfd5c3 100644 --- a/backend/app/core/file_handler.py +++ b/backend/app/core/file_handler.py @@ -16,6 +16,8 @@ settings = get_settings() class FileHandler: """Handler simple para archivos adjuntos""" + + _CHUNK_SIZE_BYTES = 1024 * 1024 # 1MB def __init__(self): self.upload_path = Path(settings.UPLOAD_PATH) @@ -24,8 +26,8 @@ class FileHandler: # Crear directorio si no existe self.upload_path.mkdir(parents=True, exist_ok=True) - def _validate_file(self, filename: str, file_size: int) -> None: - """Validar archivo""" + def _validate_extension(self, filename: str) -> str: + """Validar extensión del archivo y retornarla.""" extension = Path(filename).suffix.lower().lstrip('.') if extension not in self.allowed_extensions: @@ -33,32 +35,52 @@ class FileHandler: status_code=status.HTTP_400_BAD_REQUEST, detail=f"Extensión no permitida: {extension}" ) - - if file_size > self.max_size_bytes: + + return extension + + def _validate_magic_bytes(self, extension: str, first_bytes: bytes) -> None: + """Validación básica por firma (magic bytes) para tipos comunes.""" + + signatures = { + # PDFs start with %PDF- + "pdf": [b"%PDF-"], + # PNG signature + "png": [b"\x89PNG\r\n\x1a\n"], + # JPEG starts with FF D8 FF + "jpg": [b"\xff\xd8\xff"], + "jpeg": [b"\xff\xd8\xff"], + # Legacy MS Office (OLE Compound File) + "doc": [b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"], + "xls": [b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"], + # OOXML (zip-based) + "docx": [b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08"], + "xlsx": [b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08"], + } + + # For plain text, we can't reliably validate via magic bytes. + if extension == "txt": + return + + allowed = signatures.get(extension) + if not allowed: + return + + if not any(first_bytes.startswith(sig) for sig in allowed): raise HTTPException( - status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, - detail=f"Archivo muy grande. Máximo: {settings.MAX_UPLOAD_SIZE_MB}MB" + status_code=status.HTTP_400_BAD_REQUEST, + detail="Contenido de archivo no coincide con la extensión declarada", ) - def _calculate_checksums(self, content: bytes) -> Tuple[str, str]: - """Calcular MD5 y SHA256""" - return hashlib.md5(content).hexdigest(), hashlib.sha256(content).hexdigest() - async def save_upload(self, file: UploadFile, tenant_id: uuid.UUID, ticket_id: uuid.UUID) -> dict: """Guardar archivo y retornar metadata""" if not file.filename: raise HTTPException(status_code=400, detail="Filename requerido") - - content = await file.read() - file_size = len(content) - - self._validate_file(file.filename, file_size) - - md5_hash, sha256_hash = self._calculate_checksums(content) - + + extension = self._validate_extension(file.filename) + # Nombre único - extension = Path(file.filename).suffix.lower() - safe_filename = f"{uuid.uuid4().hex}{extension}" + original_extension = Path(file.filename).suffix.lower() + safe_filename = f"{uuid.uuid4().hex}{original_extension}" # Estructura: uploads/tenant_id/tickets/ticket_id/ file_directory = self.upload_path / str(tenant_id) / "tickets" / str(ticket_id) @@ -66,10 +88,61 @@ class FileHandler: file_path = file_directory / safe_filename relative_path = str(file_path.relative_to(self.upload_path)) - - # Guardar archivo - with open(file_path, "wb") as f: - f.write(content) + + # Guardar archivo (streaming) + checksums incrementales + md5 = hashlib.md5() + sha256 = hashlib.sha256() + file_size = 0 + validated_magic = False + first_bytes: bytes = b"" + + try: + with open(file_path, "wb") as f: + while True: + chunk = await file.read(self._CHUNK_SIZE_BYTES) + if not chunk: + break + + if not validated_magic: + first_bytes = chunk[:16] + self._validate_magic_bytes(extension, first_bytes) + validated_magic = True + + file_size += len(chunk) + if file_size > self.max_size_bytes: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail=f"Archivo muy grande. Máximo: {settings.MAX_UPLOAD_SIZE_MB}MB", + ) + + md5.update(chunk) + sha256.update(chunk) + f.write(chunk) + + if file_size == 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Archivo vacío", + ) + + except HTTPException: + # Eliminar archivo parcial si existe + try: + if file_path.exists(): + file_path.unlink() + except Exception: + pass + raise + except Exception as exc: + try: + if file_path.exists(): + file_path.unlink() + except Exception: + pass + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error guardando archivo: {exc}", + ) import mimetypes mime_type = mimetypes.guess_type(file.filename)[0] or "application/octet-stream" @@ -80,8 +153,8 @@ class FileHandler: "file_path": relative_path, "file_size": file_size, "mime_type": mime_type, - "md5_hash": md5_hash, - "sha256_hash": sha256_hash + "md5_hash": md5.hexdigest(), + "sha256_hash": sha256.hexdigest(), } def get_file_path(self, relative_path: str) -> Path: diff --git a/backend/app/core/security.py b/backend/app/core/security.py index a6c4dd4..4147a3f 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -13,6 +13,7 @@ import pyotp import secrets import base64 import struct +import uuid from app.core.config import get_settings @@ -100,7 +101,8 @@ class SecurityUtils: """ to_encode = data.copy() expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS) - to_encode.update({"exp": expire, "type": "refresh"}) + # Add a unique identifier so refresh tokens are never deterministic. + to_encode.update({"exp": expire, "type": "refresh", "jti": str(uuid.uuid4())}) encoded_jwt = jwt.encode( to_encode, diff --git a/backend/app/main.py b/backend/app/main.py index 2b6d53d..f84d798 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -76,12 +76,26 @@ app = FastAPI( # CORS cors_origins = settings.CORS_ORIGINS.split(",") if isinstance(settings.CORS_ORIGINS, str) else settings.CORS_ORIGINS + +if settings.is_production(): + cors_allow_methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] + cors_allow_headers = [ + "Authorization", + "Content-Type", + "X-Tenant-ID", + "X-Tenant-Slug", + "X-Correlation-ID", + ] +else: + cors_allow_methods = ["*"] + cors_allow_headers = ["*"] + app.add_middleware( CORSMiddleware, allow_origins=cors_origins, allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + allow_methods=cors_allow_methods, + allow_headers=cors_allow_headers, ) # Compression diff --git a/backend/app/middleware/tenant.py b/backend/app/middleware/tenant.py index 5ea9697..78de68e 100644 --- a/backend/app/middleware/tenant.py +++ b/backend/app/middleware/tenant.py @@ -9,8 +9,9 @@ from starlette.requests import Request from starlette.responses import Response, JSONResponse from sqlalchemy import select import structlog +import uuid -from app.core.database import AsyncSessionLocal +from app.core.database import AsyncSessionLocal, get_db from app.core.config import get_settings from app.models.tenant import Tenant, TenantStatus @@ -30,11 +31,17 @@ class TenantMiddleware(BaseHTTPMiddleware): # Rutas que no requieren tenant EXCLUDED_PATHS = { "/health", + "/api/v1/health", + "/v1/health", + "/api/v1/health/detailed", + "/v1/health/detailed", "/", "/api/v1/auth/login", "/v1/auth/login", "/api/v1/auth/refresh", "/v1/auth/refresh", + "/api/v1/auth/logout", + "/v1/auth/logout", "/api/v1/auth/forgot-password", "/v1/auth/forgot-password", "/api/v1/auth/reset-password", @@ -66,33 +73,58 @@ class TenantMiddleware(BaseHTTPMiddleware): tenant_id = request.headers.get("X-Tenant-ID") tenant_slug = request.headers.get("X-Tenant-Slug") - # Si no hay headers de tenant - if not tenant_id and not tenant_slug: - if settings.ENVIRONMENT == "production": + tenant_uuid: uuid.UUID | None = None + if tenant_id: + try: + tenant_uuid = uuid.UUID(tenant_id) + except ValueError: return JSONResponse( status_code=400, - content={"detail": "Tenant information required (X-Tenant-ID or X-Tenant-Slug header)"} + content={"detail": "Invalid X-Tenant-ID header (must be UUID)"}, ) - # En desarrollo, continuar sin tenant con advertencia - logger.warning( - "Request without tenant information", - path=request.url.path, - method=request.method, + + # Si no hay headers de tenant (requerido para aislamiento multi-tenant) + if not tenant_id and not tenant_slug: + return JSONResponse( + status_code=400, + content={"detail": "Tenant information required (X-Tenant-ID or X-Tenant-Slug header)"}, ) - return await call_next(request) # Validar tenant contra la base de datos try: - async with AsyncSessionLocal() as session: - if tenant_id: - result = await session.execute( - select(Tenant).where(Tenant.id == tenant_id) - ) - else: - result = await session.execute( - select(Tenant).where(Tenant.slug == tenant_slug) - ) - tenant = result.scalars().first() + # Prefer DB session coming from dependency overrides (tests) when available. + # Guard: in unit tests request.app may be a MagicMock, not a real FastAPI app. + dependency_overrides = getattr(request.app, "dependency_overrides", None) + override_get_db = None + if isinstance(dependency_overrides, dict): + override_get_db = dependency_overrides.get(get_db) + + if override_get_db is not None: + agen = override_get_db() + session = await agen.__anext__() + try: + if tenant_uuid is not None: + result = await session.execute( + select(Tenant).where(Tenant.id == tenant_uuid) + ) + else: + result = await session.execute( + select(Tenant).where(Tenant.slug == tenant_slug) + ) + tenant = result.scalars().first() + finally: + await agen.aclose() + else: + async with AsyncSessionLocal() as session: + if tenant_uuid is not None: + result = await session.execute( + select(Tenant).where(Tenant.id == tenant_uuid) + ) + else: + result = await session.execute( + select(Tenant).where(Tenant.slug == tenant_slug) + ) + tenant = result.scalars().first() if tenant is None: logger.warning( diff --git a/backend/app/models/attachment.py b/backend/app/models/attachment.py index 95602b0..42de114 100644 --- a/backend/app/models/attachment.py +++ b/backend/app/models/attachment.py @@ -3,12 +3,11 @@ Attachment Model - ServiceManagerWeb """ from sqlalchemy import String, ForeignKey, Integer, DateTime, func from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.dialects.postgresql import UUID from typing import Optional, TYPE_CHECKING from datetime import datetime import uuid -from app.core.database import Base +from app.core.database import Base, GUID if TYPE_CHECKING: from app.models.ticket import Ticket @@ -21,24 +20,24 @@ class TicketAttachment(Base): __tablename__ = "ticket_attachments" # Sobrescribir campos heredados de Base para que coincidan con la tabla real - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) # Esta tabla NO tiene updated_at, así que lo excluimos del mapping ticket_id: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), + GUID(), ForeignKey("tickets.id", ondelete="CASCADE"), nullable=False ) comment_id: Mapped[Optional[uuid.UUID]] = mapped_column( - UUID(as_uuid=True), + GUID(), ForeignKey("ticket_comments.id", ondelete="CASCADE"), nullable=True ) uploaded_by: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), + GUID(), ForeignKey("users.id"), nullable=False ) diff --git a/backend/app/models/audit.py b/backend/app/models/audit.py index d3e05d2..fbf36ff 100644 --- a/backend/app/models/audit.py +++ b/backend/app/models/audit.py @@ -5,14 +5,14 @@ Modelo para bit├ícora de auditor├¡a y compliance. Registra todas las acciones importantes del sistema. """ -from sqlalchemy import String, Text, DateTime, ForeignKey, Index +from sqlalchemy import String, Text, DateTime, ForeignKey, Index, JSON from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.dialects.postgresql import UUID, INET, JSONB +from sqlalchemy.dialects.postgresql import INET, JSONB from typing import Optional, Dict, Any, TYPE_CHECKING import uuid from datetime import datetime -from app.core.database import Base +from app.core.database import Base, GUID if TYPE_CHECKING: from app.models.tenant import Tenant @@ -36,7 +36,7 @@ class AuditLog(Base): # Multi-tenancy tenant_id: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), + GUID(), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True @@ -44,7 +44,7 @@ class AuditLog(Base): # Usuario que ejecut├│ la acci├│n (NULL = acci├│n del sistema) user_id: Mapped[Optional[uuid.UUID]] = mapped_column( - UUID(as_uuid=True), + GUID(), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True @@ -66,30 +66,33 @@ class AuditLog(Base): # ID del recurso afectado resource_id: Mapped[Optional[uuid.UUID]] = mapped_column( - UUID(as_uuid=True), + GUID(), nullable=True ) # Contexto de la request - ip_address: Mapped[Optional[str]] = mapped_column(INET, nullable=True) + ip_address: Mapped[Optional[str]] = mapped_column( + String(45).with_variant(INET, "postgresql"), + nullable=True, + ) user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # Correlation ID para rastrear requests relacionadas correlation_id: Mapped[Optional[uuid.UUID]] = mapped_column( - UUID(as_uuid=True), + GUID(), nullable=True, index=True ) # Valores antes del cambio (JSON) old_values: Mapped[Optional[Dict[str, Any]]] = mapped_column( - JSONB, + JSON().with_variant(JSONB, "postgresql"), nullable=True ) # Valores despu├®s del cambio (JSON) new_values: Mapped[Optional[Dict[str, Any]]] = mapped_column( - JSONB, + JSON().with_variant(JSONB, "postgresql"), nullable=True ) @@ -97,7 +100,7 @@ class AuditLog(Base): # Nota: 'metadata' est├í reservado en SQLAlchemy, usamos 'extra_metadata' extra_metadata: Mapped[Optional[Dict[str, Any]]] = mapped_column( 'metadata', # Nombre real de la columna en BD - JSONB, + JSON().with_variant(JSONB, "postgresql"), nullable=True ) diff --git a/backend/app/models/category.py b/backend/app/models/category.py index 64f0d44..60f336f 100644 --- a/backend/app/models/category.py +++ b/backend/app/models/category.py @@ -4,11 +4,10 @@ Categorías de tickets por tenant """ from sqlalchemy import String, Text, Boolean, Integer, ForeignKey, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.dialects.postgresql import UUID from typing import List, Optional import uuid -from app.core.database import Base +from app.core.database import Base, GUID class Category(Base): """Modelo de categorías de tickets (ticket_categories en BD)""" @@ -21,7 +20,7 @@ class Category(Base): # ✅ CORREGIDO: tenant_id es obligatorio para multi-tenancy tenant_id: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), + GUID(), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False # ✅ Obligatorio ) @@ -31,7 +30,7 @@ class Category(Base): sla_response_hours: Mapped[int] = mapped_column(Integer, default=24, nullable=False) sla_resolution_hours: Mapped[int] = mapped_column(Integer, default=72, nullable=False) auto_assign_to: Mapped[Optional[uuid.UUID]] = mapped_column( - UUID(as_uuid=True), + GUID(), ForeignKey("users.id"), nullable=True ) diff --git a/backend/app/models/client_profile.py b/backend/app/models/client_profile.py index 9067305..2b9688a 100644 --- a/backend/app/models/client_profile.py +++ b/backend/app/models/client_profile.py @@ -7,12 +7,11 @@ Almacena información detallada de la empresa cliente from sqlalchemy import String, Boolean, DateTime, ForeignKey, Text, Numeric from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.dialects.postgresql import UUID from typing import Optional, TYPE_CHECKING import uuid from datetime import datetime -from app.core.database import Base +from app.core.database import Base, GUID if TYPE_CHECKING: from app.models.tenant import Tenant @@ -25,7 +24,7 @@ class ClientProfile(Base): # Relación con tenant (uno a uno) tenant_id: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), + GUID(), ForeignKey("tenants.id", ondelete="CASCADE"), unique=True, nullable=False, diff --git a/backend/app/models/comment.py b/backend/app/models/comment.py index a130a10..7f672a4 100644 --- a/backend/app/models/comment.py +++ b/backend/app/models/comment.py @@ -5,12 +5,11 @@ Modelo para comentarios en tickets """ from sqlalchemy import Column, String, Text, Boolean, ForeignKey, DateTime -from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column, relationship from datetime import datetime import uuid -from app.core.database import Base +from app.core.database import Base, GUID class TicketComment(Base): @@ -20,20 +19,20 @@ class TicketComment(Base): # Columnas id: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), + GUID(), primary_key=True, default=uuid.uuid4 ) ticket_id: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), + GUID(), ForeignKey("tickets.id", ondelete="CASCADE"), nullable=False, index=True ) author_id: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), + GUID(), ForeignKey("users.id"), nullable=False, index=True diff --git a/backend/app/models/refresh_token.py b/backend/app/models/refresh_token.py index 077e250..7ced0de 100644 --- a/backend/app/models/refresh_token.py +++ b/backend/app/models/refresh_token.py @@ -6,12 +6,11 @@ Modelo para persistencia de refresh tokens con revocaci├│n y tracking. from sqlalchemy import String, Boolean, DateTime, ForeignKey, Index, Integer from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.dialects.postgresql import UUID from typing import Optional, TYPE_CHECKING import uuid -from datetime import datetime +from datetime import datetime, timezone -from app.core.database import Base +from app.core.database import Base, GUID if TYPE_CHECKING: from app.models.user import User @@ -36,7 +35,7 @@ class RefreshToken(Base): # User relationship user_id: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), + GUID(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True @@ -92,7 +91,7 @@ class RefreshToken(Base): ) revoked_by: Mapped[Optional[uuid.UUID]] = mapped_column( - UUID(as_uuid=True), + GUID(), ForeignKey("users.id", ondelete="SET NULL"), nullable=True ) @@ -146,12 +145,12 @@ class RefreshToken(Base): - No est├í revocado - No ha expirado """ - return not self.revoked and self.expires_at > datetime.utcnow() + return not self.revoked and self.expires_at > datetime.now(timezone.utc) @property def is_expired(self) -> bool: """Verificar si el token ha expirado.""" - return datetime.utcnow() >= self.expires_at + return datetime.now(timezone.utc) >= self.expires_at def revoke(self, revoked_by: Optional[uuid.UUID] = None) -> None: """ @@ -161,11 +160,11 @@ class RefreshToken(Base): revoked_by: ID del usuario que revoc├│ el token """ self.revoked = True - self.revoked_at = datetime.utcnow() + self.revoked_at = datetime.now(timezone.utc) if revoked_by: self.revoked_by = revoked_by def track_usage(self) -> None: """Registrar uso del token.""" - self.last_used_at = datetime.utcnow() + self.last_used_at = datetime.now(timezone.utc) self.usage_count += 1 diff --git a/backend/app/models/system.py b/backend/app/models/system.py index a29c43c..9963e83 100644 --- a/backend/app/models/system.py +++ b/backend/app/models/system.py @@ -4,11 +4,10 @@ Sistemas afectados por tenant """ from sqlalchemy import String, Text, Boolean, ForeignKey, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.dialects.postgresql import UUID from typing import List, Optional import uuid -from app.core.database import Base +from app.core.database import Base, GUID class System(Base): """Modelo de sistemas afectados (affected_systems en BD)""" @@ -21,7 +20,7 @@ class System(Base): # ✅ AÑADIDO: tenant_id obligatorio para multi-tenancy (faltaba completamente) tenant_id: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), + GUID(), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False ) diff --git a/backend/app/models/tenant.py b/backend/app/models/tenant.py index bee1f70..af513e3 100644 --- a/backend/app/models/tenant.py +++ b/backend/app/models/tenant.py @@ -2,9 +2,9 @@ Tenant Model - ServiceManagerWeb Modelo para organizaciones cliente (multi-tenancy) """ -from sqlalchemy import String, Integer, Text, Boolean, ARRAY +from sqlalchemy import String, Integer, Text, Boolean, JSON from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.dialects.postgresql import UUID, ENUM +from sqlalchemy.dialects.postgresql import UUID, ENUM, ARRAY as PG_ARRAY from typing import List, Optional import enum import uuid @@ -40,7 +40,7 @@ class Tenant(Base): max_users: Mapped[int] = mapped_column(Integer, default=50) max_storage_mb: Mapped[int] = mapped_column(Integer, default=1024) allowed_file_types: Mapped[List[str]] = mapped_column( - ARRAY(String), + JSON().with_variant(PG_ARRAY(String), "postgresql"), default=["pdf", "jpg", "jpeg", "png", "doc", "docx", "xls", "xlsx", "txt"] ) diff --git a/backend/app/models/ticket.py b/backend/app/models/ticket.py index b100a4b..ab9b9ea 100644 --- a/backend/app/models/ticket.py +++ b/backend/app/models/ticket.py @@ -2,15 +2,20 @@ Ticket Model - ServiceManagerWeb Tickets de soporte - Core del negocio """ -from sqlalchemy import String, ForeignKey, Text, Integer, CheckConstraint, UniqueConstraint -from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.dialects.postgresql import UUID, ENUM +from sqlalchemy import String, ForeignKey, Text, Integer, CheckConstraint, UniqueConstraint, Enum as SAEnum +from sqlalchemy.orm import Mapped, mapped_column, relationship, synonym +from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM from typing import Optional from datetime import datetime import enum import uuid -from app.core.database import Base +from app.core.database import Base, GUID + + +def _generate_fallback_ticket_number() -> str: + # Matches helper format "TK-000001" and stays within VARCHAR(20) + return f"TK-{(uuid.uuid4().int % 1_000_000):06d}" class TicketStatus(str, enum.Enum): """Estados posibles de un ticket""" @@ -35,50 +40,64 @@ class Ticket(Base): # Multi-tenancy tenant_id: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), + GUID(), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False ) # Campos básicos - ticket_number: Mapped[str] = mapped_column(String(20), nullable=False) + ticket_number: Mapped[str] = mapped_column( + String(20), + nullable=False, + default=_generate_fallback_ticket_number, + ) subject: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[str] = mapped_column(Text, nullable=False) + + # Compatibility aliases (API/UI/tests often use these names) + title = synonym("subject") + system_id = synonym("affected_system_id") # Estado y Prioridad status: Mapped[TicketStatus] = mapped_column( - ENUM(TicketStatus, name="ticket_status_enum", create_type=False), + SAEnum(TicketStatus, name="ticket_status_enum", native_enum=False).with_variant( + PG_ENUM(TicketStatus, name="ticket_status_enum", create_type=True), + "postgresql", + ), default=TicketStatus.NEW, nullable=False ) priority: Mapped[TicketPriority] = mapped_column( - ENUM(TicketPriority, name="ticket_priority_enum", create_type=False), + SAEnum(TicketPriority, name="ticket_priority_enum", native_enum=False).with_variant( + PG_ENUM(TicketPriority, name="ticket_priority_enum", create_type=True), + "postgresql", + ), default=TicketPriority.MEDIUM, nullable=False ) # ✅ CORREGIDO: Foreign Keys apuntan a tablas correctas created_by: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), + GUID(), ForeignKey("users.id"), nullable=False ) assigned_to: Mapped[Optional[uuid.UUID]] = mapped_column( - UUID(as_uuid=True), + GUID(), ForeignKey("users.id"), nullable=True ) # ✅ CORREGIDO: Renombrado de system_id a affected_system_id affected_system_id: Mapped[Optional[uuid.UUID]] = mapped_column( - UUID(as_uuid=True), + GUID(), ForeignKey("affected_systems.id"), # ✅ Tabla correcta nullable=True ) # ✅ CORREGIDO: Foreign key a tabla correcta category_id: Mapped[Optional[uuid.UUID]] = mapped_column( - UUID(as_uuid=True), + GUID(), ForeignKey("ticket_categories.id"), # ✅ Tabla correcta nullable=True ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 943e7d0..878c6a4 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -4,15 +4,15 @@ User Model - ServiceManagerWeb Modelo para usuarios del sistema (internos y clientes) """ -from sqlalchemy import String, Boolean, DateTime, ForeignKey, Text, ARRAY +from sqlalchemy import String, Boolean, DateTime, ForeignKey, Text, JSON, Enum as SAEnum from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.dialects.postgresql import UUID, ENUM +from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM, ARRAY as PG_ARRAY from typing import Optional, List import enum import uuid from datetime import datetime -from app.core.database import Base +from app.core.database import Base, GUID class UserRole(str, enum.Enum): @@ -35,7 +35,7 @@ class User(Base): # Relación con tenant tenant_id: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), + GUID(), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False ) @@ -48,12 +48,20 @@ class User(Base): # Autenticación password_hash: Mapped[str] = mapped_column(String(255), nullable=False) - role: Mapped[UserRole] = mapped_column(ENUM(UserRole, name="user_role_enum"), nullable=False) + role: Mapped[UserRole] = mapped_column( + SAEnum(UserRole, name="user_role_enum", native_enum=False).with_variant( + PG_ENUM(UserRole, name="user_role_enum", create_type=True), + "postgresql", + ), + nullable=False, + ) # 2FA (opcional para staff interno) totp_secret: Mapped[Optional[str]] = mapped_column(String(32)) totp_enabled: Mapped[bool] = mapped_column(Boolean, default=False) - backup_codes: Mapped[Optional[List[str]]] = mapped_column(ARRAY(String)) + backup_codes: Mapped[Optional[List[str]]] = mapped_column( + JSON().with_variant(PG_ARRAY(String), "postgresql") + ) # Estado is_active: Mapped[bool] = mapped_column(Boolean, default=True) @@ -85,11 +93,6 @@ class User(Base): cascade="all, delete-orphan" ) - # Unique constraint por tenant - __table_args__ = ( - {"postgresql_tablespace": "users"}, - ) - def __repr__(self) -> str: return f"" diff --git a/backend/app/services/token_service.py b/backend/app/services/token_service.py index 30e0dac..dbd0f08 100644 --- a/backend/app/services/token_service.py +++ b/backend/app/services/token_service.py @@ -6,7 +6,7 @@ Servicio para gesti├│n de refresh tokens persistentes. from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, delete -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Optional import uuid import structlog @@ -56,7 +56,7 @@ class TokenService: RefreshToken creado """ # Calcular expiraci├│n - expires_at = datetime.utcnow() + timedelta( + expires_at = datetime.now(timezone.utc) + timedelta( days=settings.REFRESH_TOKEN_EXPIRE_DAYS ) @@ -232,7 +232,7 @@ class TokenService: N├║mero de tokens eliminados """ # Eliminar tokens expirados hace m├ís de 7 d├¡as - cutoff_date = datetime.utcnow() - timedelta(days=7) + cutoff_date = datetime.now(timezone.utc) - timedelta(days=7) query = delete(RefreshToken).where( RefreshToken.expires_at < cutoff_date diff --git a/backend/pytest.ini b/backend/pytest.ini index 2d3aaae..996632d 100644 --- a/backend/pytest.ini +++ b/backend/pytest.ini @@ -1,4 +1,4 @@ -[tool:pytest] +[pytest] testpaths = tests tests/unit tests/integration python_files = test_*.py python_functions = test_* diff --git a/backend/tests/conftest_integration.py b/backend/tests/conftest_integration.py index 835ca55..4393446 100644 --- a/backend/tests/conftest_integration.py +++ b/backend/tests/conftest_integration.py @@ -6,6 +6,7 @@ Fixtures y utilidades para tests de integración con BD real import pytest import asyncio +import os from typing import AsyncGenerator, Generator from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker from sqlalchemy.pool import NullPool @@ -21,8 +22,19 @@ from app.models.system import System from app.models.category import Category -# Database URL para testing (usa la misma BD pero limpia después) -TEST_DATABASE_URL = "postgresql+asyncpg://servicemanager:servicemanager123@localhost:5432/servicemanager_test" +# Database URL para testing. +# - En host/local: usa localhost +# - En Docker: deriva de DATABASE_URL (normalmente apunta a host 'postgres') +_DEFAULT_TEST_DATABASE_URL = "postgresql+asyncpg://servicemanager:servicemanager123@localhost:5432/servicemanager_test" +_ENV_TEST_DATABASE_URL = os.getenv("TEST_DATABASE_URL") +_ENV_DATABASE_URL = os.getenv("DATABASE_URL") + +if _ENV_TEST_DATABASE_URL: + TEST_DATABASE_URL = _ENV_TEST_DATABASE_URL +elif _ENV_DATABASE_URL and "@postgres:" in _ENV_DATABASE_URL: + TEST_DATABASE_URL = _ENV_DATABASE_URL.rsplit("/", 1)[0] + "/servicemanager_test" +else: + TEST_DATABASE_URL = _DEFAULT_TEST_DATABASE_URL @pytest.fixture(scope="session") @@ -99,8 +111,8 @@ async def test_tenant(db_session: AsyncSession) -> Tenant: slug="test-company", domain="test.company.com", status=TenantStatus.ACTIVE, - email="admin@test.company.com", - phone="+1234567890" + contact_email="admin@test.company.com", + contact_phone="+1234567890", ) db_session.add(tenant) await db_session.commit() @@ -116,8 +128,8 @@ async def test_tenant_2(db_session: AsyncSession) -> Tenant: slug="test-company-2", domain="test2.company.com", status=TenantStatus.ACTIVE, - email="admin@test2.company.com", - phone="+9876543210" + contact_email="admin@test2.company.com", + contact_phone="+9876543210", ) db_session.add(tenant) await db_session.commit() diff --git a/backend/tests/integration/conftest.py b/backend/tests/integration/conftest.py new file mode 100644 index 0000000..257b46b --- /dev/null +++ b/backend/tests/integration/conftest.py @@ -0,0 +1,289 @@ +"""Integration Test Configuration - ServiceManagerWeb + +Fixtures y utilidades para tests de integración con BD real. + +Este conftest vive dentro de tests/integration para que sus fixtures (client, db_session, +test_tenant, tokens, etc.) apliquen solo a los tests de integración y no colisionen con +los fixtures SQLite del conftest global. +""" + +import os +import pytest +from typing import AsyncGenerator + +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker +from sqlalchemy.pool import NullPool +from sqlalchemy import text +from httpx import AsyncClient + +from app.main import app +from app.core.database import Base, get_db +from app.core.security import SecurityUtils +from app.models.tenant import Tenant, TenantStatus +from app.models.user import User, UserRole +from app.models.system import System +from app.models.category import Category + + +_DEFAULT_TEST_DATABASE_URL = "postgresql+asyncpg://servicemanager:servicemanager123@localhost:5432/servicemanager_test" +_ENV_TEST_DATABASE_URL = os.getenv("TEST_DATABASE_URL") +_ENV_DATABASE_URL = os.getenv("DATABASE_URL") + +if _ENV_TEST_DATABASE_URL: + TEST_DATABASE_URL = _ENV_TEST_DATABASE_URL +elif _ENV_DATABASE_URL and "@postgres:" in _ENV_DATABASE_URL: + TEST_DATABASE_URL = _ENV_DATABASE_URL.rsplit("/", 1)[0] + "/servicemanager_test" +else: + TEST_DATABASE_URL = _DEFAULT_TEST_DATABASE_URL + + +@pytest.fixture(scope="session") +async def test_engine(): + """Create test database engine.""" + engine = create_async_engine( + TEST_DATABASE_URL, + echo=False, + poolclass=NullPool, + ) + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + yield engine + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + await engine.dispose() + + +@pytest.fixture +async def db_session(test_engine) -> AsyncGenerator[AsyncSession, None]: + """Create a fresh database session for each integration test.""" + async_session = async_sessionmaker( + test_engine, + class_=AsyncSession, + expire_on_commit=False, + ) + + async with async_session() as session: + try: + yield session + finally: + # Rollback any open transaction + await session.rollback() + + # Hard reset DB state for next test (tests commit, so rollback alone isn't enough) + table_names = [t.name for t in Base.metadata.sorted_tables] + if table_names: + quoted = ", ".join(f'"{name}"' for name in table_names) + await session.execute(text(f"TRUNCATE TABLE {quoted} RESTART IDENTITY CASCADE")) + await session.commit() + + +@pytest.fixture +async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]: + """Create test client with overridden database dependency.""" + + # Disable login rate limiting during integration tests to avoid flakiness + # (tests perform many logins quickly from the same IP). + import app.api.v1.endpoints.auth as auth_endpoint + + old_rate_limit_enabled = getattr(auth_endpoint.settings, "RATE_LIMIT_ENABLED", None) + old_testing = getattr(auth_endpoint.settings, "TESTING", None) + auth_endpoint.settings.RATE_LIMIT_ENABLED = False + auth_endpoint.settings.TESTING = True + + async def override_get_db(): + yield db_session + + app.dependency_overrides[get_db] = override_get_db + + async with AsyncClient(app=app, base_url="http://test") as ac: + yield ac + + app.dependency_overrides.clear() + + # Restore settings + if old_rate_limit_enabled is not None: + auth_endpoint.settings.RATE_LIMIT_ENABLED = old_rate_limit_enabled + if old_testing is not None: + auth_endpoint.settings.TESTING = old_testing + + +# =================================== +# FIXTURES DE DATOS DE TEST +# =================================== + + +@pytest.fixture +async def test_tenant(db_session: AsyncSession) -> Tenant: + tenant = Tenant( + name="Test Company", + slug="test-company", + domain="test.company.com", + status=TenantStatus.ACTIVE, + contact_email="admin@test.company.com", + contact_phone="+1234567890", + ) + db_session.add(tenant) + await db_session.commit() + await db_session.refresh(tenant) + return tenant + + +@pytest.fixture +async def test_tenant_2(db_session: AsyncSession) -> Tenant: + tenant = Tenant( + name="Test Company 2", + slug="test-company-2", + domain="test2.company.com", + status=TenantStatus.ACTIVE, + contact_email="admin@test2.company.com", + contact_phone="+9876543210", + ) + db_session.add(tenant) + await db_session.commit() + await db_session.refresh(tenant) + return tenant + + +@pytest.fixture +async def test_admin_user(db_session: AsyncSession, test_tenant: Tenant) -> User: + user = User( + tenant_id=test_tenant.id, + email="admin@test.com", + first_name="Admin", + last_name="User", + password_hash=SecurityUtils.hash_password("AdminPass123!"), + role=UserRole.ADMIN, + is_active=True, + email_verified=True, + ) + db_session.add(user) + await db_session.commit() + await db_session.refresh(user) + return user + + +@pytest.fixture +async def test_agent_user(db_session: AsyncSession, test_tenant: Tenant) -> User: + user = User( + tenant_id=test_tenant.id, + email="agent@test.com", + first_name="Agent", + last_name="User", + password_hash=SecurityUtils.hash_password("AgentPass123!"), + role=UserRole.AGENT, + is_active=True, + email_verified=True, + ) + db_session.add(user) + await db_session.commit() + await db_session.refresh(user) + return user + + +@pytest.fixture +async def test_client_user(db_session: AsyncSession, test_tenant: Tenant) -> User: + user = User( + tenant_id=test_tenant.id, + email="client@test.com", + first_name="Client", + last_name="User", + password_hash=SecurityUtils.hash_password("ClientPass123!"), + role=UserRole.CLIENT_USER, + is_active=True, + email_verified=True, + ) + db_session.add(user) + await db_session.commit() + await db_session.refresh(user) + return user + + +@pytest.fixture +async def test_system(db_session: AsyncSession, test_tenant: Tenant) -> System: + system = System( + name="Test System", + description="Test system description", + tenant_id=test_tenant.id, + is_active=True, + ) + db_session.add(system) + await db_session.commit() + await db_session.refresh(system) + return system + + +@pytest.fixture +async def test_category(db_session: AsyncSession, test_tenant: Tenant) -> Category: + category = Category( + name="Test Category", + description="Test category description", + tenant_id=test_tenant.id, + is_active=True, + sla_response_hours=24, + sla_resolution_hours=72, + ) + db_session.add(category) + await db_session.commit() + await db_session.refresh(category) + return category + + +@pytest.fixture +async def admin_token(client: AsyncClient, test_admin_user: User, test_tenant: Tenant) -> str: + response = await client.post( + "/v1/auth/login", + json={ + "email": test_admin_user.email, + "password": "AdminPass123!", + "tenant_slug": test_tenant.slug, + }, + ) + assert response.status_code == 200 + return response.json()["access_token"] + + +@pytest.fixture +async def agent_token(client: AsyncClient, test_agent_user: User, test_tenant: Tenant) -> str: + response = await client.post( + "/v1/auth/login", + json={ + "email": test_agent_user.email, + "password": "AgentPass123!", + "tenant_slug": test_tenant.slug, + }, + ) + assert response.status_code == 200 + return response.json()["access_token"] + + +@pytest.fixture +async def client_token(client: AsyncClient, test_client_user: User, test_tenant: Tenant) -> str: + response = await client.post( + "/v1/auth/login", + json={ + "email": test_client_user.email, + "password": "ClientPass123!", + "tenant_slug": test_tenant.slug, + }, + ) + assert response.status_code == 200 + return response.json()["access_token"] + + +@pytest.fixture +def auth_headers_admin(admin_token: str) -> dict: + return {"Authorization": f"Bearer {admin_token}"} + + +@pytest.fixture +def auth_headers_agent(agent_token: str) -> dict: + return {"Authorization": f"Bearer {agent_token}"} + + +@pytest.fixture +def auth_headers_client(client_token: str) -> dict: + return {"Authorization": f"Bearer {client_token}"} diff --git a/backend/tests/integration/test_auth_integration.py b/backend/tests/integration/test_auth_integration.py index c521e09..3b748b1 100644 --- a/backend/tests/integration/test_auth_integration.py +++ b/backend/tests/integration/test_auth_integration.py @@ -16,9 +16,6 @@ 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: @@ -126,6 +123,58 @@ class TestAuthentication: assert response.status_code == 403 + async def test_login_rate_limited_after_too_many_attempts( + self, + client: AsyncClient, + test_admin_user: User, + test_tenant: Tenant, + monkeypatch, + ): + """Debe devolver 429 después de demasiados intentos de login (rate limit).""" + + import app.api.v1.endpoints.auth as auth_endpoint + + class _FakeCache: + def __init__(self): + self._counts = {} + self._expires = {} + + async def incr(self, key: str, amount: int = 1): + self._counts[key] = self._counts.get(key, 0) + amount + return self._counts[key] + + async def expire(self, key: str, ttl: int): + self._expires[key] = ttl + return True + + async def delete(self, key: str): + self._counts.pop(key, None) + return True + + fake_cache = _FakeCache() + monkeypatch.setattr(auth_endpoint, "cache", fake_cache) + monkeypatch.setattr(auth_endpoint.settings, "RATE_LIMIT_ENABLED", True, raising=False) + monkeypatch.setattr(auth_endpoint.settings, "TESTING", False, raising=False) + monkeypatch.setattr(auth_endpoint.settings, "LOGIN_RATE_LIMIT_WINDOW_SECONDS", 60, raising=False) + monkeypatch.setattr(auth_endpoint.settings, "LOGIN_RATE_LIMIT_IP_MAX_ATTEMPTS", 10_000, raising=False) + monkeypatch.setattr(auth_endpoint.settings, "LOGIN_RATE_LIMIT_ID_MAX_ATTEMPTS", 2, raising=False) + + payload = { + "email": test_admin_user.email, + "password": "WrongPassword123!", + "tenant_slug": test_tenant.slug, + } + + r1 = await client.post("/v1/auth/login", json=payload) + assert r1.status_code == 401 + + r2 = await client.post("/v1/auth/login", json=payload) + assert r2.status_code == 401 + + r3 = await client.post("/v1/auth/login", json=payload) + assert r3.status_code == 429 + assert "Retry-After" in r3.headers + @pytest.mark.integration @pytest.mark.auth @@ -305,13 +354,17 @@ class TestUserProfile: async def test_get_current_user_profile( self, client: AsyncClient, + test_tenant: Tenant, 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 + headers={ + **auth_headers_admin, + "X-Tenant-ID": str(test_tenant.id), + }, ) assert response.status_code == 200 @@ -348,13 +401,17 @@ class TestPasswordSecurity: async def test_password_not_exposed_in_response( self, client: AsyncClient, + test_tenant: Tenant, 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 + headers={ + **auth_headers_admin, + "X-Tenant-ID": str(test_tenant.id), + }, ) assert response.status_code == 200 diff --git a/backend/tests/integration/test_multitenant_integration.py b/backend/tests/integration/test_multitenant_integration.py index 627fac1..351e480 100644 --- a/backend/tests/integration/test_multitenant_integration.py +++ b/backend/tests/integration/test_multitenant_integration.py @@ -14,9 +14,6 @@ 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: diff --git a/backend/tests/test_setup_verification.py b/backend/tests/integration/test_setup_verification.py similarity index 51% rename from backend/tests/test_setup_verification.py rename to backend/tests/integration/test_setup_verification.py index d88a309..c34a776 100644 --- a/backend/tests/test_setup_verification.py +++ b/backend/tests/integration/test_setup_verification.py @@ -1,78 +1,56 @@ -""" -Quick Test Verification - ServiceManagerWeb +"""Quick Test Verification - ServiceManagerWeb -Test rápido para verificar que la configuración de tests funciona correctamente. +Smoke tests para verificar que el setup de tests de integración funciona correctamente. """ import pytest from httpx import AsyncClient -pytest_plugins = ['tests.conftest_integration'] - @pytest.mark.integration class TestSetupVerification: - """Verificar que el setup de tests funciona.""" - async def test_client_fixture_works(self, client: AsyncClient): - """Test que el fixture de client HTTP funciona.""" assert client is not None - assert client.base_url == "http://test" - + assert str(client.base_url) == "http://test" + async def test_database_connection(self, db_session): - """Test que la conexión a BD de testing funciona.""" - assert db_session is not None - - # Ejecutar query simple from sqlalchemy import text + result = await db_session.execute(text("SELECT 1")) assert result.scalar() == 1 - + async def test_tenant_fixture_creates_tenant(self, test_tenant): - """Test que el fixture de tenant funciona.""" - assert test_tenant is not None assert test_tenant.name == "Test Company" assert test_tenant.slug == "test-company" - + async def test_user_fixtures_work(self, test_admin_user, test_agent_user, test_client_user): - """Test que los fixtures de usuarios funcionan.""" assert test_admin_user.role.value == "ADMIN" assert test_agent_user.role.value == "AGENT" assert test_client_user.role.value == "CLIENT_USER" - - async def test_auth_token_generation(self, admin_token): - """Test que la generación de tokens funciona.""" - assert admin_token is not None + + async def test_auth_token_generation(self, admin_token: str): assert isinstance(admin_token, str) assert len(admin_token) > 20 - + async def test_health_endpoint(self, client: AsyncClient): - """Test que el endpoint de health funciona.""" response = await client.get("/health") assert response.status_code == 200 - data = response.json() - assert data["status"] == "healthy" + assert response.json()["status"] == "healthy" @pytest.mark.integration class TestBasicEndpoints: - """Tests básicos de endpoints para verificar conectividad.""" - async def test_health_endpoint_detailed(self, client: AsyncClient): - """Test del endpoint de health detallado.""" response = await client.get("/v1/health/detailed") - assert response.status_code == 200 - + assert response.status_code in (200, 503) + async def test_login_endpoint_exists(self, client: AsyncClient): - """Test que el endpoint de login responde.""" - # Enviar credenciales inválidas para verificar que el endpoint existe response = await client.post( "/v1/auth/login", json={ "email": "nonexistent@test.com", "password": "wrong", - "tenant_slug": "nonexistent" - } + "tenant_slug": "nonexistent", + }, ) - # Debe responder (aunque con error) - assert response.status_code in [401, 404, 422] + assert response.status_code in (401, 404, 422) diff --git a/backend/tests/integration/test_tickets_integration.py b/backend/tests/integration/test_tickets_integration.py index 3db1089..6203710 100644 --- a/backend/tests/integration/test_tickets_integration.py +++ b/backend/tests/integration/test_tickets_integration.py @@ -6,6 +6,7 @@ Tests completos del CRUD de tickets y funcionalidad relacionada. import pytest from httpx import AsyncClient + from sqlalchemy.ext.asyncio import AsyncSession import uuid @@ -14,8 +15,7 @@ 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'] +from app.core.file_handler import file_handler @pytest.mark.integration @@ -611,3 +611,66 @@ class TestTicketPermissions: # Debe ver ambos tickets assert len(tickets) >= 2 + + +@pytest.mark.integration +@pytest.mark.db +class TestTicketAttachmentPermissions: + async def test_client_cannot_download_other_users_attachment( + self, + client: AsyncClient, + test_tenant: Tenant, + test_category: Category, + auth_headers_admin: dict, + auth_headers_client: dict, + ): + # Admin crea ticket + create_resp = await client.post( + "/v1/tickets/", + headers={ + **auth_headers_admin, + "X-Tenant-ID": str(test_tenant.id), + }, + json={ + "title": "Admin ticket", + "description": "Ticket with attachment", + "priority": "MEDIUM", + "category_id": str(test_category.id), + }, + ) + assert create_resp.status_code == 201 + ticket_id = create_resp.json()["id"] + + # Admin sube adjunto (PDF válido por magic bytes) + pdf_bytes = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF\n" + upload_resp = await client.post( + f"/v1/tickets/{ticket_id}/attachments", + headers={ + **auth_headers_admin, + "X-Tenant-ID": str(test_tenant.id), + }, + files={ + "file": ("test.pdf", pdf_bytes, "application/pdf"), + }, + ) + assert upload_resp.status_code == 201 + attachment_data = upload_resp.json()["data"] + attachment_id = attachment_data["id"] + + # Cliente intenta descargar adjunto de ticket ajeno -> 404 + download_resp = await client.get( + f"/v1/tickets/{ticket_id}/attachments/{attachment_id}/download", + headers={ + **auth_headers_client, + "X-Tenant-ID": str(test_tenant.id), + }, + ) + assert download_resp.status_code == 404 + + # Limpieza del archivo subido (mejor esfuerzo) + try: + uploaded_path = file_handler.get_file_path(attachment_data["file_path"]) + if uploaded_path.exists(): + uploaded_path.unlink() + except Exception: + pass diff --git a/backend/tests/unit/test_file_handler.py b/backend/tests/unit/test_file_handler.py new file mode 100644 index 0000000..9b378cb --- /dev/null +++ b/backend/tests/unit/test_file_handler.py @@ -0,0 +1,80 @@ +"""Unit Tests - FileHandler - ServiceManagerWeb + +Tests para app.core.file_handler.FileHandler. +""" + +import io +import uuid +import tempfile + +import pytest +from fastapi import UploadFile +from fastapi import HTTPException + + +@pytest.mark.asyncio +async def test_save_upload_pdf_valid_streaming(): + from app.core.file_handler import FileHandler, settings + + with tempfile.TemporaryDirectory() as tmp: + settings.UPLOAD_PATH = tmp + handler = FileHandler() + + tenant_id = uuid.uuid4() + ticket_id = uuid.uuid4() + + content = b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n1 0 obj\n<<>>\nendobj\n" + up = UploadFile(filename="test.pdf", file=io.BytesIO(content)) + + meta = await handler.save_upload(up, tenant_id=tenant_id, ticket_id=ticket_id) + assert meta["file_size"] == len(content) + assert meta["original_filename"] == "test.pdf" + assert meta["filename"].endswith(".pdf") + assert meta["md5_hash"] + assert meta["sha256_hash"] + + +@pytest.mark.asyncio +async def test_save_upload_pdf_invalid_magic_bytes_rejected(): + from app.core.file_handler import FileHandler, settings + + with tempfile.TemporaryDirectory() as tmp: + settings.UPLOAD_PATH = tmp + handler = FileHandler() + + up = UploadFile(filename="bad.pdf", file=io.BytesIO(b"NOTPDF")) + + with pytest.raises(HTTPException) as exc: + await handler.save_upload(up, tenant_id=uuid.uuid4(), ticket_id=uuid.uuid4()) + + assert exc.value.status_code == 400 + + +@pytest.mark.asyncio +async def test_save_upload_oversize_rejected_and_file_removed(): + from app.core.file_handler import FileHandler, settings + + with tempfile.TemporaryDirectory() as tmp: + settings.UPLOAD_PATH = tmp + settings.MAX_UPLOAD_SIZE_MB = 0 # 0MB => max 0 bytes + handler = FileHandler() + + up = UploadFile(filename="a.txt", file=io.BytesIO(b"x")) + + with pytest.raises(HTTPException) as exc: + await handler.save_upload(up, tenant_id=uuid.uuid4(), ticket_id=uuid.uuid4()) + + assert exc.value.status_code == 413 + + +def test_get_file_path_prevents_path_traversal(): + from app.core.file_handler import FileHandler, settings + + with tempfile.TemporaryDirectory() as tmp: + settings.UPLOAD_PATH = tmp + handler = FileHandler() + + with pytest.raises(HTTPException) as exc: + handler.get_file_path("../../etc/passwd") + + assert exc.value.status_code == 403 diff --git a/backend/tests/unit/test_middleware.py b/backend/tests/unit/test_middleware.py index cf438b5..eea72f7 100644 --- a/backend/tests/unit/test_middleware.py +++ b/backend/tests/unit/test_middleware.py @@ -124,8 +124,8 @@ class TestMiddlewareNoTenantHeaders: """Tests para requests sin headers de tenant.""" @pytest.mark.asyncio - async def test_missing_tenant_headers_in_dev_continues(self): - """En entorno de desarrollo, sin tenant headers continúa con advertencia.""" + async def test_missing_tenant_headers_returns_400(self): + """Sin tenant headers debe retornar 400 (requerido para aislamiento multi-tenant).""" from app.middleware.tenant import TenantMiddleware mock_app = AsyncMock() @@ -139,18 +139,15 @@ class TestMiddlewareNoTenantHeaders: call_next = AsyncMock(return_value=MagicMock(status_code=200)) - # En modo testing (que hereda de development), debe continuar response = await middleware.dispatch(request, call_next) - # El request continúa (call_next fue llamado) - call_next.assert_called_once() + assert response.status_code == 400 + call_next.assert_not_called() @pytest.mark.asyncio - async def test_missing_tenant_headers_in_production_returns_400(self): - """En producción, sin tenant headers retorna 400.""" + async def test_missing_tenant_headers_does_not_call_next(self): + """Sin tenant headers no debe llegar al handler (call_next).""" from app.middleware.tenant import TenantMiddleware - from app.core.config import get_settings - from starlette.responses import JSONResponse mock_app = AsyncMock() middleware = TenantMiddleware(mock_app) @@ -163,13 +160,10 @@ class TestMiddlewareNoTenantHeaders: call_next = AsyncMock(return_value=MagicMock(status_code=200)) - with patch.object(get_settings(), "ENVIRONMENT", "production"): - response = await middleware.dispatch(request, call_next) + response = await middleware.dispatch(request, call_next) - # En producción sin tenant debe retornar error - # (si la response es JSONResponse con status 400, el test pasa) - if hasattr(response, "status_code"): - assert response.status_code in [400, 200] # depende del env + assert response.status_code == 400 + call_next.assert_not_called() # ============================================================ diff --git a/docker-compose.yml b/docker-compose.yml index 6a2a4ac..91243ba 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: # =================================== # POSTGRES DATABASE @@ -113,6 +111,7 @@ services: - ./backend:/backend:ro - uploads_data:/app/uploads - logs_data:/app/logs + command: celery -A app.celery worker --loglevel=info -Q default,email,sla,maintenance,notifications depends_on: postgres: condition: service_healthy diff --git a/docker/Dockerfile.backend b/docker/Dockerfile.backend index 3274fb8..d111a55 100644 --- a/docker/Dockerfile.backend +++ b/docker/Dockerfile.backend @@ -42,4 +42,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ # Comando por defecto # Development: usar --reload # Production: usar --workers y quitar --reload -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"] \ No newline at end of file +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] \ No newline at end of file diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf index 589692b..faf4c98 100644 --- a/docker/nginx/nginx.conf +++ b/docker/nginx/nginx.conf @@ -55,6 +55,9 @@ http { add_header X-Frame-Options DENY always; add_header X-Content-Type-Options nosniff always; add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()" always; + add_header X-Permitted-Cross-Domain-Policies "none" always; add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; # Hide server version diff --git a/frontend-client/src/lib/components/Icon.svelte b/frontend-client/src/lib/components/Icon.svelte index b9a46c0..b962919 100644 --- a/frontend-client/src/lib/components/Icon.svelte +++ b/frontend-client/src/lib/components/Icon.svelte @@ -17,7 +17,14 @@ eye: 'M15 12a3 3 0 11-6 0 3 3 0 016 0z M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z', clock: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z', check: 'M5 13l4 4L19 7', - chevronDown: 'M19 9l-7 7-7-7' + chevronDown: 'M19 9l-7 7-7-7', + 'building-2': 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4', + 'loader-2': 'M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83', + 'alert-circle': 'M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z', + 'shield-check': 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z', + mail: 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z', + lock: 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z', + 'eye-off': 'M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21' }; $: path = icons[name] || icons.home; diff --git a/frontend-client/src/lib/components/TicketCard.svelte b/frontend-client/src/lib/components/TicketCard.svelte index c79f0c9..aec2629 100644 --- a/frontend-client/src/lib/components/TicketCard.svelte +++ b/frontend-client/src/lib/components/TicketCard.svelte @@ -2,22 +2,25 @@ export let ticket: import('$lib/stores/tickets').Ticket; // Status mapping - const statusConfig = { + const statusConfig: Record = { NEW: { label: 'Nuevo', class: 'badge-new' }, IN_PROGRESS: { label: 'En Progreso', class: 'badge-in-progress' }, WAITING_CUSTOMER: { label: 'Esperando Cliente', class: 'badge-waiting' }, + WAITING_FOR_CLIENT: { label: 'Esperando Cliente', class: 'badge-waiting' }, RESOLVED: { label: 'Resuelto', class: 'badge-resolved' }, CLOSED: { label: 'Cerrado', class: 'badge-closed' }, REOPENED: { label: 'Reabierto', class: 'badge-reopened' } }; + const fallbackStatus = { label: 'Desconocido', class: 'badge-new' }; // Priority mapping - const priorityConfig = { + const priorityConfig: Record = { LOW: { label: 'Baja', class: 'badge-priority-low' }, MEDIUM: { label: 'Media', class: 'badge-priority-medium' }, HIGH: { label: 'Alta', class: 'badge-priority-high' }, URGENT: { label: 'Urgente', class: 'badge-priority-urgent' } }; + const fallbackPriority = { label: 'Normal', class: 'badge-priority-medium' }; // Format date function formatDate(dateString: string): string { @@ -58,11 +61,11 @@
- - {statusConfig[ticket.status].label} + + {(statusConfig[ticket.status] ?? fallbackStatus).label} - - {priorityConfig[ticket.priority].label} + + {(priorityConfig[ticket.priority] ?? fallbackPriority).label}
diff --git a/frontend-client/src/lib/stores/app.ts b/frontend-client/src/lib/stores/app.ts index 300ea5f..98c3a24 100644 --- a/frontend-client/src/lib/stores/app.ts +++ b/frontend-client/src/lib/stores/app.ts @@ -35,6 +35,7 @@ async function apiCall(endpoint: string, options: RequestInit = {}) { headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${authState.token}`, + ...(authState.user?.tenant_id ? { 'X-Tenant-ID': authState.user.tenant_id } : {}), ...options.headers } }); diff --git a/frontend-client/src/lib/stores/tickets.ts b/frontend-client/src/lib/stores/tickets.ts index 3ada625..8c153ca 100644 --- a/frontend-client/src/lib/stores/tickets.ts +++ b/frontend-client/src/lib/stores/tickets.ts @@ -135,7 +135,9 @@ function createTicketsStore() { update((state: TicketsState) => ({ ...state, isLoading: true, error: null })); try { - const tickets = await apiCall('/tickets/'); + const raw = await apiCall('/tickets/'); + // El backend devuelve 'subject', el tipo Ticket usa 'title' + const tickets = raw.map((t: any) => ({ ...t, title: t.subject ?? t.title })); update((state: TicketsState) => ({ ...state, tickets, isLoading: false })); } catch (error) { update((state: TicketsState) => ({ @@ -151,11 +153,13 @@ function createTicketsStore() { update((state: TicketsState) => ({ ...state, isLoading: true, error: null })); try { - const [ticket, comments, attachments] = await Promise.all([ + const [ticketRaw, comments, attachments] = await Promise.all([ apiCall(`/tickets/${ticketId}`), apiCall(`/tickets/${ticketId}/comments`), apiCall(`/tickets/${ticketId}/attachments`) ]); + // El backend devuelve 'subject', el tipo Ticket usa 'title' + const ticket = { ...ticketRaw, title: ticketRaw.subject ?? ticketRaw.title }; update((state: TicketsState) => ({ ...state, diff --git a/frontend-client/src/routes/login/+page.svelte b/frontend-client/src/routes/login/+page.svelte index f1fcc14..19a9d3f 100644 --- a/frontend-client/src/routes/login/+page.svelte +++ b/frontend-client/src/routes/login/+page.svelte @@ -7,6 +7,7 @@ let email = ''; let password = ''; + let tenantSlug = 'aduanasoft-demo'; let totpCode = ''; let isLoading = false; let showTwoFactor = false; @@ -33,7 +34,7 @@ await auth.login({ email, password, - tenant_slug: 'aduanasoft', // Default tenant for now + tenant_slug: tenantSlug.trim() || 'aduanasoft-demo', totp_code: totpCode || undefined }); diff --git a/frontend-client/src/routes/tickets/[id]/+page.svelte b/frontend-client/src/routes/tickets/[id]/+page.svelte index e2952c1..d8c8867 100644 --- a/frontend-client/src/routes/tickets/[id]/+page.svelte +++ b/frontend-client/src/routes/tickets/[id]/+page.svelte @@ -63,22 +63,25 @@ } // Status mapping - const statusConfig = { + const statusConfig: Record = { NEW: { label: 'Nuevo', class: 'badge-new' }, IN_PROGRESS: { label: 'En Progreso', class: 'badge-in-progress' }, WAITING_FOR_CLIENT: { label: 'Esperando Cliente', class: 'badge-waiting' }, + WAITING_CUSTOMER: { label: 'Esperando Cliente', class: 'badge-waiting' }, RESOLVED: { label: 'Resuelto', class: 'badge-resolved' }, CLOSED: { label: 'Cerrado', class: 'badge-closed' }, REOPENED: { label: 'Reabierto', class: 'badge-reopened' } }; + const fallbackStatus = { label: 'Desconocido', class: 'badge-new' }; // Priority mapping - const priorityConfig = { + const priorityConfig: Record = { LOW: { label: 'Baja', class: 'badge-priority-low' }, MEDIUM: { label: 'Media', class: 'badge-priority-medium' }, HIGH: { label: 'Alta', class: 'badge-priority-high' }, URGENT: { label: 'Urgente', class: 'badge-priority-urgent' } }; + const fallbackPriority = { label: 'Normal', class: 'badge-priority-medium' }; async function handleAddComment() { if (!newComment.trim()) return; @@ -175,7 +178,7 @@ // Check if user can close ticket $: canClose = $tickets.currentTicket && - ['RESOLVED', 'WAITING_FOR_CLIENT'].includes($tickets.currentTicket.status); + ['RESOLVED', 'WAITING_CUSTOMER'].includes($tickets.currentTicket.status); @@ -230,11 +233,11 @@ {$tickets.currentTicket.title}
- - {statusConfig[$tickets.currentTicket.status].label} + + {(statusConfig[$tickets.currentTicket.status] ?? fallbackStatus).label} - - {priorityConfig[$tickets.currentTicket.priority].label} + + {(priorityConfig[$tickets.currentTicket.priority] ?? fallbackPriority).label} Creado {formatDate($tickets.currentTicket.created_at)} @@ -505,6 +508,45 @@
{formatDate($tickets.currentTicket.updated_at)}
+ + {#if $tickets.currentTicket.sla_response_due || $tickets.currentTicket.sla_resolution_due} +
+
Tiempos de SLA
+ + {#if $tickets.currentTicket.sla_response_due} + {@const respDue = new Date($tickets.currentTicket.sla_response_due)} + {@const respVencido = respDue < new Date() && !$tickets.currentTicket.first_response_at} +
+
Respuesta límite
+
+ {formatDate($tickets.currentTicket.sla_response_due)} + {#if respVencido} + ¡Vencido! + {:else if $tickets.currentTicket.first_response_at} + ✓ Respondido + {/if} +
+
+ {/if} + + {#if $tickets.currentTicket.sla_resolution_due} + {@const resDue = new Date($tickets.currentTicket.sla_resolution_due)} + {@const resVencido = resDue < new Date() && !$tickets.currentTicket.resolved_at} +
+
Resolución límite
+
+ {formatDate($tickets.currentTicket.sla_resolution_due)} + {#if resVencido} + ¡Vencido! + {:else if $tickets.currentTicket.resolved_at} + ✓ Resuelto + {/if} +
+
+ {/if} +
+ {/if} + {#if $tickets.currentTicket.due_date}
Fecha límite
diff --git a/frontend-client/vite.config.js b/frontend-client/vite.config.js index 0f5ae60..389110b 100644 --- a/frontend-client/vite.config.js +++ b/frontend-client/vite.config.js @@ -12,7 +12,7 @@ export default defineConfig({ }, proxy: { '/api': { - target: process.env.PUBLIC_API_URL || 'http://backend:8000', + target: process.env.PUBLIC_API_URL || 'http://localhost:8000', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } diff --git a/frontend-internal/src/lib/utils/api.ts b/frontend-internal/src/lib/utils/api.ts index 509a0ac..b2639e6 100644 --- a/frontend-internal/src/lib/utils/api.ts +++ b/frontend-internal/src/lib/utils/api.ts @@ -26,10 +26,22 @@ async function request(endpoint: string, options: RequestOptions = {}): Promi const authState = get(auth); const token = authState.token || (typeof window !== 'undefined' ? localStorage.getItem('internal_auth_token') : null); + // Resolve tenant_id from store or from the persisted user object in localStorage + let tenantId = authState.user?.tenant_id ?? null; + if (!tenantId && typeof window !== 'undefined') { + try { + const stored = localStorage.getItem('internal_auth_user'); + if (stored) tenantId = JSON.parse(stored)?.tenant_id ?? null; + } catch { /* ignore */ } + } + const headers = new Headers(init.headers); if (token) { headers.set('Authorization', `Bearer ${token}`); } + if (tenantId && !headers.has('X-Tenant-ID')) { + headers.set('X-Tenant-ID', tenantId); + } if (!headers.has('Content-Type')) { headers.set('Content-Type', 'application/json'); } @@ -66,10 +78,21 @@ async function downloadFile(endpoint: string, filename: string): Promise { const authState = get(auth); const token = authState.token || (typeof window !== 'undefined' ? localStorage.getItem('internal_auth_token') : null); + let tenantId = authState.user?.tenant_id ?? null; + if (!tenantId && typeof window !== 'undefined') { + try { + const stored = localStorage.getItem('internal_auth_user'); + if (stored) tenantId = JSON.parse(stored)?.tenant_id ?? null; + } catch { /* ignore */ } + } + const headers = new Headers(); if (token) { headers.set('Authorization', `Bearer ${token}`); } + if (tenantId) { + headers.set('X-Tenant-ID', tenantId); + } const response = await fetch(`${API_BASE}${endpoint}`, { method: 'GET', diff --git a/frontend-internal/src/routes/profile/+page.svelte b/frontend-internal/src/routes/profile/+page.svelte index cbcb576..4352925 100644 --- a/frontend-internal/src/routes/profile/+page.svelte +++ b/frontend-internal/src/routes/profile/+page.svelte @@ -48,7 +48,8 @@ method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${$auth.token}` + Authorization: `Bearer ${$auth.token}`, + ...($auth.user?.tenant_id ? { 'X-Tenant-ID': $auth.user.tenant_id } : {}) }, body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }) }); @@ -78,7 +79,7 @@ try { const response = await fetch('/api/v1/auth/2fa/setup', { method: 'POST', - headers: { Authorization: `Bearer ${$auth.token}` } + headers: { Authorization: `Bearer ${$auth.token}`, ...($auth.user?.tenant_id ? { 'X-Tenant-ID': $auth.user.tenant_id } : {}) } }); if (!response.ok) throw new Error((await response.json()).detail); const data = await response.json(); @@ -101,7 +102,7 @@ try { const response = await fetch('/api/v1/auth/2fa/enable', { method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${$auth.token}` }, + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${$auth.token}`, ...($auth.user?.tenant_id ? { 'X-Tenant-ID': $auth.user.tenant_id } : {}) }, body: JSON.stringify({ totp_code: totpSetupCode }) }); if (!response.ok) throw new Error((await response.json()).detail); @@ -127,7 +128,7 @@ try { const response = await fetch('/api/v1/auth/2fa/disable', { method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${$auth.token}` }, + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${$auth.token}`, ...($auth.user?.tenant_id ? { 'X-Tenant-ID': $auth.user.tenant_id } : {}) }, body: JSON.stringify({ totp_code: disableTotpCode }) }); if (!response.ok) throw new Error((await response.json()).detail); diff --git a/frontend-internal/src/routes/sla/violations/+page.svelte b/frontend-internal/src/routes/sla/violations/+page.svelte index f101485..d586f8d 100644 --- a/frontend-internal/src/routes/sla/violations/+page.svelte +++ b/frontend-internal/src/routes/sla/violations/+page.svelte @@ -188,12 +188,11 @@

- {total} {total === 1 ? 'violación activa' : 'violaciones activas'} encontradas - {#if total > 0} - - Requieren atención inmediata - {/if} -

-
+ {total} {total === 1 ? 'violación activa' : 'violaciones activas'} encontradas + {#if total > 0} + - Requieren atención inmediata + {/if} +

@@ -282,12 +281,9 @@
- {formatHours(violation.hours_overdue)} - -
- vencido -
-
+ {formatHours(violation.hours_overdue)} + +
vencido
diff --git a/frontend-internal/src/routes/tickets/+page.svelte b/frontend-internal/src/routes/tickets/+page.svelte index 6155a12..654d059 100644 --- a/frontend-internal/src/routes/tickets/+page.svelte +++ b/frontend-internal/src/routes/tickets/+page.svelte @@ -86,7 +86,7 @@ $: filteredTickets = allTickets.filter(t => { if (filterStatus && t.status !== filterStatus) return false; if (filterPriority && t.priority !== filterPriority) return false; - if (filterTenantId && t.tenant?.id !== filterTenantId) return false; + if (filterTenantId && (t.tenant_id ?? t.tenant?.id) !== filterTenantId) return false; if (searchQuery) { const q = searchQuery.toLowerCase(); const haystack = @@ -155,7 +155,7 @@ $: tenantCounts = (() => { const counts: Record = {}; for (const t of allTickets) { - const id = t.tenant?.id; + const id = t.tenant_id ?? t.tenant?.id; if (id) counts[id] = (counts[id] || 0) + 1; } return counts; @@ -311,56 +311,25 @@ ------------------------------------------------------------------------------ -->