feat: Funcion de sistema tenants
This commit is contained in:
@@ -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