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

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

@@ -0,0 +1,56 @@
"""Quick Test Verification - ServiceManagerWeb
Smoke tests para verificar que el setup de tests de integración funciona correctamente.
"""
import pytest
from httpx import AsyncClient
@pytest.mark.integration
class TestSetupVerification:
async def test_client_fixture_works(self, client: AsyncClient):
assert client is not None
assert str(client.base_url) == "http://test"
async def test_database_connection(self, db_session):
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):
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):
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: str):
assert isinstance(admin_token, str)
assert len(admin_token) > 20
async def test_health_endpoint(self, client: AsyncClient):
response = await client.get("/health")
assert response.status_code == 200
assert response.json()["status"] == "healthy"
@pytest.mark.integration
class TestBasicEndpoints:
async def test_health_endpoint_detailed(self, client: AsyncClient):
response = await client.get("/v1/health/detailed")
assert response.status_code in (200, 503)
async def test_login_endpoint_exists(self, client: AsyncClient):
response = await client.post(
"/v1/auth/login",
json={
"email": "nonexistent@test.com",
"password": "wrong",
"tenant_slug": "nonexistent",
},
)
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