feat: Funcion de sistema tenants

This commit is contained in:
2026-02-23 13:01:24 -07:00
parent ceea67eb2b
commit 1ccc39732b
58 changed files with 1889 additions and 315 deletions

View File

@@ -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()

View 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}"}

View File

@@ -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

View File

@@ -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:

View File

@@ -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)

View File

@@ -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

View 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

View File

@@ -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()
# ============================================================