feat: Funcion de sistema tenants
This commit is contained in:
Binary file not shown.
@@ -56,6 +56,22 @@ backend/
|
||||
- [x] TOTP 2FA implementation
|
||||
- [x] Validation con Pydantic v2
|
||||
|
||||
### Rate limiting (login)
|
||||
|
||||
El endpoint `/{API_VERSION}/auth/login` incluye rate limiting (best-effort) usando Redis:
|
||||
|
||||
- Por IP: limita intentos totales por ventana
|
||||
- Por identidad: limita por `(tenant_id, email)` por ventana
|
||||
|
||||
Responde `429 Too Many Requests` con header `Retry-After`.
|
||||
|
||||
Variables de entorno (ver `app/core/config.py`):
|
||||
|
||||
- `RATE_LIMIT_ENABLED` (default: `true`)
|
||||
- `LOGIN_RATE_LIMIT_WINDOW_SECONDS` (default: `300`)
|
||||
- `LOGIN_RATE_LIMIT_IP_MAX_ATTEMPTS` (default: `30`)
|
||||
- `LOGIN_RATE_LIMIT_ID_MAX_ATTEMPTS` (default: `10`)
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
@@ -157,8 +173,8 @@ Ver `.env.example` para todas las variables disponibles.
|
||||
- [x] CORS restrictivo
|
||||
- [x] Input validation con Pydantic
|
||||
- [x] SQL injection protection (SQLAlchemy)
|
||||
- [x] Rate limiting (TODO: implementar)
|
||||
- [x] File upload validation (TODO: implementar)
|
||||
- [x] Rate limiting (login)
|
||||
- [x] File upload validation (extensión + firma básica + tamaño + streaming)
|
||||
- [x] XSS protection (headers en nginx)
|
||||
|
||||
## Próximos pasos
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from starlette.requests import Request
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import jwt, JWTError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -17,6 +18,7 @@ settings = get_settings()
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"/{settings.API_VERSION}/auth/login")
|
||||
|
||||
async def get_current_user(
|
||||
request: Request,
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> User:
|
||||
@@ -43,6 +45,15 @@ async def get_current_user(
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=400, detail="Inactive user")
|
||||
|
||||
# Enforce that tenant header (if present) matches the authenticated user's tenant.
|
||||
# Prevents cross-tenant header impersonation.
|
||||
request_tenant_id = getattr(getattr(request, "state", None), "tenant_id", None)
|
||||
if request_tenant_id and str(user.tenant_id) != str(request_tenant_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Tenant header does not match authenticated user",
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ Ticket Schemas - ServiceManagerWeb
|
||||
Pydantic schemas para gestión de tickets y comentarios.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, ConfigDict, model_validator
|
||||
from typing import Optional, Literal
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@@ -15,7 +15,23 @@ class TicketCreate(BaseModel):
|
||||
description: str
|
||||
category_id: Optional[str] = None
|
||||
affected_system_id: Optional[str] = None
|
||||
priority: str = "MEDIUM"
|
||||
priority: Literal["LOW", "MEDIUM", "HIGH", "URGENT"] = "MEDIUM"
|
||||
contact_email: Optional[str] = None
|
||||
contact_phone: Optional[str] = None
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _accept_legacy_fields(cls, data):
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
|
||||
if "subject" not in data and "title" in data:
|
||||
data["subject"] = data["title"]
|
||||
|
||||
if "affected_system_id" not in data and "system_id" in data:
|
||||
data["affected_system_id"] = data["system_id"]
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class TicketUpdate(BaseModel):
|
||||
@@ -39,9 +55,15 @@ class TicketResponse(BaseModel):
|
||||
status: str
|
||||
priority: str
|
||||
category_id: Optional[str] = None
|
||||
category_name: Optional[str] = None
|
||||
affected_system_id: Optional[str] = None
|
||||
system_id: Optional[str] = None
|
||||
affected_system_name: Optional[str] = None
|
||||
contact_email: Optional[str] = None
|
||||
contact_phone: Optional[str] = None
|
||||
created_by: str
|
||||
assigned_to: Optional[str] = None
|
||||
assigned_to_name: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
sla_response_due: Optional[datetime] = None
|
||||
|
||||
@@ -208,7 +208,7 @@ async def get_security_analysis(all_tenants: bool = Query(False), current_user:
|
||||
recommended_actions.append("Continuar monitoreando actividad del sistema")
|
||||
|
||||
# Calcular IPs sospechosas (más de 5 intentos fallidos)
|
||||
suspicious_ips = len(set([log.ip_address for log in logs if log.ip_address and log.action == 'auth.login.failed']))
|
||||
suspicious_ips = len(set([log.ip_address for log in logs if log.ip_address and log.action == 'user.login_failed']))
|
||||
|
||||
# Contar acciones críticas (delete, privilege changes, etc)
|
||||
critical_actions = mass_deletions + privilege_changes
|
||||
|
||||
@@ -4,7 +4,7 @@ Authentication Endpoints - ServiceManagerWeb
|
||||
Endpoints para autenticación y autorización
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status, Depends
|
||||
from fastapi import APIRouter, HTTPException, status, Depends, Request
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
@@ -18,7 +18,9 @@ from app.core.config import get_settings
|
||||
from app.models.user import User
|
||||
from app.models.tenant import Tenant
|
||||
from app.services.audit_service import AuditService
|
||||
from app.services.token_service import TokenService
|
||||
from app.api.deps import oauth2_scheme, get_current_user
|
||||
from app.core.cache import cache, cache_key
|
||||
from app.api.schemas.auth import (
|
||||
LoginRequest, LoginResponse, RefreshTokenRequest, TokenResponse,
|
||||
TwoFactorStatusResponse, TwoFactorSetupResponse,
|
||||
@@ -38,6 +40,7 @@ settings = get_settings()
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(
|
||||
login_data: LoginRequest,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
@@ -58,13 +61,85 @@ async def login(
|
||||
email=login_data.email,
|
||||
tenant_slug=login_data.tenant_slug
|
||||
)
|
||||
|
||||
# Rate limiting (best-effort): by IP before any tenant/user lookup.
|
||||
if settings.RATE_LIMIT_ENABLED and not settings.TESTING:
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
ip_key = cache_key("rl", "login", "ip", client_ip)
|
||||
ip_count = await cache.incr(ip_key, 1)
|
||||
if ip_count == 1:
|
||||
await cache.expire(ip_key, settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)
|
||||
|
||||
if ip_count is not None and ip_count > settings.LOGIN_RATE_LIMIT_IP_MAX_ATTEMPTS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Too many login attempts. Try again later.",
|
||||
headers={"Retry-After": str(settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)},
|
||||
)
|
||||
|
||||
# 1. Buscar usuario en base de datos
|
||||
query = select(User).where(User.email == login_data.email)
|
||||
# 1. Validar tenant
|
||||
tenant_result = await db.execute(
|
||||
select(Tenant).where(Tenant.slug == login_data.tenant_slug)
|
||||
)
|
||||
tenant = tenant_result.scalar_one_or_none()
|
||||
if tenant is None:
|
||||
logger.warning(
|
||||
"Login failed - tenant not found",
|
||||
email=login_data.email,
|
||||
tenant_slug=login_data.tenant_slug,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tenant not found",
|
||||
)
|
||||
|
||||
# Rate limiting (best-effort): by (tenant,email) to slow brute force.
|
||||
ident_key = None
|
||||
if settings.RATE_LIMIT_ENABLED and not settings.TESTING:
|
||||
email_norm = login_data.email.strip().lower()
|
||||
ident_key = cache_key("rl", "login", "id", str(tenant.id), email_norm)
|
||||
ident_count = await cache.incr(ident_key, 1)
|
||||
if ident_count == 1:
|
||||
await cache.expire(ident_key, settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)
|
||||
|
||||
if ident_count is not None and ident_count > settings.LOGIN_RATE_LIMIT_ID_MAX_ATTEMPTS:
|
||||
try:
|
||||
await AuditService.log(
|
||||
db=db,
|
||||
tenant_id=tenant.id,
|
||||
user_id=None,
|
||||
action="user.login_rate_limited",
|
||||
resource_type="user",
|
||||
resource_id=None,
|
||||
metadata={
|
||||
"email": email_norm,
|
||||
"tenant_slug": login_data.tenant_slug,
|
||||
"ip": request.client.host if request.client else None,
|
||||
"scope": "tenant_email",
|
||||
"window_seconds": settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS,
|
||||
"max_attempts": settings.LOGIN_RATE_LIMIT_ID_MAX_ATTEMPTS,
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to log rate limit audit entry", error=str(e))
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Too many login attempts. Try again later.",
|
||||
headers={"Retry-After": str(settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)},
|
||||
)
|
||||
|
||||
# 2. Buscar usuario en base de datos (aislado por tenant)
|
||||
query = select(User).where(
|
||||
User.email == login_data.email,
|
||||
User.tenant_id == tenant.id,
|
||||
)
|
||||
result = await db.execute(query)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
# 2. Verificar usuario y contraseña
|
||||
# 3. Verificar usuario y contraseña
|
||||
if not user or not security.verify_password(login_data.password, user.password_hash):
|
||||
logger.warning(
|
||||
"Login failed - invalid credentials",
|
||||
@@ -89,21 +164,21 @@ async def login(
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Credenciales inválidas"
|
||||
detail="Invalid credentials",
|
||||
)
|
||||
|
||||
# 3. Verificar si está activo
|
||||
# 4. Verificar si está activo
|
||||
if not user.is_active:
|
||||
logger.warning(
|
||||
"Login failed - user inactive",
|
||||
email=login_data.email
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Usuario inactivo"
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User inactive",
|
||||
)
|
||||
|
||||
# 4. Verificar 2FA si está habilitado
|
||||
# 5. Verificar 2FA si está habilitado
|
||||
if user.totp_enabled:
|
||||
if not login_data.totp_code:
|
||||
# Indicar al frontend que debe pedir el código TOTP
|
||||
@@ -128,6 +203,24 @@ async def login(
|
||||
|
||||
access_token = security.create_access_token(token_data)
|
||||
refresh_token = security.create_refresh_token(token_data)
|
||||
|
||||
# Persist refresh token so it can be revoked/validated later
|
||||
try:
|
||||
await TokenService.create_refresh_token(
|
||||
db=db,
|
||||
user=user,
|
||||
refresh_token=refresh_token,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
ip_address=request.client.host if request.client else None,
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
# If persistence fails, do not leak tokens
|
||||
logger.error("Failed to persist refresh token", error=str(e), user_id=str(user.id))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Service temporarily unavailable",
|
||||
)
|
||||
|
||||
# Registrar login exitoso en auditoría
|
||||
try:
|
||||
@@ -150,6 +243,10 @@ async def login(
|
||||
tenant_slug=login_data.tenant_slug,
|
||||
user_id=str(user.id)
|
||||
)
|
||||
|
||||
# Best-effort: clear per-identity limiter on success.
|
||||
if ident_key:
|
||||
await cache.delete(ident_key)
|
||||
|
||||
return LoginResponse(
|
||||
access_token=access_token,
|
||||
@@ -198,7 +295,20 @@ async def refresh_token(
|
||||
detail="Invalid refresh token"
|
||||
)
|
||||
|
||||
# TODO: Check if refresh token exists in database and is not revoked
|
||||
# Check token exists in database and is not revoked/expired
|
||||
db_token = await TokenService.verify_refresh_token(db=db, refresh_token=refresh_data.refresh_token)
|
||||
if db_token is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token",
|
||||
)
|
||||
|
||||
# Defensive: ensure DB token belongs to same subject
|
||||
if str(db_token.user_id) != str(payload.get("sub")):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token",
|
||||
)
|
||||
|
||||
# Create new access token
|
||||
token_data = {
|
||||
@@ -243,7 +353,19 @@ async def logout(
|
||||
detail="Invalid token"
|
||||
)
|
||||
|
||||
# TODO: Revoke refresh token in database
|
||||
# Revoke all active refresh tokens for this user (logout invalidates refresh)
|
||||
try:
|
||||
import uuid
|
||||
|
||||
user_id = uuid.UUID(payload["sub"])
|
||||
await TokenService.revoke_all_user_tokens(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
revoked_by_user_id=user_id,
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to revoke refresh tokens on logout", error=str(e))
|
||||
|
||||
# Registrar logout en auditoría
|
||||
try:
|
||||
|
||||
@@ -80,8 +80,15 @@ async def create_ticket(ticket: TicketCreate, db: AsyncSession = Depends(get_db)
|
||||
"title": db_ticket.subject, "description": db_ticket.description, "status": db_ticket.status.value,
|
||||
"priority": db_ticket.priority.value, "category_id": str(db_ticket.category_id) if db_ticket.category_id else None,
|
||||
"affected_system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None,
|
||||
"system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None,
|
||||
"contact_email": ticket.contact_email,
|
||||
"contact_phone": ticket.contact_phone,
|
||||
"created_by": str(db_ticket.created_by), "assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None,
|
||||
"created_at": db_ticket.created_at, "updated_at": db_ticket.updated_at
|
||||
"created_at": db_ticket.created_at, "updated_at": db_ticket.updated_at,
|
||||
"sla_response_due": db_ticket.sla_response_due,
|
||||
"sla_resolution_due": db_ticket.sla_resolution_due,
|
||||
"first_response_at": db_ticket.first_response_at,
|
||||
"resolved_at": db_ticket.resolved_at,
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
@@ -111,35 +118,36 @@ async def get_tickets(skip: int = 0, limit: int = 100, status: Optional[str] = N
|
||||
|
||||
query = apply_enum_filter(query, Ticket.status, status, TicketStatus, "status")
|
||||
query = apply_enum_filter(query, Ticket.priority, priority, TicketPriority, "priority")
|
||||
query = query.options(
|
||||
selectinload(Ticket.category),
|
||||
selectinload(Ticket.affected_system),
|
||||
selectinload(Ticket.assigned_to_user)
|
||||
)
|
||||
query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
tickets = result.scalars().all()
|
||||
|
||||
return [
|
||||
{"id": str(t.id), "ticket_number": t.ticket_number, "subject": t.subject, "title": t.subject,
|
||||
"description": t.description, "status": t.status.value, "priority": t.priority.value,
|
||||
"category_id": str(t.category_id) if t.category_id else None,
|
||||
"affected_system_id": str(t.affected_system_id) if t.affected_system_id else None,
|
||||
"created_by": str(t.created_by), "assigned_to": str(t.assigned_to) if t.assigned_to else None,
|
||||
"created_at": t.created_at, "updated_at": t.updated_at, "sla_response_due": t.sla_response_due,
|
||||
"sla_resolution_due": t.sla_resolution_due, "first_response_at": t.first_response_at, "resolved_at": t.resolved_at}
|
||||
for t in tickets
|
||||
]
|
||||
return [ticket_to_dict(t) for t in tickets]
|
||||
|
||||
@router.get("/admin/all", response_model=List[dict])
|
||||
async def get_all_tickets_admin(skip: int = 0, limit: int = 100, status_filter: Optional[str] = None,
|
||||
priority_filter: Optional[str] = None, tenant_id_filter: Optional[str] = None, category_filter: Optional[str] = None,
|
||||
assigned_to_filter: Optional[str] = None, search: Optional[str] = None, date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""Obtener todos los tickets de todos los tenants (solo para administradores)"""
|
||||
"""Obtener todos los tickets del tenant del administrador (ADMIN/SUPPORT_MANAGER)."""
|
||||
if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No tienes permisos para acceder a esta función")
|
||||
|
||||
|
||||
query = select(Ticket, Tenant, User).join(Tenant, Ticket.tenant_id == Tenant.id).join(User, Ticket.created_by == User.id)
|
||||
|
||||
|
||||
# SUPPORT_MANAGER solo ve su propio tenant.
|
||||
# ADMIN ve todos los tenants (es el administrador de la plataforma).
|
||||
if current_user.role == "SUPPORT_MANAGER":
|
||||
query = query.where(Ticket.tenant_id == current_user.tenant_id)
|
||||
|
||||
query = apply_enum_filter(query, Ticket.status, status_filter, TicketStatus, "status")
|
||||
query = apply_enum_filter(query, Ticket.priority, priority_filter, TicketPriority, "priority")
|
||||
query = apply_enum_filter(query, Ticket.priority, priority_filter, TicketPriority, "priority")
|
||||
if tenant_id_filter:
|
||||
query = query.where(Ticket.tenant_id == validate_uuid_param(tenant_id_filter, "tenant ID"))
|
||||
if category_filter:
|
||||
@@ -166,10 +174,20 @@ async def get_all_tickets_admin(skip: int = 0, limit: int = 100, status_filter:
|
||||
result = await db.execute(query)
|
||||
rows = result.all()
|
||||
|
||||
# Cargar categorías en un solo query para evitar N+1
|
||||
category_ids = list({ticket.category_id for ticket, _, _ in rows if ticket.category_id})
|
||||
categories_map = {}
|
||||
if category_ids:
|
||||
from app.models.category import Category as CategoryModel
|
||||
cat_result = await db.execute(select(CategoryModel).where(CategoryModel.id.in_(category_ids)))
|
||||
categories_map = {c.id: c.name for c in cat_result.scalars().all()}
|
||||
|
||||
return [
|
||||
{"id": str(ticket.id), "ticket_number": ticket.ticket_number, "subject": ticket.subject,
|
||||
"description": ticket.description, "status": ticket.status.value, "priority": ticket.priority.value,
|
||||
"tenant_id": str(ticket.tenant_id), "tenant_name": tenant.name, "tenant_slug": tenant.slug,
|
||||
"category_id": str(ticket.category_id) if ticket.category_id else None,
|
||||
"category_name": categories_map.get(ticket.category_id) if ticket.category_id else None,
|
||||
"created_by": str(ticket.created_by), "creator_name": f"{creator.first_name} {creator.last_name}",
|
||||
"creator_email": creator.email, "assigned_to": str(ticket.assigned_to) if ticket.assigned_to else None,
|
||||
"created_at": ticket.created_at, "updated_at": ticket.updated_at, "sla_response_due": ticket.sla_response_due,
|
||||
@@ -358,6 +376,9 @@ async def get_ticket_attachments(ticket_id: str, db: AsyncSession = Depends(get_
|
||||
|
||||
if not ticket:
|
||||
raise HTTPException(status_code=404, detail="Ticket no encontrado")
|
||||
|
||||
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"] and ticket.created_by != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Ticket no encontrado")
|
||||
|
||||
result = await db.execute(select(TicketAttachment).where(TicketAttachment.ticket_id == ticket_uuid).options(selectinload(TicketAttachment.uploaded_by_user)).order_by(TicketAttachment.created_at.desc()))
|
||||
attachments = result.scalars().all()
|
||||
@@ -382,6 +403,9 @@ async def upload_attachment(ticket_id: str, file: UploadFile = File(...), db: As
|
||||
|
||||
if not ticket:
|
||||
raise HTTPException(status_code=404, detail="Ticket no encontrado")
|
||||
|
||||
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"] and ticket.created_by != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Ticket no encontrado")
|
||||
|
||||
file_metadata = await file_handler.save_upload(file, current_tenant.id, ticket_uuid)
|
||||
|
||||
@@ -425,6 +449,9 @@ async def download_attachment(ticket_id: str, attachment_id: str, db: AsyncSessi
|
||||
if not ticket:
|
||||
logger.error(f"Ticket not found - ticket_id: {ticket_id}")
|
||||
raise HTTPException(status_code=404, detail="Ticket no encontrado")
|
||||
|
||||
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"] and ticket.created_by != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Ticket no encontrado")
|
||||
|
||||
result = await db.execute(select(TicketAttachment).where(TicketAttachment.id == attachment_uuid, TicketAttachment.ticket_id == ticket_uuid))
|
||||
attachment = result.scalar_one_or_none()
|
||||
|
||||
@@ -19,6 +19,14 @@ router = APIRouter()
|
||||
# ENDPOINTS
|
||||
# ===================================
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def read_current_user(
|
||||
current_user: User = Depends(deps.get_current_user),
|
||||
):
|
||||
"""Obtener el perfil del usuario actual."""
|
||||
return current_user
|
||||
|
||||
@router.get("/", response_model=List[UserResponse])
|
||||
async def read_users(
|
||||
skip: int = 0,
|
||||
|
||||
@@ -100,7 +100,10 @@ def ticket_to_dict(ticket: Ticket) -> dict:
|
||||
"category_id": str(ticket.category_id) if ticket.category_id else None,
|
||||
"category_name": ticket.category.name if ticket.category else None,
|
||||
"affected_system_id": str(ticket.affected_system_id) if ticket.affected_system_id else None,
|
||||
"system_id": str(ticket.affected_system_id) if ticket.affected_system_id else None,
|
||||
"affected_system_name": ticket.affected_system.name if ticket.affected_system else None,
|
||||
"contact_email": None,
|
||||
"contact_phone": None,
|
||||
"created_by": str(ticket.created_by),
|
||||
"assigned_to": str(ticket.assigned_to) if ticket.assigned_to else None,
|
||||
"assigned_to_name": f"{ticket.assigned_to_user.first_name} {ticket.assigned_to_user.last_name}" if ticket.assigned_to_user else None,
|
||||
|
||||
@@ -24,6 +24,7 @@ class Settings(BaseSettings):
|
||||
# GENERAL
|
||||
# ===================================
|
||||
ENVIRONMENT: str = Field(default="development")
|
||||
TESTING: bool = Field(default=False)
|
||||
DEBUG: bool = Field(default=False)
|
||||
SECRET_KEY: str = Field(...)
|
||||
API_VERSION: str = Field(default="v1")
|
||||
@@ -86,6 +87,9 @@ class Settings(BaseSettings):
|
||||
# SECURITY
|
||||
# ===================================
|
||||
RATE_LIMIT_ENABLED: bool = Field(default=True)
|
||||
LOGIN_RATE_LIMIT_WINDOW_SECONDS: int = Field(default=300)
|
||||
LOGIN_RATE_LIMIT_IP_MAX_ATTEMPTS: int = Field(default=30)
|
||||
LOGIN_RATE_LIMIT_ID_MAX_ATTEMPTS: int = Field(default=10)
|
||||
PASSWORD_MIN_LENGTH: int = Field(default=8)
|
||||
|
||||
# Argon2 settings
|
||||
|
||||
@@ -6,7 +6,9 @@ SQLAlchemy 2.0 async setup con PostgreSQL
|
||||
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
from sqlalchemy import String, DateTime, func
|
||||
from sqlalchemy import String, DateTime, func, text
|
||||
from sqlalchemy.types import TypeDecorator, CHAR
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||
from typing import AsyncGenerator
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
@@ -34,18 +36,46 @@ AsyncSessionLocal = async_sessionmaker(
|
||||
autoflush=True,
|
||||
autocommit=False
|
||||
)
|
||||
class GUID(TypeDecorator):
|
||||
"""UUID portable: UUID nativo en Postgres, CHAR(36) en otros dialectos (SQLite para tests)."""
|
||||
|
||||
impl = CHAR
|
||||
cache_ok = True
|
||||
|
||||
def load_dialect_impl(self, dialect):
|
||||
if dialect.name == "postgresql":
|
||||
return dialect.type_descriptor(PG_UUID(as_uuid=True))
|
||||
return dialect.type_descriptor(CHAR(36))
|
||||
|
||||
def process_bind_param(self, value, dialect):
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if dialect.name == "postgresql":
|
||||
return value
|
||||
|
||||
if isinstance(value, uuid.UUID):
|
||||
return str(value)
|
||||
return str(uuid.UUID(str(value)))
|
||||
|
||||
def process_result_value(self, value, dialect):
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, uuid.UUID):
|
||||
return value
|
||||
return uuid.UUID(str(value))
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class para todos los modelos SQLAlchemy."""
|
||||
|
||||
|
||||
# Columnas comunes para auditoría
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now()
|
||||
onupdate=func.now(),
|
||||
)
|
||||
|
||||
|
||||
@@ -89,7 +119,7 @@ async def check_database_health() -> bool:
|
||||
"""
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
await session.execute("SELECT 1")
|
||||
await session.execute(text("SELECT 1"))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
@@ -16,6 +16,8 @@ settings = get_settings()
|
||||
|
||||
class FileHandler:
|
||||
"""Handler simple para archivos adjuntos"""
|
||||
|
||||
_CHUNK_SIZE_BYTES = 1024 * 1024 # 1MB
|
||||
|
||||
def __init__(self):
|
||||
self.upload_path = Path(settings.UPLOAD_PATH)
|
||||
@@ -24,8 +26,8 @@ class FileHandler:
|
||||
# Crear directorio si no existe
|
||||
self.upload_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _validate_file(self, filename: str, file_size: int) -> None:
|
||||
"""Validar archivo"""
|
||||
def _validate_extension(self, filename: str) -> str:
|
||||
"""Validar extensión del archivo y retornarla."""
|
||||
extension = Path(filename).suffix.lower().lstrip('.')
|
||||
|
||||
if extension not in self.allowed_extensions:
|
||||
@@ -33,32 +35,52 @@ class FileHandler:
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Extensión no permitida: {extension}"
|
||||
)
|
||||
|
||||
if file_size > self.max_size_bytes:
|
||||
|
||||
return extension
|
||||
|
||||
def _validate_magic_bytes(self, extension: str, first_bytes: bytes) -> None:
|
||||
"""Validación básica por firma (magic bytes) para tipos comunes."""
|
||||
|
||||
signatures = {
|
||||
# PDFs start with %PDF-
|
||||
"pdf": [b"%PDF-"],
|
||||
# PNG signature
|
||||
"png": [b"\x89PNG\r\n\x1a\n"],
|
||||
# JPEG starts with FF D8 FF
|
||||
"jpg": [b"\xff\xd8\xff"],
|
||||
"jpeg": [b"\xff\xd8\xff"],
|
||||
# Legacy MS Office (OLE Compound File)
|
||||
"doc": [b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"],
|
||||
"xls": [b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"],
|
||||
# OOXML (zip-based)
|
||||
"docx": [b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08"],
|
||||
"xlsx": [b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08"],
|
||||
}
|
||||
|
||||
# For plain text, we can't reliably validate via magic bytes.
|
||||
if extension == "txt":
|
||||
return
|
||||
|
||||
allowed = signatures.get(extension)
|
||||
if not allowed:
|
||||
return
|
||||
|
||||
if not any(first_bytes.startswith(sig) for sig in allowed):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail=f"Archivo muy grande. Máximo: {settings.MAX_UPLOAD_SIZE_MB}MB"
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Contenido de archivo no coincide con la extensión declarada",
|
||||
)
|
||||
|
||||
def _calculate_checksums(self, content: bytes) -> Tuple[str, str]:
|
||||
"""Calcular MD5 y SHA256"""
|
||||
return hashlib.md5(content).hexdigest(), hashlib.sha256(content).hexdigest()
|
||||
|
||||
async def save_upload(self, file: UploadFile, tenant_id: uuid.UUID, ticket_id: uuid.UUID) -> dict:
|
||||
"""Guardar archivo y retornar metadata"""
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="Filename requerido")
|
||||
|
||||
content = await file.read()
|
||||
file_size = len(content)
|
||||
|
||||
self._validate_file(file.filename, file_size)
|
||||
|
||||
md5_hash, sha256_hash = self._calculate_checksums(content)
|
||||
|
||||
|
||||
extension = self._validate_extension(file.filename)
|
||||
|
||||
# Nombre único
|
||||
extension = Path(file.filename).suffix.lower()
|
||||
safe_filename = f"{uuid.uuid4().hex}{extension}"
|
||||
original_extension = Path(file.filename).suffix.lower()
|
||||
safe_filename = f"{uuid.uuid4().hex}{original_extension}"
|
||||
|
||||
# Estructura: uploads/tenant_id/tickets/ticket_id/
|
||||
file_directory = self.upload_path / str(tenant_id) / "tickets" / str(ticket_id)
|
||||
@@ -66,10 +88,61 @@ class FileHandler:
|
||||
|
||||
file_path = file_directory / safe_filename
|
||||
relative_path = str(file_path.relative_to(self.upload_path))
|
||||
|
||||
# Guardar archivo
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
# Guardar archivo (streaming) + checksums incrementales
|
||||
md5 = hashlib.md5()
|
||||
sha256 = hashlib.sha256()
|
||||
file_size = 0
|
||||
validated_magic = False
|
||||
first_bytes: bytes = b""
|
||||
|
||||
try:
|
||||
with open(file_path, "wb") as f:
|
||||
while True:
|
||||
chunk = await file.read(self._CHUNK_SIZE_BYTES)
|
||||
if not chunk:
|
||||
break
|
||||
|
||||
if not validated_magic:
|
||||
first_bytes = chunk[:16]
|
||||
self._validate_magic_bytes(extension, first_bytes)
|
||||
validated_magic = True
|
||||
|
||||
file_size += len(chunk)
|
||||
if file_size > self.max_size_bytes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail=f"Archivo muy grande. Máximo: {settings.MAX_UPLOAD_SIZE_MB}MB",
|
||||
)
|
||||
|
||||
md5.update(chunk)
|
||||
sha256.update(chunk)
|
||||
f.write(chunk)
|
||||
|
||||
if file_size == 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Archivo vacío",
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
# Eliminar archivo parcial si existe
|
||||
try:
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
except Exception as exc:
|
||||
try:
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error guardando archivo: {exc}",
|
||||
)
|
||||
|
||||
import mimetypes
|
||||
mime_type = mimetypes.guess_type(file.filename)[0] or "application/octet-stream"
|
||||
@@ -80,8 +153,8 @@ class FileHandler:
|
||||
"file_path": relative_path,
|
||||
"file_size": file_size,
|
||||
"mime_type": mime_type,
|
||||
"md5_hash": md5_hash,
|
||||
"sha256_hash": sha256_hash
|
||||
"md5_hash": md5.hexdigest(),
|
||||
"sha256_hash": sha256.hexdigest(),
|
||||
}
|
||||
|
||||
def get_file_path(self, relative_path: str) -> Path:
|
||||
|
||||
@@ -13,6 +13,7 @@ import pyotp
|
||||
import secrets
|
||||
import base64
|
||||
import struct
|
||||
import uuid
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
@@ -100,7 +101,8 @@ class SecurityUtils:
|
||||
"""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
to_encode.update({"exp": expire, "type": "refresh"})
|
||||
# Add a unique identifier so refresh tokens are never deterministic.
|
||||
to_encode.update({"exp": expire, "type": "refresh", "jti": str(uuid.uuid4())})
|
||||
|
||||
encoded_jwt = jwt.encode(
|
||||
to_encode,
|
||||
|
||||
@@ -76,12 +76,26 @@ app = FastAPI(
|
||||
|
||||
# CORS
|
||||
cors_origins = settings.CORS_ORIGINS.split(",") if isinstance(settings.CORS_ORIGINS, str) else settings.CORS_ORIGINS
|
||||
|
||||
if settings.is_production():
|
||||
cors_allow_methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
|
||||
cors_allow_headers = [
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"X-Tenant-ID",
|
||||
"X-Tenant-Slug",
|
||||
"X-Correlation-ID",
|
||||
]
|
||||
else:
|
||||
cors_allow_methods = ["*"]
|
||||
cors_allow_headers = ["*"]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_methods=cors_allow_methods,
|
||||
allow_headers=cors_allow_headers,
|
||||
)
|
||||
|
||||
# Compression
|
||||
|
||||
@@ -9,8 +9,9 @@ from starlette.requests import Request
|
||||
from starlette.responses import Response, JSONResponse
|
||||
from sqlalchemy import select
|
||||
import structlog
|
||||
import uuid
|
||||
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.core.database import AsyncSessionLocal, get_db
|
||||
from app.core.config import get_settings
|
||||
from app.models.tenant import Tenant, TenantStatus
|
||||
|
||||
@@ -30,11 +31,17 @@ class TenantMiddleware(BaseHTTPMiddleware):
|
||||
# Rutas que no requieren tenant
|
||||
EXCLUDED_PATHS = {
|
||||
"/health",
|
||||
"/api/v1/health",
|
||||
"/v1/health",
|
||||
"/api/v1/health/detailed",
|
||||
"/v1/health/detailed",
|
||||
"/",
|
||||
"/api/v1/auth/login",
|
||||
"/v1/auth/login",
|
||||
"/api/v1/auth/refresh",
|
||||
"/v1/auth/refresh",
|
||||
"/api/v1/auth/logout",
|
||||
"/v1/auth/logout",
|
||||
"/api/v1/auth/forgot-password",
|
||||
"/v1/auth/forgot-password",
|
||||
"/api/v1/auth/reset-password",
|
||||
@@ -66,33 +73,58 @@ class TenantMiddleware(BaseHTTPMiddleware):
|
||||
tenant_id = request.headers.get("X-Tenant-ID")
|
||||
tenant_slug = request.headers.get("X-Tenant-Slug")
|
||||
|
||||
# Si no hay headers de tenant
|
||||
if not tenant_id and not tenant_slug:
|
||||
if settings.ENVIRONMENT == "production":
|
||||
tenant_uuid: uuid.UUID | None = None
|
||||
if tenant_id:
|
||||
try:
|
||||
tenant_uuid = uuid.UUID(tenant_id)
|
||||
except ValueError:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"detail": "Tenant information required (X-Tenant-ID or X-Tenant-Slug header)"}
|
||||
content={"detail": "Invalid X-Tenant-ID header (must be UUID)"},
|
||||
)
|
||||
# En desarrollo, continuar sin tenant con advertencia
|
||||
logger.warning(
|
||||
"Request without tenant information",
|
||||
path=request.url.path,
|
||||
method=request.method,
|
||||
|
||||
# Si no hay headers de tenant (requerido para aislamiento multi-tenant)
|
||||
if not tenant_id and not tenant_slug:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"detail": "Tenant information required (X-Tenant-ID or X-Tenant-Slug header)"},
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
# Validar tenant contra la base de datos
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
if tenant_id:
|
||||
result = await session.execute(
|
||||
select(Tenant).where(Tenant.id == tenant_id)
|
||||
)
|
||||
else:
|
||||
result = await session.execute(
|
||||
select(Tenant).where(Tenant.slug == tenant_slug)
|
||||
)
|
||||
tenant = result.scalars().first()
|
||||
# Prefer DB session coming from dependency overrides (tests) when available.
|
||||
# Guard: in unit tests request.app may be a MagicMock, not a real FastAPI app.
|
||||
dependency_overrides = getattr(request.app, "dependency_overrides", None)
|
||||
override_get_db = None
|
||||
if isinstance(dependency_overrides, dict):
|
||||
override_get_db = dependency_overrides.get(get_db)
|
||||
|
||||
if override_get_db is not None:
|
||||
agen = override_get_db()
|
||||
session = await agen.__anext__()
|
||||
try:
|
||||
if tenant_uuid is not None:
|
||||
result = await session.execute(
|
||||
select(Tenant).where(Tenant.id == tenant_uuid)
|
||||
)
|
||||
else:
|
||||
result = await session.execute(
|
||||
select(Tenant).where(Tenant.slug == tenant_slug)
|
||||
)
|
||||
tenant = result.scalars().first()
|
||||
finally:
|
||||
await agen.aclose()
|
||||
else:
|
||||
async with AsyncSessionLocal() as session:
|
||||
if tenant_uuid is not None:
|
||||
result = await session.execute(
|
||||
select(Tenant).where(Tenant.id == tenant_uuid)
|
||||
)
|
||||
else:
|
||||
result = await session.execute(
|
||||
select(Tenant).where(Tenant.slug == tenant_slug)
|
||||
)
|
||||
tenant = result.scalars().first()
|
||||
|
||||
if tenant is None:
|
||||
logger.warning(
|
||||
|
||||
@@ -3,12 +3,11 @@ Attachment Model - ServiceManagerWeb
|
||||
"""
|
||||
from sqlalchemy import String, ForeignKey, Integer, DateTime, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from app.core.database import Base
|
||||
from app.core.database import Base, GUID
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.ticket import Ticket
|
||||
@@ -21,24 +20,24 @@ class TicketAttachment(Base):
|
||||
__tablename__ = "ticket_attachments"
|
||||
|
||||
# Sobrescribir campos heredados de Base para que coincidan con la tabla real
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
# Esta tabla NO tiene updated_at, así que lo excluimos del mapping
|
||||
|
||||
ticket_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("tickets.id", ondelete="CASCADE"),
|
||||
nullable=False
|
||||
)
|
||||
|
||||
comment_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("ticket_comments.id", ondelete="CASCADE"),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
uploaded_by: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("users.id"),
|
||||
nullable=False
|
||||
)
|
||||
|
||||
@@ -5,14 +5,14 @@ Modelo para bitácora de auditoría y compliance.
|
||||
Registra todas las acciones importantes del sistema.
|
||||
"""
|
||||
|
||||
from sqlalchemy import String, Text, DateTime, ForeignKey, Index
|
||||
from sqlalchemy import String, Text, DateTime, ForeignKey, Index, JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, INET, JSONB
|
||||
from sqlalchemy.dialects.postgresql import INET, JSONB
|
||||
from typing import Optional, Dict, Any, TYPE_CHECKING
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from app.core.database import Base
|
||||
from app.core.database import Base, GUID
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.tenant import Tenant
|
||||
@@ -36,7 +36,7 @@ class AuditLog(Base):
|
||||
|
||||
# Multi-tenancy
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True
|
||||
@@ -44,7 +44,7 @@ class AuditLog(Base):
|
||||
|
||||
# Usuario que ejecut├│ la acci├│n (NULL = acci├│n del sistema)
|
||||
user_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True
|
||||
@@ -66,30 +66,33 @@ class AuditLog(Base):
|
||||
|
||||
# ID del recurso afectado
|
||||
resource_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Contexto de la request
|
||||
ip_address: Mapped[Optional[str]] = mapped_column(INET, nullable=True)
|
||||
ip_address: Mapped[Optional[str]] = mapped_column(
|
||||
String(45).with_variant(INET, "postgresql"),
|
||||
nullable=True,
|
||||
)
|
||||
user_agent: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Correlation ID para rastrear requests relacionadas
|
||||
correlation_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
nullable=True,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Valores antes del cambio (JSON)
|
||||
old_values: Mapped[Optional[Dict[str, Any]]] = mapped_column(
|
||||
JSONB,
|
||||
JSON().with_variant(JSONB, "postgresql"),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Valores despu├®s del cambio (JSON)
|
||||
new_values: Mapped[Optional[Dict[str, Any]]] = mapped_column(
|
||||
JSONB,
|
||||
JSON().with_variant(JSONB, "postgresql"),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
@@ -97,7 +100,7 @@ class AuditLog(Base):
|
||||
# Nota: 'metadata' está reservado en SQLAlchemy, usamos 'extra_metadata'
|
||||
extra_metadata: Mapped[Optional[Dict[str, Any]]] = mapped_column(
|
||||
'metadata', # Nombre real de la columna en BD
|
||||
JSONB,
|
||||
JSON().with_variant(JSONB, "postgresql"),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
|
||||
@@ -4,11 +4,10 @@ Categorías de tickets por tenant
|
||||
"""
|
||||
from sqlalchemy import String, Text, Boolean, Integer, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from typing import List, Optional
|
||||
import uuid
|
||||
|
||||
from app.core.database import Base
|
||||
from app.core.database import Base, GUID
|
||||
|
||||
class Category(Base):
|
||||
"""Modelo de categorías de tickets (ticket_categories en BD)"""
|
||||
@@ -21,7 +20,7 @@ class Category(Base):
|
||||
|
||||
# ✅ CORREGIDO: tenant_id es obligatorio para multi-tenancy
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False # ✅ Obligatorio
|
||||
)
|
||||
@@ -31,7 +30,7 @@ class Category(Base):
|
||||
sla_response_hours: Mapped[int] = mapped_column(Integer, default=24, nullable=False)
|
||||
sla_resolution_hours: Mapped[int] = mapped_column(Integer, default=72, nullable=False)
|
||||
auto_assign_to: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("users.id"),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
@@ -7,12 +7,11 @@ Almacena información detallada de la empresa cliente
|
||||
|
||||
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Text, Numeric
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from app.core.database import Base
|
||||
from app.core.database import Base, GUID
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.tenant import Tenant
|
||||
@@ -25,7 +24,7 @@ class ClientProfile(Base):
|
||||
|
||||
# Relación con tenant (uno a uno)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
|
||||
@@ -5,12 +5,11 @@ Modelo para comentarios en tickets
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, String, Text, Boolean, ForeignKey, DateTime
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from app.core.database import Base
|
||||
from app.core.database import Base, GUID
|
||||
|
||||
|
||||
class TicketComment(Base):
|
||||
@@ -20,20 +19,20 @@ class TicketComment(Base):
|
||||
|
||||
# Columnas
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4
|
||||
)
|
||||
|
||||
ticket_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("tickets.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
author_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("users.id"),
|
||||
nullable=False,
|
||||
index=True
|
||||
|
||||
@@ -6,12 +6,11 @@ Modelo para persistencia de refresh tokens con revocaci├│n y tracking.
|
||||
|
||||
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Index, Integer
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.core.database import Base
|
||||
from app.core.database import Base, GUID
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
@@ -36,7 +35,7 @@ class RefreshToken(Base):
|
||||
|
||||
# User relationship
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True
|
||||
@@ -92,7 +91,7 @@ class RefreshToken(Base):
|
||||
)
|
||||
|
||||
revoked_by: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True
|
||||
)
|
||||
@@ -146,12 +145,12 @@ class RefreshToken(Base):
|
||||
- No está revocado
|
||||
- No ha expirado
|
||||
"""
|
||||
return not self.revoked and self.expires_at > datetime.utcnow()
|
||||
return not self.revoked and self.expires_at > datetime.now(timezone.utc)
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
"""Verificar si el token ha expirado."""
|
||||
return datetime.utcnow() >= self.expires_at
|
||||
return datetime.now(timezone.utc) >= self.expires_at
|
||||
|
||||
def revoke(self, revoked_by: Optional[uuid.UUID] = None) -> None:
|
||||
"""
|
||||
@@ -161,11 +160,11 @@ class RefreshToken(Base):
|
||||
revoked_by: ID del usuario que revoc├│ el token
|
||||
"""
|
||||
self.revoked = True
|
||||
self.revoked_at = datetime.utcnow()
|
||||
self.revoked_at = datetime.now(timezone.utc)
|
||||
if revoked_by:
|
||||
self.revoked_by = revoked_by
|
||||
|
||||
def track_usage(self) -> None:
|
||||
"""Registrar uso del token."""
|
||||
self.last_used_at = datetime.utcnow()
|
||||
self.last_used_at = datetime.now(timezone.utc)
|
||||
self.usage_count += 1
|
||||
|
||||
@@ -4,11 +4,10 @@ Sistemas afectados por tenant
|
||||
"""
|
||||
from sqlalchemy import String, Text, Boolean, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from typing import List, Optional
|
||||
import uuid
|
||||
|
||||
from app.core.database import Base
|
||||
from app.core.database import Base, GUID
|
||||
|
||||
class System(Base):
|
||||
"""Modelo de sistemas afectados (affected_systems en BD)"""
|
||||
@@ -21,7 +20,7 @@ class System(Base):
|
||||
|
||||
# ✅ AÑADIDO: tenant_id obligatorio para multi-tenancy (faltaba completamente)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False
|
||||
)
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
Tenant Model - ServiceManagerWeb
|
||||
Modelo para organizaciones cliente (multi-tenancy)
|
||||
"""
|
||||
from sqlalchemy import String, Integer, Text, Boolean, ARRAY
|
||||
from sqlalchemy import String, Integer, Text, Boolean, JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, ENUM
|
||||
from sqlalchemy.dialects.postgresql import UUID, ENUM, ARRAY as PG_ARRAY
|
||||
from typing import List, Optional
|
||||
import enum
|
||||
import uuid
|
||||
@@ -40,7 +40,7 @@ class Tenant(Base):
|
||||
max_users: Mapped[int] = mapped_column(Integer, default=50)
|
||||
max_storage_mb: Mapped[int] = mapped_column(Integer, default=1024)
|
||||
allowed_file_types: Mapped[List[str]] = mapped_column(
|
||||
ARRAY(String),
|
||||
JSON().with_variant(PG_ARRAY(String), "postgresql"),
|
||||
default=["pdf", "jpg", "jpeg", "png", "doc", "docx", "xls", "xlsx", "txt"]
|
||||
)
|
||||
|
||||
|
||||
@@ -2,15 +2,20 @@
|
||||
Ticket Model - ServiceManagerWeb
|
||||
Tickets de soporte - Core del negocio
|
||||
"""
|
||||
from sqlalchemy import String, ForeignKey, Text, Integer, CheckConstraint, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, ENUM
|
||||
from sqlalchemy import String, ForeignKey, Text, Integer, CheckConstraint, UniqueConstraint, Enum as SAEnum
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship, synonym
|
||||
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
import enum
|
||||
import uuid
|
||||
|
||||
from app.core.database import Base
|
||||
from app.core.database import Base, GUID
|
||||
|
||||
|
||||
def _generate_fallback_ticket_number() -> str:
|
||||
# Matches helper format "TK-000001" and stays within VARCHAR(20)
|
||||
return f"TK-{(uuid.uuid4().int % 1_000_000):06d}"
|
||||
|
||||
class TicketStatus(str, enum.Enum):
|
||||
"""Estados posibles de un ticket"""
|
||||
@@ -35,50 +40,64 @@ class Ticket(Base):
|
||||
|
||||
# Multi-tenancy
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False
|
||||
)
|
||||
|
||||
# Campos básicos
|
||||
ticket_number: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
ticket_number: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
default=_generate_fallback_ticket_number,
|
||||
)
|
||||
subject: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
# Compatibility aliases (API/UI/tests often use these names)
|
||||
title = synonym("subject")
|
||||
system_id = synonym("affected_system_id")
|
||||
|
||||
# Estado y Prioridad
|
||||
status: Mapped[TicketStatus] = mapped_column(
|
||||
ENUM(TicketStatus, name="ticket_status_enum", create_type=False),
|
||||
SAEnum(TicketStatus, name="ticket_status_enum", native_enum=False).with_variant(
|
||||
PG_ENUM(TicketStatus, name="ticket_status_enum", create_type=True),
|
||||
"postgresql",
|
||||
),
|
||||
default=TicketStatus.NEW,
|
||||
nullable=False
|
||||
)
|
||||
priority: Mapped[TicketPriority] = mapped_column(
|
||||
ENUM(TicketPriority, name="ticket_priority_enum", create_type=False),
|
||||
SAEnum(TicketPriority, name="ticket_priority_enum", native_enum=False).with_variant(
|
||||
PG_ENUM(TicketPriority, name="ticket_priority_enum", create_type=True),
|
||||
"postgresql",
|
||||
),
|
||||
default=TicketPriority.MEDIUM,
|
||||
nullable=False
|
||||
)
|
||||
|
||||
# ✅ CORREGIDO: Foreign Keys apuntan a tablas correctas
|
||||
created_by: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("users.id"),
|
||||
nullable=False
|
||||
)
|
||||
assigned_to: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("users.id"),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# ✅ CORREGIDO: Renombrado de system_id a affected_system_id
|
||||
affected_system_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("affected_systems.id"), # ✅ Tabla correcta
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# ✅ CORREGIDO: Foreign key a tabla correcta
|
||||
category_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("ticket_categories.id"), # ✅ Tabla correcta
|
||||
nullable=True
|
||||
)
|
||||
|
||||
@@ -4,15 +4,15 @@ User Model - ServiceManagerWeb
|
||||
Modelo para usuarios del sistema (internos y clientes)
|
||||
"""
|
||||
|
||||
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Text, ARRAY
|
||||
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Text, JSON, Enum as SAEnum
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, ENUM
|
||||
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM, ARRAY as PG_ARRAY
|
||||
from typing import Optional, List
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from app.core.database import Base
|
||||
from app.core.database import Base, GUID
|
||||
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
@@ -35,7 +35,7 @@ class User(Base):
|
||||
|
||||
# Relación con tenant
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
GUID(),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False
|
||||
)
|
||||
@@ -48,12 +48,20 @@ class User(Base):
|
||||
|
||||
# Autenticación
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
role: Mapped[UserRole] = mapped_column(ENUM(UserRole, name="user_role_enum"), nullable=False)
|
||||
role: Mapped[UserRole] = mapped_column(
|
||||
SAEnum(UserRole, name="user_role_enum", native_enum=False).with_variant(
|
||||
PG_ENUM(UserRole, name="user_role_enum", create_type=True),
|
||||
"postgresql",
|
||||
),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# 2FA (opcional para staff interno)
|
||||
totp_secret: Mapped[Optional[str]] = mapped_column(String(32))
|
||||
totp_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
backup_codes: Mapped[Optional[List[str]]] = mapped_column(ARRAY(String))
|
||||
backup_codes: Mapped[Optional[List[str]]] = mapped_column(
|
||||
JSON().with_variant(PG_ARRAY(String), "postgresql")
|
||||
)
|
||||
|
||||
# Estado
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
@@ -85,11 +93,6 @@ class User(Base):
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
# Unique constraint por tenant
|
||||
__table_args__ = (
|
||||
{"postgresql_tablespace": "users"},
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<User(id={self.id}, email='{self.email}', role='{self.role}')>"
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Servicio para gesti├│n de refresh tokens persistentes.
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
import uuid
|
||||
import structlog
|
||||
@@ -56,7 +56,7 @@ class TokenService:
|
||||
RefreshToken creado
|
||||
"""
|
||||
# Calcular expiraci├│n
|
||||
expires_at = datetime.utcnow() + timedelta(
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(
|
||||
days=settings.REFRESH_TOKEN_EXPIRE_DAYS
|
||||
)
|
||||
|
||||
@@ -232,7 +232,7 @@ class TokenService:
|
||||
N├║mero de tokens eliminados
|
||||
"""
|
||||
# Eliminar tokens expirados hace más de 7 días
|
||||
cutoff_date = datetime.utcnow() - timedelta(days=7)
|
||||
cutoff_date = datetime.now(timezone.utc) - timedelta(days=7)
|
||||
|
||||
query = delete(RefreshToken).where(
|
||||
RefreshToken.expires_at < cutoff_date
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
[tool:pytest]
|
||||
[pytest]
|
||||
testpaths = tests tests/unit tests/integration
|
||||
python_files = test_*.py
|
||||
python_functions = test_*
|
||||
|
||||
@@ -6,6 +6,7 @@ Fixtures y utilidades para tests de integración con BD real
|
||||
|
||||
import pytest
|
||||
import asyncio
|
||||
import os
|
||||
from typing import AsyncGenerator, Generator
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.pool import NullPool
|
||||
@@ -21,8 +22,19 @@ from app.models.system import System
|
||||
from app.models.category import Category
|
||||
|
||||
|
||||
# Database URL para testing (usa la misma BD pero limpia después)
|
||||
TEST_DATABASE_URL = "postgresql+asyncpg://servicemanager:servicemanager123@localhost:5432/servicemanager_test"
|
||||
# Database URL para testing.
|
||||
# - En host/local: usa localhost
|
||||
# - En Docker: deriva de DATABASE_URL (normalmente apunta a host 'postgres')
|
||||
_DEFAULT_TEST_DATABASE_URL = "postgresql+asyncpg://servicemanager:servicemanager123@localhost:5432/servicemanager_test"
|
||||
_ENV_TEST_DATABASE_URL = os.getenv("TEST_DATABASE_URL")
|
||||
_ENV_DATABASE_URL = os.getenv("DATABASE_URL")
|
||||
|
||||
if _ENV_TEST_DATABASE_URL:
|
||||
TEST_DATABASE_URL = _ENV_TEST_DATABASE_URL
|
||||
elif _ENV_DATABASE_URL and "@postgres:" in _ENV_DATABASE_URL:
|
||||
TEST_DATABASE_URL = _ENV_DATABASE_URL.rsplit("/", 1)[0] + "/servicemanager_test"
|
||||
else:
|
||||
TEST_DATABASE_URL = _DEFAULT_TEST_DATABASE_URL
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
@@ -99,8 +111,8 @@ async def test_tenant(db_session: AsyncSession) -> Tenant:
|
||||
slug="test-company",
|
||||
domain="test.company.com",
|
||||
status=TenantStatus.ACTIVE,
|
||||
email="admin@test.company.com",
|
||||
phone="+1234567890"
|
||||
contact_email="admin@test.company.com",
|
||||
contact_phone="+1234567890",
|
||||
)
|
||||
db_session.add(tenant)
|
||||
await db_session.commit()
|
||||
@@ -116,8 +128,8 @@ async def test_tenant_2(db_session: AsyncSession) -> Tenant:
|
||||
slug="test-company-2",
|
||||
domain="test2.company.com",
|
||||
status=TenantStatus.ACTIVE,
|
||||
email="admin@test2.company.com",
|
||||
phone="+9876543210"
|
||||
contact_email="admin@test2.company.com",
|
||||
contact_phone="+9876543210",
|
||||
)
|
||||
db_session.add(tenant)
|
||||
await db_session.commit()
|
||||
|
||||
289
backend/tests/integration/conftest.py
Normal file
289
backend/tests/integration/conftest.py
Normal file
@@ -0,0 +1,289 @@
|
||||
"""Integration Test Configuration - ServiceManagerWeb
|
||||
|
||||
Fixtures y utilidades para tests de integración con BD real.
|
||||
|
||||
Este conftest vive dentro de tests/integration para que sus fixtures (client, db_session,
|
||||
test_tenant, tokens, etc.) apliquen solo a los tests de integración y no colisionen con
|
||||
los fixtures SQLite del conftest global.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.pool import NullPool
|
||||
from sqlalchemy import text
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.main import app
|
||||
from app.core.database import Base, get_db
|
||||
from app.core.security import SecurityUtils
|
||||
from app.models.tenant import Tenant, TenantStatus
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.system import System
|
||||
from app.models.category import Category
|
||||
|
||||
|
||||
_DEFAULT_TEST_DATABASE_URL = "postgresql+asyncpg://servicemanager:servicemanager123@localhost:5432/servicemanager_test"
|
||||
_ENV_TEST_DATABASE_URL = os.getenv("TEST_DATABASE_URL")
|
||||
_ENV_DATABASE_URL = os.getenv("DATABASE_URL")
|
||||
|
||||
if _ENV_TEST_DATABASE_URL:
|
||||
TEST_DATABASE_URL = _ENV_TEST_DATABASE_URL
|
||||
elif _ENV_DATABASE_URL and "@postgres:" in _ENV_DATABASE_URL:
|
||||
TEST_DATABASE_URL = _ENV_DATABASE_URL.rsplit("/", 1)[0] + "/servicemanager_test"
|
||||
else:
|
||||
TEST_DATABASE_URL = _DEFAULT_TEST_DATABASE_URL
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def test_engine():
|
||||
"""Create test database engine."""
|
||||
engine = create_async_engine(
|
||||
TEST_DATABASE_URL,
|
||||
echo=False,
|
||||
poolclass=NullPool,
|
||||
)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
yield engine
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_session(test_engine) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Create a fresh database session for each integration test."""
|
||||
async_session = async_sessionmaker(
|
||||
test_engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
async with async_session() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
# Rollback any open transaction
|
||||
await session.rollback()
|
||||
|
||||
# Hard reset DB state for next test (tests commit, so rollback alone isn't enough)
|
||||
table_names = [t.name for t in Base.metadata.sorted_tables]
|
||||
if table_names:
|
||||
quoted = ", ".join(f'"{name}"' for name in table_names)
|
||||
await session.execute(text(f"TRUNCATE TABLE {quoted} RESTART IDENTITY CASCADE"))
|
||||
await session.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
|
||||
"""Create test client with overridden database dependency."""
|
||||
|
||||
# Disable login rate limiting during integration tests to avoid flakiness
|
||||
# (tests perform many logins quickly from the same IP).
|
||||
import app.api.v1.endpoints.auth as auth_endpoint
|
||||
|
||||
old_rate_limit_enabled = getattr(auth_endpoint.settings, "RATE_LIMIT_ENABLED", None)
|
||||
old_testing = getattr(auth_endpoint.settings, "TESTING", None)
|
||||
auth_endpoint.settings.RATE_LIMIT_ENABLED = False
|
||||
auth_endpoint.settings.TESTING = True
|
||||
|
||||
async def override_get_db():
|
||||
yield db_session
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
async with AsyncClient(app=app, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
# Restore settings
|
||||
if old_rate_limit_enabled is not None:
|
||||
auth_endpoint.settings.RATE_LIMIT_ENABLED = old_rate_limit_enabled
|
||||
if old_testing is not None:
|
||||
auth_endpoint.settings.TESTING = old_testing
|
||||
|
||||
|
||||
# ===================================
|
||||
# FIXTURES DE DATOS DE TEST
|
||||
# ===================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_tenant(db_session: AsyncSession) -> Tenant:
|
||||
tenant = Tenant(
|
||||
name="Test Company",
|
||||
slug="test-company",
|
||||
domain="test.company.com",
|
||||
status=TenantStatus.ACTIVE,
|
||||
contact_email="admin@test.company.com",
|
||||
contact_phone="+1234567890",
|
||||
)
|
||||
db_session.add(tenant)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(tenant)
|
||||
return tenant
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_tenant_2(db_session: AsyncSession) -> Tenant:
|
||||
tenant = Tenant(
|
||||
name="Test Company 2",
|
||||
slug="test-company-2",
|
||||
domain="test2.company.com",
|
||||
status=TenantStatus.ACTIVE,
|
||||
contact_email="admin@test2.company.com",
|
||||
contact_phone="+9876543210",
|
||||
)
|
||||
db_session.add(tenant)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(tenant)
|
||||
return tenant
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_admin_user(db_session: AsyncSession, test_tenant: Tenant) -> User:
|
||||
user = User(
|
||||
tenant_id=test_tenant.id,
|
||||
email="admin@test.com",
|
||||
first_name="Admin",
|
||||
last_name="User",
|
||||
password_hash=SecurityUtils.hash_password("AdminPass123!"),
|
||||
role=UserRole.ADMIN,
|
||||
is_active=True,
|
||||
email_verified=True,
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_agent_user(db_session: AsyncSession, test_tenant: Tenant) -> User:
|
||||
user = User(
|
||||
tenant_id=test_tenant.id,
|
||||
email="agent@test.com",
|
||||
first_name="Agent",
|
||||
last_name="User",
|
||||
password_hash=SecurityUtils.hash_password("AgentPass123!"),
|
||||
role=UserRole.AGENT,
|
||||
is_active=True,
|
||||
email_verified=True,
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_client_user(db_session: AsyncSession, test_tenant: Tenant) -> User:
|
||||
user = User(
|
||||
tenant_id=test_tenant.id,
|
||||
email="client@test.com",
|
||||
first_name="Client",
|
||||
last_name="User",
|
||||
password_hash=SecurityUtils.hash_password("ClientPass123!"),
|
||||
role=UserRole.CLIENT_USER,
|
||||
is_active=True,
|
||||
email_verified=True,
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_system(db_session: AsyncSession, test_tenant: Tenant) -> System:
|
||||
system = System(
|
||||
name="Test System",
|
||||
description="Test system description",
|
||||
tenant_id=test_tenant.id,
|
||||
is_active=True,
|
||||
)
|
||||
db_session.add(system)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(system)
|
||||
return system
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_category(db_session: AsyncSession, test_tenant: Tenant) -> Category:
|
||||
category = Category(
|
||||
name="Test Category",
|
||||
description="Test category description",
|
||||
tenant_id=test_tenant.id,
|
||||
is_active=True,
|
||||
sla_response_hours=24,
|
||||
sla_resolution_hours=72,
|
||||
)
|
||||
db_session.add(category)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(category)
|
||||
return category
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_token(client: AsyncClient, test_admin_user: User, test_tenant: Tenant) -> str:
|
||||
response = await client.post(
|
||||
"/v1/auth/login",
|
||||
json={
|
||||
"email": test_admin_user.email,
|
||||
"password": "AdminPass123!",
|
||||
"tenant_slug": test_tenant.slug,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
return response.json()["access_token"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def agent_token(client: AsyncClient, test_agent_user: User, test_tenant: Tenant) -> str:
|
||||
response = await client.post(
|
||||
"/v1/auth/login",
|
||||
json={
|
||||
"email": test_agent_user.email,
|
||||
"password": "AgentPass123!",
|
||||
"tenant_slug": test_tenant.slug,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
return response.json()["access_token"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client_token(client: AsyncClient, test_client_user: User, test_tenant: Tenant) -> str:
|
||||
response = await client.post(
|
||||
"/v1/auth/login",
|
||||
json={
|
||||
"email": test_client_user.email,
|
||||
"password": "ClientPass123!",
|
||||
"tenant_slug": test_tenant.slug,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
return response.json()["access_token"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers_admin(admin_token: str) -> dict:
|
||||
return {"Authorization": f"Bearer {admin_token}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers_agent(agent_token: str) -> dict:
|
||||
return {"Authorization": f"Bearer {agent_token}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers_client(client_token: str) -> dict:
|
||||
return {"Authorization": f"Bearer {client_token}"}
|
||||
@@ -16,9 +16,6 @@ from app.models.user import User, UserRole
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
# Importar fixtures desde conftest_integration
|
||||
pytest_plugins = ['tests.conftest_integration']
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.auth
|
||||
class TestAuthentication:
|
||||
@@ -126,6 +123,58 @@ class TestAuthentication:
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
async def test_login_rate_limited_after_too_many_attempts(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
test_admin_user: User,
|
||||
test_tenant: Tenant,
|
||||
monkeypatch,
|
||||
):
|
||||
"""Debe devolver 429 después de demasiados intentos de login (rate limit)."""
|
||||
|
||||
import app.api.v1.endpoints.auth as auth_endpoint
|
||||
|
||||
class _FakeCache:
|
||||
def __init__(self):
|
||||
self._counts = {}
|
||||
self._expires = {}
|
||||
|
||||
async def incr(self, key: str, amount: int = 1):
|
||||
self._counts[key] = self._counts.get(key, 0) + amount
|
||||
return self._counts[key]
|
||||
|
||||
async def expire(self, key: str, ttl: int):
|
||||
self._expires[key] = ttl
|
||||
return True
|
||||
|
||||
async def delete(self, key: str):
|
||||
self._counts.pop(key, None)
|
||||
return True
|
||||
|
||||
fake_cache = _FakeCache()
|
||||
monkeypatch.setattr(auth_endpoint, "cache", fake_cache)
|
||||
monkeypatch.setattr(auth_endpoint.settings, "RATE_LIMIT_ENABLED", True, raising=False)
|
||||
monkeypatch.setattr(auth_endpoint.settings, "TESTING", False, raising=False)
|
||||
monkeypatch.setattr(auth_endpoint.settings, "LOGIN_RATE_LIMIT_WINDOW_SECONDS", 60, raising=False)
|
||||
monkeypatch.setattr(auth_endpoint.settings, "LOGIN_RATE_LIMIT_IP_MAX_ATTEMPTS", 10_000, raising=False)
|
||||
monkeypatch.setattr(auth_endpoint.settings, "LOGIN_RATE_LIMIT_ID_MAX_ATTEMPTS", 2, raising=False)
|
||||
|
||||
payload = {
|
||||
"email": test_admin_user.email,
|
||||
"password": "WrongPassword123!",
|
||||
"tenant_slug": test_tenant.slug,
|
||||
}
|
||||
|
||||
r1 = await client.post("/v1/auth/login", json=payload)
|
||||
assert r1.status_code == 401
|
||||
|
||||
r2 = await client.post("/v1/auth/login", json=payload)
|
||||
assert r2.status_code == 401
|
||||
|
||||
r3 = await client.post("/v1/auth/login", json=payload)
|
||||
assert r3.status_code == 429
|
||||
assert "Retry-After" in r3.headers
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.auth
|
||||
@@ -305,13 +354,17 @@ class TestUserProfile:
|
||||
async def test_get_current_user_profile(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
test_tenant: Tenant,
|
||||
test_admin_user: User,
|
||||
auth_headers_admin: dict
|
||||
):
|
||||
"""Test obtener perfil del usuario actual."""
|
||||
response = await client.get(
|
||||
"/v1/users/me",
|
||||
headers=auth_headers_admin
|
||||
headers={
|
||||
**auth_headers_admin,
|
||||
"X-Tenant-ID": str(test_tenant.id),
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -348,13 +401,17 @@ class TestPasswordSecurity:
|
||||
async def test_password_not_exposed_in_response(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
test_tenant: Tenant,
|
||||
test_admin_user: User,
|
||||
auth_headers_admin: dict
|
||||
):
|
||||
"""Test que el password hash nunca se expone en las respuestas."""
|
||||
response = await client.get(
|
||||
"/v1/users/me",
|
||||
headers=auth_headers_admin
|
||||
headers={
|
||||
**auth_headers_admin,
|
||||
"X-Tenant-ID": str(test_tenant.id),
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -14,9 +14,6 @@ from app.models.tenant import Tenant
|
||||
from app.models.ticket import Ticket, TicketStatus, TicketPriority
|
||||
from app.core.security import SecurityUtils
|
||||
|
||||
pytest_plugins = ['tests.conftest_integration']
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
class TestTenantIsolation:
|
||||
|
||||
@@ -1,78 +1,56 @@
|
||||
"""
|
||||
Quick Test Verification - ServiceManagerWeb
|
||||
"""Quick Test Verification - ServiceManagerWeb
|
||||
|
||||
Test rápido para verificar que la configuración de tests funciona correctamente.
|
||||
Smoke tests para verificar que el setup de tests de integración funciona correctamente.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
pytest_plugins = ['tests.conftest_integration']
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestSetupVerification:
|
||||
"""Verificar que el setup de tests funciona."""
|
||||
|
||||
async def test_client_fixture_works(self, client: AsyncClient):
|
||||
"""Test que el fixture de client HTTP funciona."""
|
||||
assert client is not None
|
||||
assert client.base_url == "http://test"
|
||||
|
||||
assert str(client.base_url) == "http://test"
|
||||
|
||||
async def test_database_connection(self, db_session):
|
||||
"""Test que la conexión a BD de testing funciona."""
|
||||
assert db_session is not None
|
||||
|
||||
# Ejecutar query simple
|
||||
from sqlalchemy import text
|
||||
|
||||
result = await db_session.execute(text("SELECT 1"))
|
||||
assert result.scalar() == 1
|
||||
|
||||
|
||||
async def test_tenant_fixture_creates_tenant(self, test_tenant):
|
||||
"""Test que el fixture de tenant funciona."""
|
||||
assert test_tenant is not None
|
||||
assert test_tenant.name == "Test Company"
|
||||
assert test_tenant.slug == "test-company"
|
||||
|
||||
|
||||
async def test_user_fixtures_work(self, test_admin_user, test_agent_user, test_client_user):
|
||||
"""Test que los fixtures de usuarios funcionan."""
|
||||
assert test_admin_user.role.value == "ADMIN"
|
||||
assert test_agent_user.role.value == "AGENT"
|
||||
assert test_client_user.role.value == "CLIENT_USER"
|
||||
|
||||
async def test_auth_token_generation(self, admin_token):
|
||||
"""Test que la generación de tokens funciona."""
|
||||
assert admin_token is not None
|
||||
|
||||
async def test_auth_token_generation(self, admin_token: str):
|
||||
assert isinstance(admin_token, str)
|
||||
assert len(admin_token) > 20
|
||||
|
||||
|
||||
async def test_health_endpoint(self, client: AsyncClient):
|
||||
"""Test que el endpoint de health funciona."""
|
||||
response = await client.get("/health")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "healthy"
|
||||
assert response.json()["status"] == "healthy"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestBasicEndpoints:
|
||||
"""Tests básicos de endpoints para verificar conectividad."""
|
||||
|
||||
async def test_health_endpoint_detailed(self, client: AsyncClient):
|
||||
"""Test del endpoint de health detallado."""
|
||||
response = await client.get("/v1/health/detailed")
|
||||
assert response.status_code == 200
|
||||
|
||||
assert response.status_code in (200, 503)
|
||||
|
||||
async def test_login_endpoint_exists(self, client: AsyncClient):
|
||||
"""Test que el endpoint de login responde."""
|
||||
# Enviar credenciales inválidas para verificar que el endpoint existe
|
||||
response = await client.post(
|
||||
"/v1/auth/login",
|
||||
json={
|
||||
"email": "nonexistent@test.com",
|
||||
"password": "wrong",
|
||||
"tenant_slug": "nonexistent"
|
||||
}
|
||||
"tenant_slug": "nonexistent",
|
||||
},
|
||||
)
|
||||
# Debe responder (aunque con error)
|
||||
assert response.status_code in [401, 404, 422]
|
||||
assert response.status_code in (401, 404, 422)
|
||||
@@ -6,6 +6,7 @@ Tests completos del CRUD de tickets y funcionalidad relacionada.
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import uuid
|
||||
|
||||
@@ -14,8 +15,7 @@ from app.models.tenant import Tenant
|
||||
from app.models.ticket import Ticket, TicketStatus, TicketPriority
|
||||
from app.models.system import System
|
||||
from app.models.category import Category
|
||||
|
||||
pytest_plugins = ['tests.conftest_integration']
|
||||
from app.core.file_handler import file_handler
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -611,3 +611,66 @@ class TestTicketPermissions:
|
||||
|
||||
# Debe ver ambos tickets
|
||||
assert len(tickets) >= 2
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
class TestTicketAttachmentPermissions:
|
||||
async def test_client_cannot_download_other_users_attachment(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
test_tenant: Tenant,
|
||||
test_category: Category,
|
||||
auth_headers_admin: dict,
|
||||
auth_headers_client: dict,
|
||||
):
|
||||
# Admin crea ticket
|
||||
create_resp = await client.post(
|
||||
"/v1/tickets/",
|
||||
headers={
|
||||
**auth_headers_admin,
|
||||
"X-Tenant-ID": str(test_tenant.id),
|
||||
},
|
||||
json={
|
||||
"title": "Admin ticket",
|
||||
"description": "Ticket with attachment",
|
||||
"priority": "MEDIUM",
|
||||
"category_id": str(test_category.id),
|
||||
},
|
||||
)
|
||||
assert create_resp.status_code == 201
|
||||
ticket_id = create_resp.json()["id"]
|
||||
|
||||
# Admin sube adjunto (PDF válido por magic bytes)
|
||||
pdf_bytes = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF\n"
|
||||
upload_resp = await client.post(
|
||||
f"/v1/tickets/{ticket_id}/attachments",
|
||||
headers={
|
||||
**auth_headers_admin,
|
||||
"X-Tenant-ID": str(test_tenant.id),
|
||||
},
|
||||
files={
|
||||
"file": ("test.pdf", pdf_bytes, "application/pdf"),
|
||||
},
|
||||
)
|
||||
assert upload_resp.status_code == 201
|
||||
attachment_data = upload_resp.json()["data"]
|
||||
attachment_id = attachment_data["id"]
|
||||
|
||||
# Cliente intenta descargar adjunto de ticket ajeno -> 404
|
||||
download_resp = await client.get(
|
||||
f"/v1/tickets/{ticket_id}/attachments/{attachment_id}/download",
|
||||
headers={
|
||||
**auth_headers_client,
|
||||
"X-Tenant-ID": str(test_tenant.id),
|
||||
},
|
||||
)
|
||||
assert download_resp.status_code == 404
|
||||
|
||||
# Limpieza del archivo subido (mejor esfuerzo)
|
||||
try:
|
||||
uploaded_path = file_handler.get_file_path(attachment_data["file_path"])
|
||||
if uploaded_path.exists():
|
||||
uploaded_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
80
backend/tests/unit/test_file_handler.py
Normal file
80
backend/tests/unit/test_file_handler.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Unit Tests - FileHandler - ServiceManagerWeb
|
||||
|
||||
Tests para app.core.file_handler.FileHandler.
|
||||
"""
|
||||
|
||||
import io
|
||||
import uuid
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from fastapi import UploadFile
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_upload_pdf_valid_streaming():
|
||||
from app.core.file_handler import FileHandler, settings
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
settings.UPLOAD_PATH = tmp
|
||||
handler = FileHandler()
|
||||
|
||||
tenant_id = uuid.uuid4()
|
||||
ticket_id = uuid.uuid4()
|
||||
|
||||
content = b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n1 0 obj\n<<>>\nendobj\n"
|
||||
up = UploadFile(filename="test.pdf", file=io.BytesIO(content))
|
||||
|
||||
meta = await handler.save_upload(up, tenant_id=tenant_id, ticket_id=ticket_id)
|
||||
assert meta["file_size"] == len(content)
|
||||
assert meta["original_filename"] == "test.pdf"
|
||||
assert meta["filename"].endswith(".pdf")
|
||||
assert meta["md5_hash"]
|
||||
assert meta["sha256_hash"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_upload_pdf_invalid_magic_bytes_rejected():
|
||||
from app.core.file_handler import FileHandler, settings
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
settings.UPLOAD_PATH = tmp
|
||||
handler = FileHandler()
|
||||
|
||||
up = UploadFile(filename="bad.pdf", file=io.BytesIO(b"NOTPDF"))
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await handler.save_upload(up, tenant_id=uuid.uuid4(), ticket_id=uuid.uuid4())
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_upload_oversize_rejected_and_file_removed():
|
||||
from app.core.file_handler import FileHandler, settings
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
settings.UPLOAD_PATH = tmp
|
||||
settings.MAX_UPLOAD_SIZE_MB = 0 # 0MB => max 0 bytes
|
||||
handler = FileHandler()
|
||||
|
||||
up = UploadFile(filename="a.txt", file=io.BytesIO(b"x"))
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await handler.save_upload(up, tenant_id=uuid.uuid4(), ticket_id=uuid.uuid4())
|
||||
|
||||
assert exc.value.status_code == 413
|
||||
|
||||
|
||||
def test_get_file_path_prevents_path_traversal():
|
||||
from app.core.file_handler import FileHandler, settings
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
settings.UPLOAD_PATH = tmp
|
||||
handler = FileHandler()
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
handler.get_file_path("../../etc/passwd")
|
||||
|
||||
assert exc.value.status_code == 403
|
||||
@@ -124,8 +124,8 @@ class TestMiddlewareNoTenantHeaders:
|
||||
"""Tests para requests sin headers de tenant."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_tenant_headers_in_dev_continues(self):
|
||||
"""En entorno de desarrollo, sin tenant headers continúa con advertencia."""
|
||||
async def test_missing_tenant_headers_returns_400(self):
|
||||
"""Sin tenant headers debe retornar 400 (requerido para aislamiento multi-tenant)."""
|
||||
from app.middleware.tenant import TenantMiddleware
|
||||
|
||||
mock_app = AsyncMock()
|
||||
@@ -139,18 +139,15 @@ class TestMiddlewareNoTenantHeaders:
|
||||
|
||||
call_next = AsyncMock(return_value=MagicMock(status_code=200))
|
||||
|
||||
# En modo testing (que hereda de development), debe continuar
|
||||
response = await middleware.dispatch(request, call_next)
|
||||
|
||||
# El request continúa (call_next fue llamado)
|
||||
call_next.assert_called_once()
|
||||
assert response.status_code == 400
|
||||
call_next.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_tenant_headers_in_production_returns_400(self):
|
||||
"""En producción, sin tenant headers retorna 400."""
|
||||
async def test_missing_tenant_headers_does_not_call_next(self):
|
||||
"""Sin tenant headers no debe llegar al handler (call_next)."""
|
||||
from app.middleware.tenant import TenantMiddleware
|
||||
from app.core.config import get_settings
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
mock_app = AsyncMock()
|
||||
middleware = TenantMiddleware(mock_app)
|
||||
@@ -163,13 +160,10 @@ class TestMiddlewareNoTenantHeaders:
|
||||
|
||||
call_next = AsyncMock(return_value=MagicMock(status_code=200))
|
||||
|
||||
with patch.object(get_settings(), "ENVIRONMENT", "production"):
|
||||
response = await middleware.dispatch(request, call_next)
|
||||
response = await middleware.dispatch(request, call_next)
|
||||
|
||||
# En producción sin tenant debe retornar error
|
||||
# (si la response es JSONResponse con status 400, el test pasa)
|
||||
if hasattr(response, "status_code"):
|
||||
assert response.status_code in [400, 200] # depende del env
|
||||
assert response.status_code == 400
|
||||
call_next.assert_not_called()
|
||||
|
||||
|
||||
# ============================================================
|
||||
|
||||
Reference in New Issue
Block a user