Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7003e5cd80 | |||
| d8232fda7c | |||
| 3b46f48655 |
@@ -89,9 +89,10 @@ async def get_current_user(
|
||||
raise HTTPException(status_code=400, detail="Inactive user")
|
||||
|
||||
# Enforce that tenant header (if present) matches the authenticated user's tenant.
|
||||
# Prevents cross-tenant header impersonation.
|
||||
# Roles globales (is_global) pueden operar en cualquier tenant → omitir chequeo.
|
||||
# Roles de cliente (is_client) deben coincidir con su propio tenant.
|
||||
request_tenant_id = getattr(getattr(request, "state", None), "tenant_id", None)
|
||||
if request_tenant_id and str(user.tenant_id) != str(request_tenant_id):
|
||||
if request_tenant_id and user.role.is_client and str(user.tenant_id) != str(request_tenant_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Tenant header does not match authenticated user",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Auth Schemas - ServiceManagerWeb
|
||||
|
||||
Pydantic schemas para autenticación y autorización.
|
||||
Pydantic schemas para autenticación y autorización.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, EmailStr
|
||||
@@ -12,7 +12,7 @@ class LoginRequest(BaseModel):
|
||||
"""Schema para solicitud de login."""
|
||||
email: EmailStr
|
||||
password: str
|
||||
tenant_slug: str
|
||||
tenant_slug: Optional[str] = None
|
||||
totp_code: Optional[str] = None
|
||||
|
||||
|
||||
@@ -53,28 +53,28 @@ class TwoFactorSetupResponse(BaseModel):
|
||||
|
||||
|
||||
class TwoFactorEnableRequest(BaseModel):
|
||||
"""Código TOTP para confirmar y activar 2FA."""
|
||||
"""Código TOTP para confirmar y activar 2FA."""
|
||||
totp_code: str
|
||||
|
||||
|
||||
class TwoFactorEnableResponse(BaseModel):
|
||||
"""Resultado al habilitar 2FA: incluye los códigos de respaldo."""
|
||||
"""Resultado al habilitar 2FA: incluye los códigos de respaldo."""
|
||||
enabled: bool
|
||||
backup_codes: List[str]
|
||||
|
||||
|
||||
class TwoFactorDisableRequest(BaseModel):
|
||||
"""Deshabilitar 2FA verificando con TOTP o código de respaldo."""
|
||||
"""Deshabilitar 2FA verificando con TOTP o código de respaldo."""
|
||||
totp_code: Optional[str] = None
|
||||
backup_code: Optional[str] = None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Cambio de contraseña
|
||||
# Cambio de contraseña
|
||||
# ============================================================
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
"""Schema para cambio de contraseña del usuario autenticado."""
|
||||
"""Schema para cambio de contraseña del usuario autenticado."""
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
@@ -82,15 +82,15 @@ class ChangePasswordRequest(BaseModel):
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Recuperación de contraseña
|
||||
# Recuperación de contraseña
|
||||
# ============================================================
|
||||
|
||||
class ForgotPasswordRequest(BaseModel):
|
||||
"""Solicitar enlace de reseteo de contraseña por email."""
|
||||
"""Solicitar enlace de reseteo de contraseña por email."""
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
"""Aplicar nueva contraseña usando token de reseteo."""
|
||||
"""Aplicar nueva contraseña usando token de reseteo."""
|
||||
token: str
|
||||
new_password: str
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Authentication Endpoints - ServiceManagerWeb
|
||||
|
||||
Endpoints para autenticación y autorización
|
||||
Endpoints para autenticación y autorización
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status, Depends, Request, Response
|
||||
@@ -28,7 +28,7 @@ CLIENT_ROLES = {"CLIENT_ADMIN", "CLIENT_USER"}
|
||||
|
||||
|
||||
def _cookie_name_for_role(role: str) -> str:
|
||||
"""Devuelve el nombre de cookie según el rol del usuario."""
|
||||
"""Devuelve el nombre de cookie según el rol del usuario."""
|
||||
return "client_access_token" if role in CLIENT_ROLES else "internal_access_token"
|
||||
from app.api.schemas.auth import (
|
||||
LoginRequest, LoginResponse, RefreshTokenRequest, TokenResponse,
|
||||
@@ -88,25 +88,23 @@ async def login(
|
||||
headers={"Retry-After": str(settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)},
|
||||
)
|
||||
|
||||
# 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",
|
||||
# 1. Validar tenant - por slug si viene, sino buscar por email
|
||||
if login_data.tenant_slug:
|
||||
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:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tenant not found",
|
||||
)
|
||||
else:
|
||||
tenant = None
|
||||
|
||||
# Rate limiting (best-effort): by (tenant,email) to slow brute force.
|
||||
ident_key = None
|
||||
if settings.RATE_LIMIT_ENABLED and not settings.TESTING:
|
||||
if settings.RATE_LIMIT_ENABLED and not settings.TESTING and tenant:
|
||||
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)
|
||||
@@ -142,22 +140,25 @@ async def login(
|
||||
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,
|
||||
)
|
||||
# 2. Buscar usuario - filtrar por tenant si se detecto, sino buscar por email
|
||||
if tenant:
|
||||
query = select(User).where(
|
||||
User.email == login_data.email,
|
||||
User.tenant_id == tenant.id,
|
||||
)
|
||||
else:
|
||||
query = select(User).where(User.email == login_data.email)
|
||||
result = await db.execute(query)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
# 3. 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",
|
||||
email=login_data.email
|
||||
)
|
||||
|
||||
# Registrar intento fallido en auditoría (si el usuario existe)
|
||||
# Registrar intento fallido en auditorÃa (si el usuario existe)
|
||||
if user:
|
||||
try:
|
||||
await AuditService.log(
|
||||
@@ -178,7 +179,7 @@ async def login(
|
||||
detail="Invalid credentials",
|
||||
)
|
||||
|
||||
# 4. Verificar si está activo
|
||||
# 4. Verificar si está activo
|
||||
if not user.is_active:
|
||||
logger.warning(
|
||||
"Login failed - user inactive",
|
||||
@@ -189,19 +190,19 @@ async def login(
|
||||
detail="User inactive",
|
||||
)
|
||||
|
||||
# 5. 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
|
||||
# Indicar al frontend que debe pedir el código TOTP
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Se requiere autenticación de dos factores (2FA). Ingresa tu código."
|
||||
detail="Se requiere autenticación de dos factores (2FA). Ingresa tu código."
|
||||
)
|
||||
if not security.verify_totp(user.totp_secret, login_data.totp_code):
|
||||
logger.warning("Login failed - invalid 2FA code", email=login_data.email)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Código 2FA inválido o expirado"
|
||||
detail="Código 2FA inválido o expirado"
|
||||
)
|
||||
|
||||
# Create tokens
|
||||
@@ -233,7 +234,7 @@ async def login(
|
||||
detail="Service temporarily unavailable",
|
||||
)
|
||||
|
||||
# Registrar login exitoso en auditoría
|
||||
# Registrar login exitoso en auditorÃa
|
||||
try:
|
||||
await AuditService.log(
|
||||
db=db,
|
||||
@@ -392,7 +393,7 @@ async def logout(
|
||||
except Exception as e:
|
||||
logger.warning("Failed to revoke refresh tokens on logout", error=str(e))
|
||||
|
||||
# Registrar logout en auditoría
|
||||
# Registrar logout en auditorÃa
|
||||
try:
|
||||
import uuid
|
||||
user_id = uuid.UUID(payload["sub"])
|
||||
@@ -413,7 +414,7 @@ async def logout(
|
||||
|
||||
logger.info("Logout successful", user_id=payload["sub"])
|
||||
|
||||
# Borrar la cookie correcta según el rol del usuario
|
||||
# Borrar la cookie correcta según el rol del usuario
|
||||
cookie_name = _cookie_name_for_role(payload.get("role", ""))
|
||||
response.delete_cookie(key=cookie_name)
|
||||
return {"message": "Successfully logged out"}
|
||||
@@ -502,7 +503,7 @@ async def get_2fa_status(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Consultar si el 2FA está habilitado para el usuario actual.
|
||||
Consultar si el 2FA está habilitado para el usuario actual.
|
||||
|
||||
Returns:
|
||||
Estado de 2FA del usuario autenticado.
|
||||
@@ -516,10 +517,10 @@ async def setup_2fa(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Iniciar configuración de 2FA: genera un nuevo TOTP secret y QR URI.
|
||||
Iniciar configuración de 2FA: genera un nuevo TOTP secret y QR URI.
|
||||
|
||||
El secret se guarda en BD pero 2FA NO se activa todavía.
|
||||
Se necesita llamar a /2fa/enable con un código válido para activarlo.
|
||||
El secret se guarda en BD pero 2FA NO se activa todavÃa.
|
||||
Se necesita llamar a /2fa/enable con un código válido para activarlo.
|
||||
|
||||
Returns:
|
||||
Secret y QR URI para escanear con la app autenticadora.
|
||||
@@ -527,7 +528,7 @@ async def setup_2fa(
|
||||
new_secret = security.generate_totp_secret()
|
||||
qr_uri = security.generate_totp_uri(new_secret, current_user.email)
|
||||
|
||||
# Guardar el secret (sin habilitar aún)
|
||||
# Guardar el secret (sin habilitar aún)
|
||||
current_user.totp_secret = new_secret
|
||||
await db.commit()
|
||||
|
||||
@@ -543,29 +544,29 @@ async def enable_2fa(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Activar 2FA verificando que el usuario escaneó correctamente el QR.
|
||||
Activar 2FA verificando que el usuario escaneó correctamente el QR.
|
||||
|
||||
Requiere que /2fa/setup haya sido llamado previamente.
|
||||
|
||||
Args:
|
||||
data: Código TOTP generado por la app autenticadora.
|
||||
data: Código TOTP generado por la app autenticadora.
|
||||
|
||||
Returns:
|
||||
Confirmación y lista de códigos de respaldo.
|
||||
Confirmación y lista de códigos de respaldo.
|
||||
"""
|
||||
if not current_user.totp_secret:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Primero inicia el proceso de configuración con /2fa/setup"
|
||||
detail="Primero inicia el proceso de configuración con /2fa/setup"
|
||||
)
|
||||
|
||||
if not security.verify_totp(current_user.totp_secret, data.totp_code):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Código TOTP inválido. Verifica la hora de tu dispositivo e intenta de nuevo."
|
||||
detail="Código TOTP inválido. Verifica la hora de tu dispositivo e intenta de nuevo."
|
||||
)
|
||||
|
||||
# Activar 2FA y generar códigos de respaldo
|
||||
# Activar 2FA y generar códigos de respaldo
|
||||
backup_codes = security.generate_backup_codes()
|
||||
current_user.totp_enabled = True
|
||||
current_user.backup_codes = backup_codes
|
||||
@@ -593,21 +594,21 @@ async def disable_2fa(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Deshabilitar 2FA verificando con código TOTP o código de respaldo.
|
||||
Deshabilitar 2FA verificando con código TOTP o código de respaldo.
|
||||
|
||||
Args:
|
||||
data: totp_code o backup_code para verificar identidad.
|
||||
|
||||
Returns:
|
||||
Mensaje de confirmación.
|
||||
Mensaje de confirmación.
|
||||
"""
|
||||
if not current_user.totp_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="El 2FA no está habilitado en esta cuenta"
|
||||
detail="El 2FA no está habilitado en esta cuenta"
|
||||
)
|
||||
|
||||
# Verificar con TOTP o código de respaldo
|
||||
# Verificar con TOTP o código de respaldo
|
||||
verified = False
|
||||
|
||||
if data.totp_code:
|
||||
@@ -615,7 +616,7 @@ async def disable_2fa(
|
||||
elif data.backup_code and current_user.backup_codes:
|
||||
if data.backup_code in current_user.backup_codes:
|
||||
verified = True
|
||||
# Invalidar el código de respaldo usado
|
||||
# Invalidar el código de respaldo usado
|
||||
current_user.backup_codes = [
|
||||
c for c in current_user.backup_codes if c != data.backup_code
|
||||
]
|
||||
@@ -623,7 +624,7 @@ async def disable_2fa(
|
||||
if not verified:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Verificación fallida. Proporciona un código TOTP o un código de respaldo válido."
|
||||
detail="Verificación fallida. Proporciona un código TOTP o un código de respaldo válido."
|
||||
)
|
||||
|
||||
# Deshabilitar 2FA
|
||||
@@ -644,7 +645,7 @@ async def disable_2fa(
|
||||
|
||||
logger.info("2FA disabled", user_id=str(current_user.id))
|
||||
|
||||
return {"message": "Autenticación de dos factores deshabilitada correctamente"}
|
||||
return {"message": "Autenticación de dos factores deshabilitada correctamente"}
|
||||
|
||||
|
||||
@router.post("/change-password", status_code=status.HTTP_200_OK)
|
||||
@@ -654,32 +655,32 @@ async def change_password(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Cambiar la contraseña del usuario autenticado.
|
||||
Cambiar la contraseña del usuario autenticado.
|
||||
|
||||
Verifica la contraseña actual antes de actualizar.
|
||||
Requiere autenticación activa.
|
||||
Verifica la contraseña actual antes de actualizar.
|
||||
Requiere autenticación activa.
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
# Validar longitud mínima
|
||||
# Validar longitud mÃnima
|
||||
if len(data.new_password) < 8:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="La nueva contraseña debe tener al menos 8 caracteres"
|
||||
detail="La nueva contraseña debe tener al menos 8 caracteres"
|
||||
)
|
||||
|
||||
# Verificar que la contraseña actual sea correcta
|
||||
# Verificar que la contraseña actual sea correcta
|
||||
if not security.verify_password(data.current_password, current_user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="La contraseña actual es incorrecta"
|
||||
detail="La contraseña actual es incorrecta"
|
||||
)
|
||||
|
||||
# No permitir que la nueva sea igual a la actual
|
||||
if security.verify_password(data.new_password, current_user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="La nueva contraseña no puede ser igual a la actual"
|
||||
detail="La nueva contraseña no puede ser igual a la actual"
|
||||
)
|
||||
|
||||
current_user.password_hash = security.hash_password(data.new_password)
|
||||
@@ -697,11 +698,11 @@ async def change_password(
|
||||
await db.commit()
|
||||
|
||||
logger.info("Password changed", user_id=str(current_user.id))
|
||||
return {"message": "Contraseña actualizada correctamente"}
|
||||
return {"message": "Contraseña actualizada correctamente"}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Recuperación de contraseña (forgot / reset)
|
||||
# Recuperación de contraseña (forgot / reset)
|
||||
# ============================================================
|
||||
|
||||
_RESET_TOKEN_TTL = 1800 # 30 minutos en segundos
|
||||
@@ -716,10 +717,10 @@ async def forgot_password(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Solicitar reseteo de contraseña.
|
||||
Solicitar reseteo de contraseña.
|
||||
|
||||
Siempre retorna 200 aunque el email no exista, para no revelar
|
||||
si una dirección está registrada en el sistema.
|
||||
si una dirección está registrada en el sistema.
|
||||
"""
|
||||
import secrets
|
||||
from redis.asyncio import from_url as redis_from_url
|
||||
@@ -735,9 +736,9 @@ async def forgot_password(
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
# Respuesta idéntica — no revelar existencia
|
||||
# Respuesta idéntica — no revelar existencia
|
||||
logger.info("Forgot password: email not found", email=data.email)
|
||||
return {"message": "Si el correo está registrado recibirás un enlace en breve."}
|
||||
return {"message": "Si el correo está registrado recibirás un enlace en breve."}
|
||||
|
||||
# Generar token seguro
|
||||
token = secrets.token_urlsafe(32)
|
||||
@@ -757,7 +758,7 @@ async def forgot_password(
|
||||
|
||||
await send_email(
|
||||
to_email=user.email,
|
||||
subject="Restablece tu contraseña — ServiceManager",
|
||||
subject="Restablece tu contraseña — ServiceManager",
|
||||
html_content=html,
|
||||
text_content=text,
|
||||
)
|
||||
@@ -774,7 +775,7 @@ async def forgot_password(
|
||||
await db.commit()
|
||||
|
||||
logger.info("Password reset email sent", user_id=str(user.id))
|
||||
return {"message": "Si el correo está registrado recibirás un enlace en breve."}
|
||||
return {"message": "Si el correo está registrado recibirás un enlace en breve."}
|
||||
|
||||
|
||||
@router.post("/reset-password", status_code=status.HTTP_200_OK)
|
||||
@@ -785,7 +786,7 @@ async def reset_password(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Aplicar nueva contraseña usando el token recibido por email.
|
||||
Aplicar nueva contraseña usando el token recibido por email.
|
||||
|
||||
El token es de un solo uso: se elimina de Redis al usarse.
|
||||
"""
|
||||
@@ -796,7 +797,7 @@ async def reset_password(
|
||||
if len(data.new_password) < 8:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="La contraseña debe tener al menos 8 caracteres"
|
||||
detail="La contraseña debe tener al menos 8 caracteres"
|
||||
)
|
||||
|
||||
redis_key = f"{_RESET_KEY_PREFIX}{data.token}"
|
||||
@@ -807,7 +808,7 @@ async def reset_password(
|
||||
if not user_id_str:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="El enlace de reseteo es inválido o ya expiró. Solicita uno nuevo."
|
||||
detail="El enlace de reseteo es inválido o ya expiró. Solicita uno nuevo."
|
||||
)
|
||||
|
||||
# Eliminar token inmediatamente (un solo uso)
|
||||
@@ -838,4 +839,4 @@ async def reset_password(
|
||||
await db.commit()
|
||||
|
||||
logger.info("Password reset completed", user_id=str(user.id))
|
||||
return {"message": "Contraseña actualizada correctamente. Ya puedes iniciar sesión."}
|
||||
return {"message": "Contraseña actualizada correctamente. Ya puedes iniciar sesión."}
|
||||
@@ -11,7 +11,7 @@ import uuid
|
||||
from app.core.database import get_db
|
||||
from app.api.deps import get_current_user, get_current_tenant
|
||||
from app.models.ticket import Ticket, TicketStatus, TicketPriority
|
||||
from app.models.user import User
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.category import Category
|
||||
from app.models.system import System
|
||||
@@ -28,98 +28,27 @@ from app.api.v1.helpers import (
|
||||
safe_audit_log, generate_next_ticket_number, calculate_sla_deadlines, ticket_to_dict
|
||||
)
|
||||
from app.services.audit_service import AuditService
|
||||
from app.services.ticket_service import TicketService, get_ticket_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/", response_model=TicketResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_ticket(ticket: TicketCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
async def create_ticket(
|
||||
ticket: TicketCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
ticket_service: TicketService = Depends(get_ticket_service),
|
||||
):
|
||||
"""Crear un nuevo ticket"""
|
||||
max_retries = 3
|
||||
last_error = None
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
ticket_number = await generate_next_ticket_number(db, current_user.tenant_id)
|
||||
category_uuid = uuid.UUID(ticket.category_id) if ticket.category_id else None
|
||||
system_uuid = uuid.UUID(ticket.affected_system_id) if ticket.affected_system_id else None
|
||||
|
||||
category = None
|
||||
if category_uuid:
|
||||
category = await db.get(Category, category_uuid)
|
||||
if not category:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"La categoría con ID {ticket.category_id} no existe.")
|
||||
# ✅ SECURITY: Validate category belongs to current tenant (prevents cross-tenant category injection)
|
||||
if category.tenant_id != current_user.tenant_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"La categoría con ID {ticket.category_id} no existe.")
|
||||
|
||||
if system_uuid:
|
||||
system = await db.get(System, system_uuid)
|
||||
if not system:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"El sistema con ID {ticket.affected_system_id} no existe.")
|
||||
# ✅ SECURITY: Validate system belongs to current tenant (prevents cross-tenant system injection)
|
||||
if system.tenant_id != current_user.tenant_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"El sistema con ID {ticket.affected_system_id} no existe.")
|
||||
|
||||
sla_response_due, sla_resolution_due = calculate_sla_deadlines(category)
|
||||
assigned_to_user = category.auto_assign_to if category and category.auto_assign_to else None
|
||||
|
||||
db_ticket = Ticket(
|
||||
id=uuid.uuid4(), tenant_id=current_user.tenant_id, ticket_number=ticket_number,
|
||||
subject=ticket.subject, description=ticket.description, category_id=category_uuid,
|
||||
affected_system_id=system_uuid, priority=TicketPriority[ticket.priority.upper()],
|
||||
created_by=current_user.id, assigned_to=assigned_to_user, status=TicketStatus.NEW,
|
||||
sla_response_due=sla_response_due, sla_resolution_due=sla_resolution_due,
|
||||
created_at=datetime.utcnow(), updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
db.add(db_ticket)
|
||||
await db.commit()
|
||||
await db.refresh(db_ticket)
|
||||
|
||||
await safe_audit_log(db=db, tenant_id=current_user.tenant_id, user_id=current_user.id,
|
||||
action="ticket.create", resource_type="ticket", resource_id=db_ticket.id,
|
||||
new_values={"ticket_number": db_ticket.ticket_number, "subject": db_ticket.subject,
|
||||
"priority": db_ticket.priority.value, "status": db_ticket.status.value})
|
||||
|
||||
return {
|
||||
"id": str(db_ticket.id), "ticket_number": db_ticket.ticket_number, "subject": db_ticket.subject,
|
||||
"title": db_ticket.subject, "description": db_ticket.description, "status": db_ticket.status.value,
|
||||
"priority": db_ticket.priority.value, "category_id": str(db_ticket.category_id) if db_ticket.category_id else None,
|
||||
"affected_system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None,
|
||||
"system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None,
|
||||
"contact_email": ticket.contact_email,
|
||||
"contact_phone": ticket.contact_phone,
|
||||
"created_by": str(db_ticket.created_by), "assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None,
|
||||
"created_at": db_ticket.created_at, "updated_at": db_ticket.updated_at,
|
||||
"sla_response_due": db_ticket.sla_response_due,
|
||||
"sla_resolution_due": db_ticket.sla_resolution_due,
|
||||
"first_response_at": db_ticket.first_response_at,
|
||||
"resolved_at": db_ticket.resolved_at,
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid UUID format: {str(e)}")
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
last_error = e
|
||||
if "duplicate key" in str(e).lower() and "ticket_number" in str(e).lower():
|
||||
if attempt < max_retries - 1:
|
||||
continue
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Error creating ticket: {str(e)}")
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"No se pudo crear el ticket después de {max_retries} intentos: {str(last_error)}")
|
||||
return await ticket_service.create_ticket(ticket, current_user.tenant_id, current_user.id)
|
||||
|
||||
@router.get("/", response_model=List[TicketResponse])
|
||||
async def get_tickets(skip: int = 0, limit: int = 100, status: Optional[str] = None, priority: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""Obtener tickets con filtros opcionales"""
|
||||
query = select(Ticket).where(Ticket.tenant_id == current_user.tenant_id)
|
||||
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
|
||||
# Solo CLIENT_USER ve únicamente sus propios tickets.
|
||||
# CLIENT_ADMIN ve todos los del tenant.
|
||||
if current_user.role == UserRole.CLIENT_USER:
|
||||
query = query.where(Ticket.created_by == current_user.id)
|
||||
|
||||
query = apply_enum_filter(query, Ticket.status, status, TicketStatus, "status")
|
||||
@@ -142,14 +71,14 @@ async def get_all_tickets_admin(skip: int = 0, limit: int = 100, status_filter:
|
||||
assigned_to_filter: Optional[str] = None, search: Optional[str] = None, date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""Obtener todos los tickets del tenant del administrador (ADMIN/SUPPORT_MANAGER)."""
|
||||
if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]:
|
||||
if current_user.role not in (UserRole.ADMIN, UserRole.SUPPORT_MANAGER):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No tienes permisos para acceder a esta función")
|
||||
|
||||
query = select(Ticket, Tenant, User).join(Tenant, Ticket.tenant_id == Tenant.id).join(User, Ticket.created_by == User.id)
|
||||
|
||||
# SUPPORT_MANAGER solo ve su propio tenant.
|
||||
# ADMIN ve todos los tenants (es el administrador de la plataforma).
|
||||
if current_user.role == "SUPPORT_MANAGER":
|
||||
if current_user.role == UserRole.SUPPORT_MANAGER:
|
||||
query = query.where(Ticket.tenant_id == current_user.tenant_id)
|
||||
|
||||
query = apply_enum_filter(query, Ticket.status, status_filter, TicketStatus, "status")
|
||||
@@ -207,7 +136,7 @@ async def get_ticket(ticket_id: str, db: AsyncSession = Depends(get_db), current
|
||||
"""Obtener un ticket por ID"""
|
||||
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
|
||||
query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id)
|
||||
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
|
||||
if current_user.role.is_client:
|
||||
query = query.where(Ticket.created_by == current_user.id)
|
||||
|
||||
query = query.options(selectinload(Ticket.category), selectinload(Ticket.affected_system), selectinload(Ticket.assigned_to_user))
|
||||
@@ -224,7 +153,7 @@ async def update_ticket(ticket_id: str, ticket: TicketUpdate, db: AsyncSession =
|
||||
"""Actualizar un ticket"""
|
||||
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
|
||||
query = select(Ticket).where(Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id)
|
||||
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"]:
|
||||
if current_user.role.is_client:
|
||||
query = query.where(Ticket.created_by == current_user.id)
|
||||
|
||||
result = await db.execute(query)
|
||||
|
||||
@@ -45,8 +45,12 @@ async def read_users(
|
||||
- role: filtrar por rol
|
||||
- is_active: filtrar por estado activo
|
||||
"""
|
||||
# ✅ CORREGIDO: Filtrar por tenant_id
|
||||
query = select(User).where(User.tenant_id == current_user.tenant_id)
|
||||
# ADMIN global ve todos los tenants; el resto solo ve su propio tenant
|
||||
from app.models.user import UserRole as _UserRole
|
||||
if current_user.role != _UserRole.ADMIN:
|
||||
query = select(User).where(User.tenant_id == current_user.tenant_id)
|
||||
else:
|
||||
query = select(User)
|
||||
|
||||
# Aplicar filtros opcionales
|
||||
if role:
|
||||
|
||||
@@ -68,11 +68,11 @@ async def generate_next_ticket_number(db: AsyncSession, tenant_id: uuid.UUID) ->
|
||||
last_ticket_number = result.scalar_one_or_none()
|
||||
|
||||
if last_ticket_number:
|
||||
last_number = int(last_ticket_number.split('-')[1])
|
||||
last_number = int(last_ticket_number.split('-')[-1])
|
||||
next_number = last_number + 1
|
||||
else:
|
||||
next_number = 1
|
||||
|
||||
|
||||
return f"TK-{next_number:06d}"
|
||||
|
||||
|
||||
|
||||
63
backend/app/models/roles.py
Normal file
63
backend/app/models/roles.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
Definición y helpers de roles para el sistema multi-tenant.
|
||||
|
||||
Fuente única: UserRole en app.models.user.
|
||||
Este módulo expone conjuntos de roles y helpers de verificación
|
||||
para usarse en deps.py y en los endpoints.
|
||||
|
||||
Roles globales (staff interno — alcance multi-tenant):
|
||||
ADMIN → control total sobre todos los tenants
|
||||
SUPPORT_MANAGER → gestiona equipos y SLAs de todos los tenants
|
||||
AGENT → atiende tickets de cualquier tenant
|
||||
AUDITOR → auditoría de solo lectura en todos los tenants
|
||||
|
||||
Roles de cliente (alcance limitado al propio tenant):
|
||||
CLIENT_ADMIN → administra organización: usuarios, configuración, tickets
|
||||
CLIENT_USER → crea y sigue sus propios tickets
|
||||
"""
|
||||
|
||||
from app.models.user import UserRole
|
||||
|
||||
# ── Conjuntos de roles ──────────────────────────────────────────────────────
|
||||
|
||||
GLOBAL_ROLES: frozenset[UserRole] = frozenset({
|
||||
UserRole.ADMIN,
|
||||
UserRole.SUPPORT_MANAGER,
|
||||
UserRole.AGENT,
|
||||
UserRole.AUDITOR,
|
||||
})
|
||||
|
||||
CLIENT_ROLES: frozenset[UserRole] = frozenset({
|
||||
UserRole.CLIENT_ADMIN,
|
||||
UserRole.CLIENT_USER,
|
||||
})
|
||||
|
||||
# ── Permisos por rol ────────────────────────────────────────────────────────
|
||||
|
||||
ROLE_PERMISSIONS: dict[UserRole, list[str]] = {
|
||||
# Staff global
|
||||
UserRole.ADMIN: ["manage_all", "view_all", "audit_all"],
|
||||
UserRole.SUPPORT_MANAGER: ["manage_teams", "view_all_tickets", "manage_sla"],
|
||||
UserRole.AGENT: ["view_all_tickets", "update_any_ticket"],
|
||||
UserRole.AUDITOR: ["view_all", "audit_all"],
|
||||
# Clientes (acotados al tenant)
|
||||
UserRole.CLIENT_ADMIN: ["manage_tenant", "manage_tenant_users", "view_tenant_tickets"],
|
||||
UserRole.CLIENT_USER: ["create_ticket", "view_own_tickets"],
|
||||
}
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def is_global_staff(role: UserRole) -> bool:
|
||||
"""Retorna True si el rol tiene alcance global (staff interno)."""
|
||||
return role.is_global
|
||||
|
||||
|
||||
def is_client_role(role: UserRole) -> bool:
|
||||
"""Retorna True si el rol está acotado al tenant del usuario."""
|
||||
return role.is_client
|
||||
|
||||
|
||||
def has_permission(role: UserRole, permission: str) -> bool:
|
||||
"""Verifica si un rol tiene un permiso específico."""
|
||||
return permission in ROLE_PERMISSIONS.get(role, [])
|
||||
|
||||
@@ -27,6 +27,24 @@ class UserRole(str, enum.Enum):
|
||||
CLIENT_ADMIN = "CLIENT_ADMIN" # Admin de organización cliente
|
||||
CLIENT_USER = "CLIENT_USER" # Usuario final cliente
|
||||
|
||||
@property
|
||||
def is_global(self) -> bool:
|
||||
"""True si el rol tiene alcance global (staff interno cross-tenant)."""
|
||||
return self in (
|
||||
UserRole.ADMIN,
|
||||
UserRole.SUPPORT_MANAGER,
|
||||
UserRole.AGENT,
|
||||
UserRole.AUDITOR,
|
||||
)
|
||||
|
||||
@property
|
||||
def is_client(self) -> bool:
|
||||
"""True si el rol está acotado al tenant del usuario."""
|
||||
return self in (
|
||||
UserRole.CLIENT_ADMIN,
|
||||
UserRole.CLIENT_USER,
|
||||
)
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""Modelo de Usuario."""
|
||||
@@ -113,11 +131,8 @@ class User(Base):
|
||||
|
||||
@property
|
||||
def is_client(self) -> bool:
|
||||
"""Check if user is a client."""
|
||||
return self.role in [
|
||||
UserRole.CLIENT_ADMIN,
|
||||
UserRole.CLIENT_USER
|
||||
]
|
||||
"""Check if user is a client (rol acotado al propio tenant)."""
|
||||
return self.role.is_client
|
||||
|
||||
@property
|
||||
def can_manage_users(self) -> bool:
|
||||
@@ -125,7 +140,7 @@ class User(Base):
|
||||
return self.role in [
|
||||
UserRole.ADMIN,
|
||||
UserRole.SUPPORT_MANAGER,
|
||||
UserRole.CLIENT_ADMIN
|
||||
UserRole.CLIENT_ADMIN,
|
||||
]
|
||||
|
||||
@property
|
||||
@@ -134,7 +149,7 @@ class User(Base):
|
||||
return self.role in [
|
||||
UserRole.ADMIN,
|
||||
UserRole.SUPPORT_MANAGER,
|
||||
UserRole.AGENT
|
||||
UserRole.AGENT,
|
||||
]
|
||||
|
||||
@property
|
||||
|
||||
170
backend/app/services/ticket_service.py
Normal file
170
backend/app/services/ticket_service.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
Ticket Service - ServiceManagerWeb
|
||||
|
||||
Lógica de negocio para creación y gestión de tickets.
|
||||
Inyectable vía Depends() en los endpoints de FastAPI.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.ticket import Ticket, TicketStatus, TicketPriority
|
||||
from app.models.category import Category
|
||||
from app.models.system import System
|
||||
from app.api.schemas.ticket import TicketCreate
|
||||
from app.api.v1.helpers import (
|
||||
generate_next_ticket_number,
|
||||
calculate_sla_deadlines,
|
||||
safe_audit_log,
|
||||
)
|
||||
|
||||
|
||||
class TicketService:
|
||||
"""Servicio de tickets: encapsula lógica de negocio fuera del router."""
|
||||
|
||||
def __init__(self, db: AsyncSession = Depends(get_db)):
|
||||
self.db = db
|
||||
|
||||
async def create_ticket(
|
||||
self,
|
||||
ticket: TicketCreate,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> dict:
|
||||
"""
|
||||
Crea un ticket con validación multi-tenant, cálculo de SLA y auto-asignación.
|
||||
|
||||
Args:
|
||||
ticket: Datos del ticket a crear.
|
||||
tenant_id: Tenant del usuario autenticado.
|
||||
user_id: ID del usuario que crea el ticket.
|
||||
|
||||
Returns:
|
||||
dict compatible con TicketResponse.
|
||||
|
||||
Raises:
|
||||
HTTPException 400: UUID inválido, categoría/sistema no encontrado o de otro tenant.
|
||||
HTTPException 500: Fallo persistente tras max_retries.
|
||||
"""
|
||||
max_retries = 3
|
||||
last_error = None
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
ticket_number = await generate_next_ticket_number(self.db, tenant_id)
|
||||
category_uuid = uuid.UUID(ticket.category_id) if ticket.category_id else None
|
||||
system_uuid = uuid.UUID(ticket.affected_system_id) if ticket.affected_system_id else None
|
||||
|
||||
category = None
|
||||
if category_uuid:
|
||||
category = await self.db.get(Category, category_uuid)
|
||||
if not category or category.tenant_id != tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"La categoría con ID {ticket.category_id} no existe.",
|
||||
)
|
||||
|
||||
if system_uuid:
|
||||
system = await self.db.get(System, system_uuid)
|
||||
if not system or system.tenant_id != tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"El sistema con ID {ticket.affected_system_id} no existe.",
|
||||
)
|
||||
|
||||
sla_response_due, sla_resolution_due = calculate_sla_deadlines(category)
|
||||
assigned_to_user = category.auto_assign_to if category and category.auto_assign_to else None
|
||||
|
||||
db_ticket = Ticket(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=tenant_id,
|
||||
ticket_number=ticket_number,
|
||||
subject=ticket.subject,
|
||||
description=ticket.description,
|
||||
category_id=category_uuid,
|
||||
affected_system_id=system_uuid,
|
||||
priority=TicketPriority[ticket.priority.upper()],
|
||||
created_by=user_id,
|
||||
assigned_to=assigned_to_user,
|
||||
status=TicketStatus.NEW,
|
||||
sla_response_due=sla_response_due,
|
||||
sla_resolution_due=sla_resolution_due,
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
|
||||
self.db.add(db_ticket)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(db_ticket)
|
||||
|
||||
await safe_audit_log(
|
||||
db=self.db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
action="ticket.create",
|
||||
resource_type="ticket",
|
||||
resource_id=db_ticket.id,
|
||||
new_values={
|
||||
"ticket_number": db_ticket.ticket_number,
|
||||
"subject": db_ticket.subject,
|
||||
"priority": db_ticket.priority.value,
|
||||
"status": db_ticket.status.value,
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"id": str(db_ticket.id),
|
||||
"ticket_number": db_ticket.ticket_number,
|
||||
"subject": db_ticket.subject,
|
||||
"title": db_ticket.subject,
|
||||
"description": db_ticket.description,
|
||||
"status": db_ticket.status.value,
|
||||
"priority": db_ticket.priority.value,
|
||||
"category_id": str(db_ticket.category_id) if db_ticket.category_id else None,
|
||||
"affected_system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None,
|
||||
"system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None,
|
||||
"contact_email": ticket.contact_email,
|
||||
"contact_phone": ticket.contact_phone,
|
||||
"created_by": str(db_ticket.created_by),
|
||||
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None,
|
||||
"created_at": db_ticket.created_at,
|
||||
"updated_at": db_ticket.updated_at,
|
||||
"sla_response_due": db_ticket.sla_response_due,
|
||||
"sla_resolution_due": db_ticket.sla_resolution_due,
|
||||
"first_response_at": db_ticket.first_response_at,
|
||||
"resolved_at": db_ticket.resolved_at,
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
await self.db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid UUID format: {str(e)}",
|
||||
)
|
||||
except HTTPException:
|
||||
await self.db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
await self.db.rollback()
|
||||
last_error = e
|
||||
if "duplicate key" in str(e).lower() and "ticket_number" in str(e).lower():
|
||||
if attempt < max_retries - 1:
|
||||
continue
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Error creating ticket: {str(e)}",
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"No se pudo crear el ticket después de {max_retries} intentos: {str(last_error)}",
|
||||
)
|
||||
|
||||
|
||||
def get_ticket_service(db: AsyncSession = Depends(get_db)) -> TicketService:
|
||||
"""Factory function para inyectar TicketService vía Depends()."""
|
||||
return TicketService(db)
|
||||
373
backend/app/tests/conftest.py
Normal file
373
backend/app/tests/conftest.py
Normal file
@@ -0,0 +1,373 @@
|
||||
"""
|
||||
Integration Test Fixtures - ServiceManagerWeb (Docker / PostgreSQL)
|
||||
|
||||
backend/app/tests/conftest.py
|
||||
|
||||
Usa la BD Docker existente (servicemanager).
|
||||
Los fixtures leen datos reales ya seedeados — no crean ni eliminan nada.
|
||||
Los tests que inserten datos propios quedan aislados por rollback.
|
||||
|
||||
Tenant de referencia : aduanasoft
|
||||
Usuarios de referencia:
|
||||
admin@aduanasoft.com → ADMIN
|
||||
manager@aduanasoft.com → SUPPORT_MANAGER
|
||||
agente@aduanasoft.com → AGENT
|
||||
auditor1@test.com → AUDITOR (tenant aduanasoft)
|
||||
admin-cliente@empresa-demo → CLIENT_ADMIN
|
||||
test_user@aduanasoft.com → CLIENT_USER
|
||||
"""
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
import pytest
|
||||
from typing import AsyncGenerator, Generator
|
||||
|
||||
# ============================================================
|
||||
# ENV VARS — antes de importar la app
|
||||
# ============================================================
|
||||
os.environ.setdefault("ENVIRONMENT", "testing")
|
||||
os.environ.setdefault("TESTING", "true")
|
||||
os.environ.setdefault("DEBUG", "false")
|
||||
os.environ.setdefault("SECRET_KEY", "integration-secret-key-32chars!!!!")
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "integration-jwt-secret-32chars!!!!")
|
||||
os.environ.setdefault(
|
||||
"DATABASE_URL",
|
||||
"postgresql+asyncpg://servicemanager:servicemanager123@localhost:5432/servicemanager",
|
||||
)
|
||||
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/14")
|
||||
os.environ.setdefault("CELERY_BROKER_URL", "redis://localhost:6379/14")
|
||||
os.environ.setdefault("CELERY_RESULT_BACKEND", "redis://localhost:6379/14")
|
||||
os.environ.setdefault("CORS_ORIGINS", "http://localhost:3000")
|
||||
os.environ.setdefault("ALLOWED_FILE_EXTENSIONS", "pdf,jpg,jpeg,png,doc,docx,txt")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# EVENT LOOP (session-scoped)
|
||||
# ============================================================
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop() -> Generator:
|
||||
"""Event loop compartido para toda la sesión de tests."""
|
||||
policy = asyncio.get_event_loop_policy()
|
||||
loop = policy.new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# ENGINE (session-scoped — reutiliza el pool toda la sesión)
|
||||
# ============================================================
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def engine():
|
||||
"""
|
||||
Conecta al PostgreSQL Docker existente (servicemanager).
|
||||
NO crea ni destruye el schema — la BD ya está lista.
|
||||
"""
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
import app.models # noqa: F401 — registra todos los modelos
|
||||
|
||||
_engine = create_async_engine(os.environ["DATABASE_URL"], echo=False)
|
||||
yield _engine
|
||||
await _engine.dispose()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# DB (function-scoped — rollback para datos creados en el test)
|
||||
# ============================================================
|
||||
|
||||
@pytest.fixture
|
||||
async def db(engine) -> AsyncGenerator:
|
||||
"""
|
||||
Sesión con transacción por test.
|
||||
Los datos seedeados son visibles (ya están committed).
|
||||
Cualquier INSERT hecho en el test se revierte al finalizar.
|
||||
"""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with factory() as session:
|
||||
await session.begin()
|
||||
yield session
|
||||
await session.rollback()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# TENANT (function-scoped — lee el registro existente)
|
||||
# ============================================================
|
||||
|
||||
@pytest.fixture
|
||||
async def tenant_a(db):
|
||||
"""Tenant 'aduanasoft' ya existente en la BD."""
|
||||
from sqlalchemy import select
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
result = await db.execute(select(Tenant).where(Tenant.slug == "aduanasoft"))
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# USUARIOS (function-scoped — leen registros existentes)
|
||||
# ============================================================
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_user(db, tenant_a):
|
||||
"""ADMIN: admin@aduanasoft.com (tenant aduanasoft)."""
|
||||
from sqlalchemy import select
|
||||
from app.models.user import User
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.email == "admin@aduanasoft.com")
|
||||
.where(User.tenant_id == tenant_a.id)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def manager_user(db, tenant_a):
|
||||
"""SUPPORT_MANAGER: manager@aduanasoft.com (tenant aduanasoft)."""
|
||||
from sqlalchemy import select
|
||||
from app.models.user import User
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.email == "manager@aduanasoft.com")
|
||||
.where(User.tenant_id == tenant_a.id)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def agent_user(db, tenant_a):
|
||||
"""AGENT: agente@aduanasoft.com (tenant aduanasoft)."""
|
||||
from sqlalchemy import select
|
||||
from app.models.user import User
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.email == "agente@aduanasoft.com")
|
||||
.where(User.tenant_id == tenant_a.id)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def user_tenant_a(db, tenant_a):
|
||||
"""CLIENT_USER: test_user@aduanasoft.com (tenant aduanasoft)."""
|
||||
from sqlalchemy import select
|
||||
from app.models.user import User
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.email == "test_user@aduanasoft.com")
|
||||
.where(User.tenant_id == tenant_a.id)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# HTTP CLIENT (function-scoped)
|
||||
# ============================================================
|
||||
|
||||
@pytest.fixture
|
||||
async def client(db) -> AsyncGenerator:
|
||||
"""
|
||||
httpx.AsyncClient contra la app FastAPI en memoria (sin red).
|
||||
get_db queda sobreescrito para inyectar la sesión de test.
|
||||
Los cambios del test se revierten al terminar (rollback en db).
|
||||
"""
|
||||
import httpx
|
||||
from httpx import ASGITransport
|
||||
from app.main import app
|
||||
from app.core.database import get_db
|
||||
|
||||
async def _override_get_db():
|
||||
yield db
|
||||
|
||||
app.dependency_overrides[get_db] = _override_get_db
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
) as ac:
|
||||
yield ac
|
||||
|
||||
app.dependency_overrides.pop(get_db, None)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# FIXTURES DE AISLAMIENTO MULTI-TENANT
|
||||
# ============================================================
|
||||
|
||||
@pytest.fixture
|
||||
def make_token():
|
||||
"""Factory de JWT tokens para autenticar clientes HTTP en tests."""
|
||||
from app.core.security import security
|
||||
|
||||
def _make(user):
|
||||
return security.create_access_token(data={"sub": str(user.id)})
|
||||
|
||||
return _make
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def app_with_db(db):
|
||||
"""
|
||||
Override de get_db compartido para todos los HTTP clients de un mismo test.
|
||||
Garantiza que todos los clients usen la misma sesión (y el mismo rollback).
|
||||
"""
|
||||
from app.main import app as _app
|
||||
from app.core.database import get_db
|
||||
|
||||
async def _override():
|
||||
yield db
|
||||
|
||||
_app.dependency_overrides[get_db] = _override
|
||||
yield _app
|
||||
_app.dependency_overrides.pop(get_db, None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def tenant_b(db):
|
||||
"""Tenant 'empresa-test' creado en la transacción del test (se revierte al final)."""
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
t = Tenant(name="Empresa Test", slug="empresa-test")
|
||||
db.add(t)
|
||||
await db.flush()
|
||||
return t
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def user_b(db, tenant_b):
|
||||
"""CLIENT_ADMIN en tenant_b — puede gestionar recursos de su tenant."""
|
||||
from app.models.user import User, UserRole
|
||||
from app.core.security import security
|
||||
|
||||
u = User(
|
||||
tenant_id=tenant_b.id,
|
||||
email="admin@empresa-test.com",
|
||||
first_name="Admin",
|
||||
last_name="Test",
|
||||
password_hash=security.hash_password("Test1234!"),
|
||||
role=UserRole.CLIENT_ADMIN,
|
||||
is_active=True,
|
||||
email_verified=True,
|
||||
)
|
||||
db.add(u)
|
||||
await db.flush()
|
||||
return u
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client_tenant_a(app_with_db, manager_user, make_token):
|
||||
"""HTTP client autenticado como SUPPORT_MANAGER de tenant_a (aduanasoft).
|
||||
Usa manager_user en lugar de admin_user para mantener el aislamiento de
|
||||
tenant en GET /users/ (el ADMIN global bypasa el filtro de tenant).
|
||||
"""
|
||||
import httpx
|
||||
from httpx import ASGITransport
|
||||
|
||||
token = make_token(manager_user)
|
||||
async with httpx.AsyncClient(
|
||||
transport=ASGITransport(app=app_with_db),
|
||||
base_url="http://test",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
) as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client_tenant_b(app_with_db, user_b, make_token):
|
||||
"""HTTP client autenticado como CLIENT_ADMIN de tenant_b (empresa-test)."""
|
||||
import httpx
|
||||
from httpx import ASGITransport
|
||||
|
||||
token = make_token(user_b)
|
||||
async with httpx.AsyncClient(
|
||||
transport=ASGITransport(app=app_with_db),
|
||||
base_url="http://test",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
) as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client_admin(app_with_db, admin_user, make_token):
|
||||
"""HTTP client autenticado como ADMIN global."""
|
||||
import httpx
|
||||
from httpx import ASGITransport
|
||||
|
||||
token = make_token(admin_user)
|
||||
async with httpx.AsyncClient(
|
||||
transport=ASGITransport(app=app_with_db),
|
||||
base_url="http://test",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
) as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_ticket_tenant_a(client_tenant_a):
|
||||
"""Factory: crea un ticket en tenant_a vía HTTP y retorna el JSON de respuesta."""
|
||||
async def _create(subject="Ticket Tenant A", priority="MEDIUM"):
|
||||
resp = await client_tenant_a.post("/v1/tickets/", json={
|
||||
"subject": subject,
|
||||
"description": "Test de aislamiento tenant A",
|
||||
"priority": priority,
|
||||
})
|
||||
assert resp.status_code in (200, 201), f"Error creando ticket A: {resp.text}"
|
||||
return resp.json()
|
||||
|
||||
return _create
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_ticket_tenant_b(client_tenant_b):
|
||||
"""Factory: crea un ticket en tenant_b vía HTTP y retorna el JSON de respuesta."""
|
||||
async def _create(subject="Ticket Tenant B", priority="MEDIUM"):
|
||||
resp = await client_tenant_b.post("/v1/tickets/", json={
|
||||
"subject": subject,
|
||||
"description": "Test de aislamiento tenant B",
|
||||
"priority": priority,
|
||||
})
|
||||
assert resp.status_code in (200, 201), f"Error creando ticket B: {resp.text}"
|
||||
return resp.json()
|
||||
|
||||
return _create
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_user_tenant_a(client_tenant_a):
|
||||
"""Factory: crea un usuario en tenant_a vía HTTP y retorna el JSON de respuesta."""
|
||||
async def _create(email="nuevo_user_a@test.com"):
|
||||
resp = await client_tenant_a.post("/v1/users/", json={
|
||||
"email": email,
|
||||
"first_name": "Usuario",
|
||||
"last_name": "TenantA",
|
||||
"password": "Test1234!",
|
||||
"role": "CLIENT_USER",
|
||||
})
|
||||
assert resp.status_code in (200, 201), f"Error creando user A: {resp.text}"
|
||||
return resp.json()
|
||||
|
||||
return _create
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_user_tenant_b(client_tenant_b):
|
||||
"""Factory: crea un usuario en tenant_b vía HTTP y retorna el JSON de respuesta."""
|
||||
async def _create(email="nuevo_user_b@test.com"):
|
||||
resp = await client_tenant_b.post("/v1/users/", json={
|
||||
"email": email,
|
||||
"first_name": "Usuario",
|
||||
"last_name": "TenantB",
|
||||
"password": "Test1234!",
|
||||
"role": "CLIENT_USER",
|
||||
})
|
||||
assert resp.status_code in (200, 201), f"Error creando user B: {resp.text}"
|
||||
return resp.json()
|
||||
|
||||
return _create
|
||||
137
backend/app/tests/test_smoke.py
Normal file
137
backend/app/tests/test_smoke.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
Smoke Tests - ServiceManagerWeb
|
||||
|
||||
Verifican que el stack completo funciona:
|
||||
- Conexión a BD Docker
|
||||
- Fixtures de tenant y usuarios
|
||||
- Login vía HTTP (httpx + FastAPI en memoria)
|
||||
- Endpoint protegido con token
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ============================================================
|
||||
# BD + FIXTURES
|
||||
# ============================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_db_connected(db):
|
||||
"""La sesión de BD está activa y responde."""
|
||||
from sqlalchemy import text
|
||||
result = await db.execute(text("SELECT 1"))
|
||||
assert result.scalar() == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tenant_a_existe(tenant_a):
|
||||
"""El tenant 'aduanasoft' existe y tiene datos válidos."""
|
||||
assert tenant_a.slug == "aduanasoft"
|
||||
assert tenant_a.name is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_user_existe(admin_user):
|
||||
"""El usuario ADMIN existe y pertenece al tenant correcto."""
|
||||
from app.models.user import UserRole
|
||||
assert admin_user.email == "admin@aduanasoft.com"
|
||||
assert admin_user.role == UserRole.ADMIN
|
||||
assert admin_user.is_active is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_user_existe(manager_user):
|
||||
"""El usuario SUPPORT_MANAGER existe."""
|
||||
from app.models.user import UserRole
|
||||
assert manager_user.email == "manager@aduanasoft.com"
|
||||
assert manager_user.role == UserRole.SUPPORT_MANAGER
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_user_existe(agent_user):
|
||||
"""El usuario AGENT existe."""
|
||||
from app.models.user import UserRole
|
||||
assert agent_user.email == "agente@aduanasoft.com"
|
||||
assert agent_user.role == UserRole.AGENT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_user_existe(user_tenant_a):
|
||||
"""El CLIENT_USER existe."""
|
||||
from app.models.user import UserRole
|
||||
assert user_tenant_a.email == "test_user@aduanasoft.com"
|
||||
assert user_tenant_a.role == UserRole.CLIENT_USER
|
||||
|
||||
|
||||
# ============================================================
|
||||
# HTTP — LOGIN
|
||||
# ============================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_admin_ok(client):
|
||||
"""Login con credenciales de admin devuelve access_token."""
|
||||
response = await client.post(
|
||||
"/v1/auth/login",
|
||||
json={
|
||||
"email": "admin@aduanasoft.com",
|
||||
"password": "admin123",
|
||||
"tenant_slug": "aduanasoft",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_credenciales_invalidas(client):
|
||||
"""Login con contraseña incorrecta devuelve 401."""
|
||||
response = await client.post(
|
||||
"/v1/auth/login",
|
||||
json={
|
||||
"email": "admin@aduanasoft.com",
|
||||
"password": "wrongpassword",
|
||||
"tenant_slug": "aduanasoft",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_endpoint_sin_token_devuelve_401(client):
|
||||
"""Acceder a un endpoint protegido sin token devuelve 401."""
|
||||
response = await client.get(
|
||||
"/v1/users/me",
|
||||
headers={"X-Tenant-Slug": "aduanasoft"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_y_me(client):
|
||||
"""Login exitoso → /users/me devuelve el usuario correcto."""
|
||||
# Login
|
||||
login = await client.post(
|
||||
"/v1/auth/login",
|
||||
json={
|
||||
"email": "admin@aduanasoft.com",
|
||||
"password": "admin123",
|
||||
"tenant_slug": "aduanasoft",
|
||||
},
|
||||
)
|
||||
assert login.status_code == 200
|
||||
token = login.json()["access_token"]
|
||||
|
||||
# Endpoint protegido
|
||||
me = await client.get(
|
||||
"/v1/users/me",
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"X-Tenant-Slug": "aduanasoft",
|
||||
},
|
||||
)
|
||||
assert me.status_code == 200
|
||||
data = me.json()
|
||||
assert data["email"] == "admin@aduanasoft.com"
|
||||
assert data["role"] == "ADMIN"
|
||||
123
backend/app/tests/test_tenant_isolation.py
Normal file
123
backend/app/tests/test_tenant_isolation.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
Pruebas de aislamiento multi-tenant para tickets y usuarios.
|
||||
Usa solo los fixtures definidos en conftest.py.
|
||||
|
||||
Roles en juego:
|
||||
client_tenant_a → SUPPORT_MANAGER (aduanasoft) — restringido a su tenant
|
||||
client_tenant_b → CLIENT_ADMIN (empresa-test) — restringido a su tenant
|
||||
client_admin → ADMIN global (aduanasoft) — acceso a todos los tenants
|
||||
"""
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tenant_a_cannot_see_tenant_b_tickets(
|
||||
client_tenant_a, create_ticket_tenant_b
|
||||
):
|
||||
"""
|
||||
El usuario del tenant B crea un ticket.
|
||||
El usuario del tenant A (SUPPORT_MANAGER) lista sus tickets.
|
||||
El ticket de tenant B NO debe aparecer en la respuesta.
|
||||
"""
|
||||
# El usuario del tenant B crea un ticket
|
||||
ticket_b = await create_ticket_tenant_b()
|
||||
ticket_b_id = ticket_b["id"]
|
||||
|
||||
# El usuario del tenant A lista sus tickets
|
||||
response = await client_tenant_a.get("/v1/tickets/")
|
||||
assert response.status_code == 200
|
||||
|
||||
ids_visibles = {t["id"] for t in response.json()}
|
||||
|
||||
# El ticket de tenant B no debe ser visible para tenant A
|
||||
assert ticket_b_id not in ids_visibles, (
|
||||
f"Fallo de aislamiento: ticket de tenant B ({ticket_b_id}) "
|
||||
f"visible para usuario de tenant A"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tenant_b_cannot_edit_tenant_a_ticket(
|
||||
create_ticket_tenant_a, client_tenant_b
|
||||
):
|
||||
"""
|
||||
El usuario del tenant A crea un ticket.
|
||||
El usuario del tenant B intenta editar ese ticket vía PATCH.
|
||||
Debe recibir 403 (prohibido) o 404 (no encontrado).
|
||||
"""
|
||||
# El usuario del tenant A crea un ticket
|
||||
ticket_a = await create_ticket_tenant_a()
|
||||
ticket_a_id = ticket_a["id"]
|
||||
|
||||
# El usuario del tenant B intenta editar el ticket de tenant A
|
||||
response = await client_tenant_b.patch(
|
||||
f"/v1/tickets/{ticket_a_id}",
|
||||
json={"status": "CLOSED"},
|
||||
)
|
||||
|
||||
# Debe recibir 403 o 404 — nunca 200
|
||||
assert response.status_code in (403, 404), (
|
||||
f"Fallo de aislamiento: tenant B pudo editar ticket de tenant A "
|
||||
f"(HTTP {response.status_code})"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tenant_a_cannot_see_tenant_b_users(
|
||||
client_tenant_a, create_user_tenant_b
|
||||
):
|
||||
"""
|
||||
El usuario del tenant B crea un usuario nuevo.
|
||||
El usuario del tenant A (SUPPORT_MANAGER) lista los usuarios.
|
||||
El usuario de tenant B NO debe aparecer en la respuesta.
|
||||
"""
|
||||
# El usuario del tenant B crea un usuario
|
||||
user_b = await create_user_tenant_b()
|
||||
user_b_id = user_b["id"]
|
||||
|
||||
# El usuario del tenant A lista los usuarios de su tenant
|
||||
response = await client_tenant_a.get("/v1/users/")
|
||||
assert response.status_code == 200
|
||||
|
||||
ids_visibles = {u["id"] for u in response.json()}
|
||||
|
||||
# El usuario de tenant B no debe ser visible para tenant A
|
||||
assert user_b_id not in ids_visibles, (
|
||||
f"Fallo de aislamiento: usuario de tenant B ({user_b_id}) "
|
||||
f"visible para usuario de tenant A"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_sees_all_tenant_data(
|
||||
client_admin,
|
||||
create_ticket_tenant_a,
|
||||
create_ticket_tenant_b,
|
||||
create_user_tenant_a,
|
||||
create_user_tenant_b,
|
||||
):
|
||||
"""
|
||||
El ADMIN global debe poder ver tickets y usuarios de TODOS los tenants.
|
||||
- Tickets: vía /v1/tickets/admin/all (endpoint multi-tenant).
|
||||
- Usuarios: vía /v1/users/ (ADMIN bypasa el filtro de tenant).
|
||||
"""
|
||||
# Crear datos en ambos tenants
|
||||
ticket_a = await create_ticket_tenant_a()
|
||||
ticket_b = await create_ticket_tenant_b()
|
||||
user_a = await create_user_tenant_a()
|
||||
user_b = await create_user_tenant_b()
|
||||
|
||||
# El admin lista todos los tickets (endpoint multi-tenant)
|
||||
resp_tickets = await client_admin.get("/v1/tickets/admin/all")
|
||||
assert resp_tickets.status_code == 200
|
||||
ids_tickets = {t["id"] for t in resp_tickets.json()}
|
||||
assert ticket_a["id"] in ids_tickets, "El ADMIN no ve el ticket de tenant A"
|
||||
assert ticket_b["id"] in ids_tickets, "El ADMIN no ve el ticket de tenant B"
|
||||
|
||||
# El admin lista todos los usuarios (ADMIN bypasa filtro de tenant)
|
||||
resp_users = await client_admin.get("/v1/users/")
|
||||
assert resp_users.status_code == 200
|
||||
ids_users = {u["id"] for u in resp_users.json()}
|
||||
assert user_a["id"] in ids_users, "El ADMIN no ve el usuario de tenant A"
|
||||
assert user_b["id"] in ids_users, "El ADMIN no ve el usuario de tenant B"
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Add CLIENT_MANAGER, CLIENT_AGENT, CLIENT_AUDITOR to user_role_enum
|
||||
|
||||
Revision ID: c1d2e3f4a5b6
|
||||
Revises: b7c8d9e0f1a2
|
||||
Create Date: 2026-03-03 10:00:00.000000
|
||||
|
||||
Agrega tres nuevos roles de cliente al enum PostgreSQL:
|
||||
- CLIENT_MANAGER → gestiona tickets y usuarios del tenant
|
||||
- CLIENT_AGENT → atiende tickets del tenant
|
||||
- CLIENT_AUDITOR → auditoría de solo lectura del tenant
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'c1d2e3f4a5b6'
|
||||
down_revision = 'b7c8d9e0f1a2'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# PostgreSQL permite agregar valores a un enum con ADD VALUE.
|
||||
# IF NOT EXISTS evita error si la migración se aplica dos veces.
|
||||
op.execute("ALTER TYPE user_role_enum ADD VALUE IF NOT EXISTS 'CLIENT_MANAGER'")
|
||||
op.execute("ALTER TYPE user_role_enum ADD VALUE IF NOT EXISTS 'CLIENT_AGENT'")
|
||||
op.execute("ALTER TYPE user_role_enum ADD VALUE IF NOT EXISTS 'CLIENT_AUDITOR'")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# PostgreSQL no permite eliminar valores de un enum con ALTER TYPE DROP VALUE.
|
||||
# Para revertir habría que recrear el tipo completo desde cero, lo que requiere
|
||||
# actualizar todas las columnas que lo usan. Se documenta como no reversible
|
||||
# automáticamente — usar con precaución.
|
||||
pass
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Remove CLIENT_MANAGER, CLIENT_AGENT, CLIENT_AUDITOR from user_role_enum
|
||||
|
||||
Revision ID: d2e3f4a5b6c7
|
||||
Revises: c1d2e3f4a5b6
|
||||
Create Date: 2026-03-03 14:00:00.000000
|
||||
|
||||
Consolida 9 roles → 6 roles migrando datos primero y luego recreando
|
||||
el tipo enum de PostgreSQL (única forma de eliminar valores en PG).
|
||||
|
||||
Mapeo de datos:
|
||||
CLIENT_MANAGER → CLIENT_ADMIN (conserva nivel de gestión)
|
||||
CLIENT_AGENT → CLIENT_USER (acceso básico de cliente)
|
||||
CLIENT_AUDITOR → CLIENT_USER (acceso básico de cliente)
|
||||
|
||||
ADVERTENCIA DOWNGRADE: La migración inversa restaura los valores del
|
||||
enum pero NO puede recuperar la distinción original entre CLIENT_AGENT
|
||||
y CLIENT_AUDITOR (ambos quedaron como CLIENT_USER). El downgrade es
|
||||
seguro a nivel de integridad de datos, pero irreversible en semántica.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'd2e3f4a5b6c7'
|
||||
down_revision = 'c1d2e3f4a5b6'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ── Paso 1: Migrar datos ANTES de modificar el tipo ────────────────────
|
||||
# CLIENT_MANAGER → CLIENT_ADMIN (conserva acceso de gestión)
|
||||
op.execute("UPDATE users SET role = 'CLIENT_ADMIN' WHERE role = 'CLIENT_MANAGER'")
|
||||
# CLIENT_AGENT → CLIENT_USER (acceso básico de cliente)
|
||||
op.execute("UPDATE users SET role = 'CLIENT_USER' WHERE role = 'CLIENT_AGENT'")
|
||||
# CLIENT_AUDITOR → CLIENT_USER (acceso básico de cliente)
|
||||
op.execute("UPDATE users SET role = 'CLIENT_USER' WHERE role = 'CLIENT_AUDITOR'")
|
||||
|
||||
# ── Paso 2: Soltar la restricción de tipo para poder recrear el enum ───
|
||||
# PostgreSQL no permite DROP VALUE en un enum; hay que recrear el tipo.
|
||||
op.execute("ALTER TABLE users ALTER COLUMN role TYPE TEXT")
|
||||
|
||||
# ── Paso 3: Eliminar tipo actual y recrearlo solo con los 6 roles ──────
|
||||
op.execute("DROP TYPE user_role_enum")
|
||||
op.execute("""
|
||||
CREATE TYPE user_role_enum AS ENUM (
|
||||
'ADMIN',
|
||||
'SUPPORT_MANAGER',
|
||||
'AGENT',
|
||||
'AUDITOR',
|
||||
'CLIENT_ADMIN',
|
||||
'CLIENT_USER'
|
||||
)
|
||||
""")
|
||||
|
||||
# ── Paso 4: Restaurar columna al tipo enum ──────────────────────────────
|
||||
op.execute(
|
||||
"ALTER TABLE users ALTER COLUMN role TYPE user_role_enum "
|
||||
"USING role::user_role_enum"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ── Paso 1: Soltar la restricción de tipo para recrear el enum ─────────
|
||||
op.execute("ALTER TABLE users ALTER COLUMN role TYPE TEXT")
|
||||
|
||||
# ── Paso 2: Recrear enum con los 9 valores originales ──────────────────
|
||||
op.execute("DROP TYPE user_role_enum")
|
||||
op.execute("""
|
||||
CREATE TYPE user_role_enum AS ENUM (
|
||||
'ADMIN',
|
||||
'SUPPORT_MANAGER',
|
||||
'AGENT',
|
||||
'AUDITOR',
|
||||
'CLIENT_ADMIN',
|
||||
'CLIENT_MANAGER',
|
||||
'CLIENT_AGENT',
|
||||
'CLIENT_AUDITOR',
|
||||
'CLIENT_USER'
|
||||
)
|
||||
""")
|
||||
|
||||
# ── Paso 3: Restaurar columna al tipo enum ──────────────────────────────
|
||||
op.execute(
|
||||
"ALTER TABLE users ALTER COLUMN role TYPE user_role_enum "
|
||||
"USING role::user_role_enum"
|
||||
)
|
||||
|
||||
# ── Nota sobre pérdida de datos ─────────────────────────────────────────
|
||||
# Los usuarios que eran CLIENT_MANAGER ahora son CLIENT_ADMIN.
|
||||
# Los usuarios que eran CLIENT_AGENT o CLIENT_AUDITOR ahora son CLIENT_USER.
|
||||
# No es posible restaurar la distinción original automáticamente.
|
||||
67
fix_login.py
Normal file
67
fix_login.py
Normal file
@@ -0,0 +1,67 @@
|
||||
import re
|
||||
|
||||
with open("/app/app/api/v1/endpoints/auth.py", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
old = ''' # 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",
|
||||
)'''
|
||||
|
||||
new = ''' # 1. Validar tenant - por slug si viene, sino detectar por email
|
||||
if login_data.tenant_slug:
|
||||
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:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tenant not found",
|
||||
)
|
||||
else:
|
||||
tenant = None'''
|
||||
|
||||
if old in content:
|
||||
content = content.replace(old, new)
|
||||
print("OK: bloque tenant reemplazado")
|
||||
else:
|
||||
print("ERROR: bloque no encontrado")
|
||||
|
||||
# Tambien actualizar la query de usuario para usar tenant o no
|
||||
old2 = ''' # 2. Buscar usuario en base de datos (aislado por tenant)
|
||||
query = select(User).where(
|
||||
User.email == login_data.email,
|
||||
User.tenant_id == tenant.id,
|
||||
)'''
|
||||
|
||||
new2 = ''' # 2. Buscar usuario - filtrar por tenant si se detecto, sino buscar por email
|
||||
if tenant:
|
||||
query = select(User).where(
|
||||
User.email == login_data.email,
|
||||
User.tenant_id == tenant.id,
|
||||
)
|
||||
else:
|
||||
query = select(User).where(User.email == login_data.email)'''
|
||||
|
||||
if old2 in content:
|
||||
content = content.replace(old2, new2)
|
||||
print("OK: bloque query reemplazado")
|
||||
else:
|
||||
print("ERROR: bloque query no encontrado")
|
||||
|
||||
with open("/app/app/api/v1/endpoints/auth.py", "w") as f:
|
||||
f.write(content)
|
||||
|
||||
print("Listo")
|
||||
23
fix_ratelimit.py
Normal file
23
fix_ratelimit.py
Normal file
@@ -0,0 +1,23 @@
|
||||
with open("/app/app/api/v1/endpoints/auth.py", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
old = ''' # 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)'''
|
||||
|
||||
new = ''' # Rate limiting (best-effort): by (tenant,email) to slow brute force.
|
||||
ident_key = None
|
||||
if settings.RATE_LIMIT_ENABLED and not settings.TESTING and tenant:
|
||||
email_norm = login_data.email.strip().lower()
|
||||
ident_key = cache_key("rl", "login", "id", str(tenant.id), email_norm)'''
|
||||
|
||||
if old in content:
|
||||
content = content.replace(old, new)
|
||||
print("OK: rate limiting fix aplicado")
|
||||
else:
|
||||
print("ERROR: bloque no encontrado")
|
||||
|
||||
with open("/app/app/api/v1/endpoints/auth.py", "w") as f:
|
||||
f.write(content)
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
let email = '';
|
||||
let password = '';
|
||||
let tenantSlug = 'aduanasoft-demo';
|
||||
let tenantSlug = 'ventas';
|
||||
let totpCode = '';
|
||||
let isLoading = false;
|
||||
let showTwoFactor = false;
|
||||
@@ -34,7 +34,7 @@
|
||||
await auth.login({
|
||||
email,
|
||||
password,
|
||||
tenant_slug: tenantSlug.trim() || 'aduanasoft-demo',
|
||||
tenant_slug: tenantSlug.trim() || 'ventas',
|
||||
totp_code: totpCode || undefined
|
||||
});
|
||||
|
||||
@@ -125,7 +125,7 @@
|
||||
<div
|
||||
class="p-3 rounded-md bg-red-50 border border-red-100 flex items-center gap-3 animate-fade-in text-sm text-red-600"
|
||||
>
|
||||
<Icon name="alert-circle" class="w-4 h-4 flex-shrink-0" />
|
||||
<Icon name="alert-circle" className="w-4 h-4 flex-shrink-0" />
|
||||
{errorMessage}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -141,7 +141,7 @@
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Icon
|
||||
name="mail"
|
||||
class="w-5 h-5 text-gray-400 group-focus-within:text-blue-600 transition-colors"
|
||||
className="w-5 h-5 text-gray-400 group-focus-within:text-blue-600 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
@@ -166,7 +166,7 @@
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Icon
|
||||
name="lock"
|
||||
class="w-5 h-5 text-gray-400 group-focus-within:text-blue-600 transition-colors"
|
||||
className="w-5 h-5 text-gray-400 group-focus-within:text-blue-600 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
{#if showPassword}
|
||||
@@ -197,7 +197,7 @@
|
||||
class="absolute inset-y-0 right-0 pr-3 flex items-center cursor-pointer text-gray-400 hover:text-gray-600 focus:outline-none"
|
||||
on:click={() => (showPassword = !showPassword)}
|
||||
>
|
||||
<Icon name={showPassword ? 'eye-off' : 'eye'} class="w-5 h-5" />
|
||||
<Icon name={showPassword ? 'eye-off' : 'eye'} className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -221,7 +221,7 @@
|
||||
class="text-sm font-medium text-blue-600 hover:text-blue-500 bg-transparent border-none p-0 cursor-pointer"
|
||||
on:click={() => goto('/forgot-password')}
|
||||
>
|
||||
Olvide mi clave
|
||||
Olvidé mi clave
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -235,7 +235,7 @@
|
||||
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Icon name="shield-check" class="w-5 h-5 text-blue-500" />
|
||||
<Icon name="shield-check" className="w-5 h-5 text-blue-500" />
|
||||
</div>
|
||||
<input
|
||||
id="code"
|
||||
@@ -259,7 +259,7 @@
|
||||
disabled={isLoading}
|
||||
>
|
||||
{#if isLoading}
|
||||
<Icon name="loader-2" class="w-5 h-5 animate-spin mr-2" />
|
||||
<Icon name="loader-2" className="w-5 h-5 animate-spin mr-2" />
|
||||
Procesando...
|
||||
{:else}
|
||||
{showTwoFactor ? 'Verificar Acceso' : 'Acceder al Portal'}
|
||||
|
||||
@@ -1,46 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import { auth } from '$lib/stores/auth.js';
|
||||
import { toast } from '$lib/stores/toast.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
|
||||
|
||||
let email = '';
|
||||
let password = '';
|
||||
let totpCode = '';
|
||||
let isLoading = false;
|
||||
let showTwoFactor = false;
|
||||
let errorMessage = '';
|
||||
|
||||
|
||||
onMount(() => {
|
||||
// Redirect if already authenticated
|
||||
if ($auth.isAuthenticated) {
|
||||
goto('/');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
async function handleLogin() {
|
||||
if (!email || !password) {
|
||||
errorMessage = 'Por favor completa todos los campos';
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
isLoading = true;
|
||||
errorMessage = '';
|
||||
|
||||
|
||||
try {
|
||||
await auth.login({
|
||||
email,
|
||||
password,
|
||||
tenant_slug: 'aduanasoft-demo',
|
||||
tenant_slug: 'aduanasoft',
|
||||
totp_code: totpCode || undefined
|
||||
});
|
||||
|
||||
|
||||
toast.success('¡Bienvenido! Has iniciado sesión correctamente');
|
||||
goto('/');
|
||||
} catch (error: any) {
|
||||
console.error('Login error:', error);
|
||||
|
||||
|
||||
// Check if 2FA is required
|
||||
if (error.message.includes('two-factor') || error.message.includes('2FA')) {
|
||||
showTwoFactor = true;
|
||||
@@ -53,7 +53,7 @@
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
handleLogin();
|
||||
@@ -61,44 +61,50 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<svelte:head>
|
||||
<title>Acceso Admin - ServiceManager</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-950 p-4 font-sans">
|
||||
<div class="w-full max-w-5xl grid grid-cols-1 md:grid-cols-2 bg-white dark:bg-gray-900 rounded-lg shadow-xl overflow-hidden border border-gray-200 dark:border-gray-800">
|
||||
|
||||
<div
|
||||
class="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-950 p-4 font-sans"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-5xl grid grid-cols-1 md:grid-cols-2 bg-white dark:bg-gray-900 rounded-lg shadow-xl overflow-hidden border border-gray-200 dark:border-gray-800"
|
||||
>
|
||||
<!-- Left Side: Internal Branding -->
|
||||
<div class="hidden md:flex flex-col justify-between p-12 bg-gray-900 text-white relative overflow-hidden">
|
||||
<div
|
||||
class="hidden md:flex flex-col justify-between p-12 bg-gray-900 text-white relative overflow-hidden"
|
||||
>
|
||||
<!-- Grid pattern overlay -->
|
||||
<div class="absolute inset-0 opacity-10" style="background-image: radial-gradient(white 1px, transparent 1px); background-size: 30px 30px;"></div>
|
||||
|
||||
<div
|
||||
class="absolute inset-0 opacity-10"
|
||||
style="background-image: radial-gradient(white 1px, transparent 1px); background-size: 30px 30px;"
|
||||
/>
|
||||
|
||||
<div class="relative z-10">
|
||||
<div class="flex items-center space-x-3 mb-6">
|
||||
<div class="p-2 bg-blue-500/20 rounded border border-blue-500/30">
|
||||
<Icon name="server" class="w-6 h-6 text-blue-400" />
|
||||
</div>
|
||||
<span class="text-sm font-mono tracking-wider text-blue-400">INTERNAL_ACCESS_V2</span>
|
||||
<div class="p-2 bg-blue-500/20 rounded border border-blue-500/30">
|
||||
<Icon name="server" className="w-6 h-6 text-blue-400" />
|
||||
</div>
|
||||
<span class="text-sm font-mono tracking-wider text-blue-400">INTERNAL_ACCESS_V2</span>
|
||||
</div>
|
||||
|
||||
<h1 class="text-3xl font-bold tracking-tight mb-4">
|
||||
Panel de Administración
|
||||
</h1>
|
||||
|
||||
<h1 class="text-3xl font-bold tracking-tight mb-4">Panel de Administración</h1>
|
||||
<p class="text-gray-400 text-sm leading-relaxed max-w-sm">
|
||||
Plataforma de gestión de servicios, monitoreo de tickets y administración de usuarios. Acceso restringido únicamente a personal autorizado.
|
||||
Plataforma de gestión de servicios, monitoreo de tickets y administración de usuarios.
|
||||
Acceso restringido únicamente a personal autorizado.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="relative z-10 mt-12">
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center space-x-3 text-xs text-gray-400 font-mono">
|
||||
<Icon name="check-circle" class="w-4 h-4 text-green-500" />
|
||||
<span>System Status: Operational</span>
|
||||
<Icon name="check-circle" className="w-4 h-4 text-green-500" />
|
||||
<span>System Status: Operational</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3 text-xs text-gray-400 font-mono">
|
||||
<Icon name="shield" class="w-4 h-4 text-blue-500" />
|
||||
<span>256-bit Encryption Enabled</span>
|
||||
<Icon name="shield" className="w-4 h-4 text-blue-500" />
|
||||
<span>256-bit Encryption Enabled</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -106,106 +112,129 @@
|
||||
|
||||
<!-- Right Side: Login Form -->
|
||||
<div class="p-8 md:p-12 flex flex-col justify-center">
|
||||
|
||||
<div class="max-w-sm mx-auto w-full">
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-1">Iniciar Sesión</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Acceso al sistema central</p>
|
||||
</div>
|
||||
<div class="max-w-sm mx-auto w-full">
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-1">Iniciar Sesión</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Acceso al sistema central</p>
|
||||
</div>
|
||||
|
||||
<form on:submit|preventDefault={handleLogin} class="space-y-5">
|
||||
{#if errorMessage}
|
||||
<div class="p-3 rounded-md bg-red-50 dark:bg-red-900/10 border border-red-200 dark:border-red-900 flex items-start gap-3">
|
||||
<Icon name="alert-triangle" class="w-5 h-5 text-red-600 dark:text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p class="text-sm text-red-600 dark:text-red-500">{errorMessage}</p>
|
||||
</div>
|
||||
{/if}
|
||||
<form on:submit|preventDefault={handleLogin} class="space-y-5">
|
||||
{#if errorMessage}
|
||||
<div
|
||||
class="p-3 rounded-md bg-red-50 dark:bg-red-900/10 border border-red-200 dark:border-red-900 flex items-start gap-3"
|
||||
>
|
||||
<Icon
|
||||
name="alert-triangle"
|
||||
className="w-5 h-5 text-red-600 dark:text-red-500 flex-shrink-0 mt-0.5"
|
||||
/>
|
||||
<p class="text-sm text-red-600 dark:text-red-500">{errorMessage}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !showTwoFactor}
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="email" class="block text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">Usuario / Correo</label>
|
||||
<div class="relative group">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-blue-500 transition-colors">
|
||||
<Icon name="user" class="w-5 h-5" />
|
||||
</div>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
bind:value={email}
|
||||
on:keydown={handleKeyDown}
|
||||
class="form-input w-full pl-10 py-2.5 bg-gray-50 dark:bg-gray-800 border-gray-300 dark:border-gray-700 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all font-mono text-sm"
|
||||
placeholder="admin@aduanasoft.com"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{#if !showTwoFactor}
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
for="email"
|
||||
class="block text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1"
|
||||
>Usuario / Correo</label
|
||||
>
|
||||
<div class="relative group">
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-blue-500 transition-colors"
|
||||
>
|
||||
<Icon name="user" className="w-5 h-5" />
|
||||
</div>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
bind:value={email}
|
||||
on:keydown={handleKeyDown}
|
||||
class="form-input w-full pl-10 py-2.5 bg-gray-50 dark:bg-gray-800 border-gray-300 dark:border-gray-700 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all font-mono text-sm"
|
||||
placeholder="admin@aduanasoft.com"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">Clave de Acceso</label>
|
||||
<div class="relative group">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-blue-500 transition-colors">
|
||||
<Icon name="lock" class="w-5 h-5" />
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
on:keydown={handleKeyDown}
|
||||
class="form-input w-full pl-10 py-2.5 bg-gray-50 dark:bg-gray-800 border-gray-300 dark:border-gray-700 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all font-mono text-sm"
|
||||
placeholder="••••••••••••"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<!-- 2FA Input -->
|
||||
<div class="bg-blue-50 dark:bg-blue-900/10 p-4 rounded-lg border border-blue-100 dark:border-blue-800/30">
|
||||
<label for="code" class="block text-xs font-semibold uppercase tracking-wider text-blue-800 dark:text-blue-300 mb-2 text-center">Verificación de Seguridad</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="code"
|
||||
type="text"
|
||||
bind:value={totpCode}
|
||||
on:keydown={handleKeyDown}
|
||||
class="form-input w-full py-3 rounded border-blue-300 dark:border-blue-700 focus:ring-blue-500 focus:border-blue-500 text-center tracking-[0.5em] font-mono text-lg bg-white dark:bg-gray-800"
|
||||
placeholder="000000"
|
||||
maxlength="6"
|
||||
required
|
||||
disabled={isLoading}
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs text-blue-600 dark:text-blue-400 mt-2 text-center">
|
||||
Consulte su dispositivo autenticador
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<label
|
||||
for="password"
|
||||
class="block text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1"
|
||||
>Clave de Acceso</label
|
||||
>
|
||||
<div class="relative group">
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-blue-500 transition-colors"
|
||||
>
|
||||
<Icon name="lock" className="w-5 h-5" />
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
on:keydown={handleKeyDown}
|
||||
class="form-input w-full pl-10 py-2.5 bg-gray-50 dark:bg-gray-800 border-gray-300 dark:border-gray-700 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all font-mono text-sm"
|
||||
placeholder="••••••••••••"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- 2FA Input -->
|
||||
<div
|
||||
class="bg-blue-50 dark:bg-blue-900/10 p-4 rounded-lg border border-blue-100 dark:border-blue-800/30"
|
||||
>
|
||||
<label
|
||||
for="code"
|
||||
class="block text-xs font-semibold uppercase tracking-wider text-blue-800 dark:text-blue-300 mb-2 text-center"
|
||||
>Verificación de Seguridad</label
|
||||
>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="code"
|
||||
type="text"
|
||||
bind:value={totpCode}
|
||||
on:keydown={handleKeyDown}
|
||||
class="form-input w-full py-3 rounded border-blue-300 dark:border-blue-700 focus:ring-blue-500 focus:border-blue-500 text-center tracking-[0.5em] font-mono text-lg bg-white dark:bg-gray-800"
|
||||
placeholder="000000"
|
||||
maxlength="6"
|
||||
required
|
||||
disabled={isLoading}
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs text-blue-600 dark:text-blue-400 mt-2 text-center">
|
||||
Consulte su dispositivo autenticador
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full flex justify-center py-2.5 px-4 rounded bg-gray-900 dark:bg-gray-700 text-white font-medium hover:bg-gray-800 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-900 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{#if isLoading}
|
||||
<Icon name="loader" class="w-4 h-4 animate-spin mr-2" />
|
||||
Autenticando...
|
||||
{:else}
|
||||
{showTwoFactor ? 'Verificar Token' : 'Entrar al Panel'}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 pt-6 border-t border-gray-100 dark:border-gray-800">
|
||||
<p class="text-[10px] text-gray-400 text-center uppercase tracking-widest">Aduanasoft Internal Systems © 2024</p>
|
||||
</div>
|
||||
<div class="pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full flex justify-center py-2.5 px-4 rounded bg-gray-900 dark:bg-gray-700 text-white font-medium hover:bg-gray-800 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-900 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{#if isLoading}
|
||||
<Icon name="loader" className="w-4 h-4 animate-spin mr-2" />
|
||||
Autenticando...
|
||||
{:else}
|
||||
{showTwoFactor ? 'Verificar Token' : 'Entrar al Panel'}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 pt-6 border-t border-gray-100 dark:border-gray-800">
|
||||
<p class="text-[10px] text-gray-400 text-center uppercase tracking-widest">
|
||||
Aduanasoft Internal Systems © 2024
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
43
scripts/README-powershell.md
Normal file
43
scripts/README-powershell.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Scripts PowerShell en ServiceManagerWeb
|
||||
|
||||
## security-test-data.ps1
|
||||
- **Propósito:** Genera o limpia datos de prueba para análisis de seguridad.
|
||||
- **Uso:**
|
||||
- `. ools\security-test-data.ps1 generar` → Genera datos de prueba.
|
||||
- `. ools\security-test-data.ps1 limpiar` → Limpia los datos de prueba.
|
||||
- **Funcionamiento:** Verifica que el contenedor backend esté corriendo y ejecuta el script Python correspondiente dentro del contenedor.
|
||||
|
||||
## test_critical_sync.ps1
|
||||
- **Propósito:** Verifica la sincronización de incidentes críticos entre Auditoría y Seguridad.
|
||||
- **Pasos:**
|
||||
1. Login como admin y obtiene token.
|
||||
2. Consulta estadísticas del módulo Auditoría.
|
||||
3. Consulta estadísticas del módulo Seguridad.
|
||||
- **Resultado:** Muestra si los incidentes críticos están sincronizados.
|
||||
|
||||
## test_tenant_update.ps1
|
||||
- **Propósito:** Prueba el endpoint de actualización de tenants.
|
||||
- **Pasos:**
|
||||
1. Login como admin.
|
||||
2. Obtiene lista de tenants.
|
||||
3. Actualiza el tenant (ejemplo: teléfono y status).
|
||||
- **Resultado:** Verifica que la actualización funcione correctamente.
|
||||
|
||||
## test_manual.ps1
|
||||
- **Propósito:** Pruebas manuales de endpoints clave.
|
||||
- **Pasos:**
|
||||
1. Login y obtención de token.
|
||||
2. Listar categorías.
|
||||
3. Crear ticket con SLA automático.
|
||||
- **Resultado:** Permite validar manualmente el flujo de API.
|
||||
|
||||
## test_frontend_integration.ps1
|
||||
- **Propósito:** Verifica la integración entre frontend y backend.
|
||||
- **Pasos:**
|
||||
1. Verifica servicios Docker.
|
||||
2. Login y obtención de token.
|
||||
3. Verifica tickets con SLA.
|
||||
- **Resultado:** Confirma que el frontend puede consumir correctamente el backend.
|
||||
|
||||
---
|
||||
**Recomendación:** Conserva estos scripts para testing e integración. Documenta cualquier script nuevo siguiendo este formato.
|
||||
29
scripts/README-uso-rapido.md
Normal file
29
scripts/README-uso-rapido.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Guía rápida de scripts esenciales
|
||||
|
||||
## 1. setup-dev.sh
|
||||
Configura el entorno de desarrollo completo (servicios, dependencias).
|
||||
|
||||
## 2. seed_data.py
|
||||
Inicializa categorías, sistemas y usuarios demo.
|
||||
|
||||
## 3. seed_tickets.py
|
||||
Genera tickets de prueba (requiere seed_data.py ejecutado).
|
||||
|
||||
## 4. run_tests.sh
|
||||
Ejecuta la suite de tests de integración.
|
||||
|
||||
## 5. reset_passwords.py
|
||||
Resetea contraseñas de usuarios demo para pruebas de login.
|
||||
|
||||
## 6. generate_sla_test_data.py
|
||||
Crea tickets con diferentes estados de SLA.
|
||||
|
||||
## 7. generate_security_test_data.py
|
||||
Genera logs de auditoría de prueba.
|
||||
|
||||
## 8. check_tenants.py
|
||||
Lista y audita los tenants existentes.
|
||||
|
||||
---
|
||||
|
||||
**Recomendación:** Ejecuta los scripts en este orden para tener un entorno funcional y datos de prueba completos. Elimina los scripts de debugging/manuales si no los necesitas para troubleshooting avanzado.
|
||||
@@ -1,7 +0,0 @@
|
||||
from app.models.ticket import Ticket
|
||||
from app.models import relationships # ensure relationships are loaded
|
||||
from sqlalchemy import inspect
|
||||
|
||||
mapper = inspect(Ticket)
|
||||
print("Relationships:", [r.key for r in mapper.relationships])
|
||||
print("Columns:", [c.key for c in mapper.columns])
|
||||
@@ -1,40 +0,0 @@
|
||||
"""Check SLA state of tickets and categories"""
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, '/app')
|
||||
os.chdir('/app')
|
||||
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
async def run():
|
||||
database_url = os.environ.get('DATABASE_URL', 'postgresql+asyncpg://postgres:postgres@db:5432/servicemanager')
|
||||
engine = create_async_engine(database_url)
|
||||
|
||||
async with engine.connect() as c:
|
||||
print("=== CATEGORIES SLA HOURS ===")
|
||||
r = await c.execute(text(
|
||||
"SELECT name, sla_response_hours, sla_resolution_hours "
|
||||
"FROM ticket_categories "
|
||||
"ORDER BY name"
|
||||
))
|
||||
for row in r.fetchall():
|
||||
print(f" {row[0]}: response={row[1]}h, resolution={row[2]}h")
|
||||
|
||||
print("\n=== TICKETS SLA DATES ===")
|
||||
r2 = await c.execute(text(
|
||||
"SELECT ticket_number, category_id, sla_response_due, sla_resolution_due "
|
||||
"FROM tickets "
|
||||
"ORDER BY created_at "
|
||||
"LIMIT 10"
|
||||
))
|
||||
for row in r2.fetchall():
|
||||
print(f" {row[0]}: cat={str(row[1])[:8] if row[1] else 'None'}, sla_resp={row[2]}, sla_res={row[3]}")
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
asyncio.run(run())
|
||||
@@ -1,41 +0,0 @@
|
||||
"""Debug: check if ticket's category_id maps to a valid category and what tenant it belongs to"""
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, '/app')
|
||||
os.chdir('/app')
|
||||
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
async def run():
|
||||
database_url = os.environ.get('DATABASE_URL', 'postgresql+asyncpg://postgres:postgres@db:5432/servicemanager')
|
||||
engine = create_async_engine(database_url)
|
||||
|
||||
async with engine.connect() as c:
|
||||
# Check tickets and their category names via direct JOIN
|
||||
r = await c.execute(text("""
|
||||
SELECT t.ticket_number, t.category_id,
|
||||
cat.name as category_name, cat.tenant_id as cat_tenant,
|
||||
t.tenant_id as ticket_tenant
|
||||
FROM tickets t
|
||||
LEFT JOIN ticket_categories cat ON cat.id = t.category_id
|
||||
WHERE t.category_id IS NOT NULL
|
||||
LIMIT 10
|
||||
"""))
|
||||
print("=== TICKET -> CATEGORY JOIN ===")
|
||||
for row in r.fetchall():
|
||||
match = "✓ SAME TENANT" if row[3] == row[4] else "✗ DIFFERENT TENANT"
|
||||
print(f" {row[0]}: cat_id={str(row[1])[:8]}, cat_name={row[2]}, {match}")
|
||||
|
||||
# Check what tenant aduanasoft-demo is
|
||||
r2 = await c.execute(text("SELECT id, slug FROM tenants WHERE slug='aduanasoft-demo'"))
|
||||
tenant = r2.fetchone()
|
||||
print(f"\nTenant aduanasoft-demo: {tenant[0] if tenant else 'NOT FOUND'}")
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
asyncio.run(run())
|
||||
@@ -1,49 +0,0 @@
|
||||
"""Debug: check SQLAlchemy ORM category loading"""
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, '/app')
|
||||
os.chdir('/app')
|
||||
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker, selectinload
|
||||
from sqlalchemy import select
|
||||
from app.models.ticket import Ticket
|
||||
from app.models.category import Category
|
||||
|
||||
|
||||
async def run():
|
||||
database_url = os.environ.get('DATABASE_URL', 'postgresql+asyncpg://postgres:postgres@db:5432/servicemanager')
|
||||
engine = create_async_engine(database_url, echo=True)
|
||||
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with async_session() as session:
|
||||
# Test selectinload
|
||||
result = await session.execute(
|
||||
select(Ticket)
|
||||
.options(selectinload(Ticket.category))
|
||||
.where(Ticket.category_id != None)
|
||||
.limit(3)
|
||||
)
|
||||
tickets = result.scalars().all()
|
||||
|
||||
print(f"\n=== ORM RESULTS ({len(tickets)} tickets) ===")
|
||||
for t in tickets:
|
||||
print(f" {t.ticket_number}: category_id={t.category_id}, category={t.category}")
|
||||
if t.category:
|
||||
print(f" -> category.name={t.category.name}")
|
||||
else:
|
||||
print(f" -> category is None!")
|
||||
|
||||
# Check if Category model can be queried directly
|
||||
r2 = await session.execute(select(Category).limit(3))
|
||||
cats = r2.scalars().all()
|
||||
print(f"\n=== DIRECT CATEGORY QUERY ({len(cats)} categories) ===")
|
||||
for c in cats:
|
||||
print(f" id={c.id}, name={c.name}")
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
asyncio.run(run())
|
||||
22
scripts/reset-fabrica.sh
Normal file
22
scripts/reset-fabrica.sh
Normal file
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
# Script para resetear datos a estado de fábrica (solo datos, no afecta estructura ni funcionalidad)
|
||||
|
||||
set -e
|
||||
|
||||
echo "🧹 Reseteando datos del sistema..."
|
||||
|
||||
# 1. Eliminar datos de tickets, comentarios, logs, auditoría, uploads
|
||||
# (Ejemplo: usando comandos SQL directos desde el contenedor)
|
||||
docker-compose exec backend psql -U servicemanager -d servicemanager -c "TRUNCATE tickets, ticket_comments, audit_logs, uploads RESTART IDENTITY CASCADE;"
|
||||
|
||||
echo "✅ Datos eliminados."
|
||||
|
||||
# 2. Volver a poblar datos demo
|
||||
|
||||
docker-compose exec backend python scripts/seed_data.py
|
||||
docker-compose exec backend python scripts/seed_tickets.py
|
||||
docker-compose exec backend python scripts/generate_sla_test_data.py
|
||||
docker-compose exec backend python scripts/generate_security_test_data.py
|
||||
docker-compose exec backend python scripts/reset_passwords.py
|
||||
|
||||
echo "🎉 Sistema restaurado a estado de fábrica demo."
|
||||
Reference in New Issue
Block a user