ProdLaunch
This commit is contained in:
@@ -0,0 +1,44 @@
|
|||||||
|
"""
|
||||||
|
Migrazione 0022: indice parziale su mailboxes(tenant_id, email_address).
|
||||||
|
|
||||||
|
Sostituisce l'indice UNIQUE completo con un indice parziale che esclude
|
||||||
|
le caselle soft-deleted (status = 'deleted'), permettendo la ri-creazione
|
||||||
|
di una casella con lo stesso indirizzo email dopo averla eliminata.
|
||||||
|
|
||||||
|
Revision ID: 0022_partial_unique_mailbox_email
|
||||||
|
Revises: 0021_add_rem_support
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# ── Identificatori migrazione ─────────────────────────────────────────────────
|
||||||
|
revision = "0022"
|
||||||
|
down_revision = "0021"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Rimuove il vecchio indice univoco completo (include anche i deleted)
|
||||||
|
op.drop_index("uq_mailbox_email_tenant", table_name="mailboxes")
|
||||||
|
|
||||||
|
# Crea un indice univoco parziale: solo caselle non-deleted
|
||||||
|
# Questo permette di avere piu' record soft-deleted con la stessa email
|
||||||
|
# e di ri-creare una casella dopo averla eliminata.
|
||||||
|
op.execute(
|
||||||
|
"CREATE UNIQUE INDEX uq_mailbox_email_tenant_active "
|
||||||
|
"ON mailboxes (tenant_id, email_address) "
|
||||||
|
"WHERE status != 'deleted'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("uq_mailbox_email_tenant_active", table_name="mailboxes")
|
||||||
|
|
||||||
|
# Ricrea l'indice completo originale
|
||||||
|
op.create_index(
|
||||||
|
"uq_mailbox_email_tenant",
|
||||||
|
"mailboxes",
|
||||||
|
["tenant_id", "email_address"],
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
@@ -47,6 +47,7 @@ limiter = Limiter(key_func=get_remote_address)
|
|||||||
summary="Login con email e password",
|
summary="Login con email e password",
|
||||||
description="Autentica l'utente. Se 2FA è attivo, richiede anche il codice TOTP.",
|
description="Autentica l'utente. Se 2FA è attivo, richiede anche il codice TOTP.",
|
||||||
)
|
)
|
||||||
|
@limiter.limit(settings.rate_limit_auth)
|
||||||
async def login(
|
async def login(
|
||||||
request: Request,
|
request: Request,
|
||||||
body: LoginRequest,
|
body: LoginRequest,
|
||||||
@@ -64,6 +65,11 @@ async def login(
|
|||||||
user_agent=ua,
|
user_agent=ua,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Commit esplicito prima di restituire la risposta: garantisce che il
|
||||||
|
# RefreshToken sia gia' in DB quando il client chiama /auth/refresh
|
||||||
|
# in sequenza rapida, evitando race condition.
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
return TokenResponse(
|
return TokenResponse(
|
||||||
access_token=access_token,
|
access_token=access_token,
|
||||||
refresh_token=refresh_token,
|
refresh_token=refresh_token,
|
||||||
@@ -83,6 +89,11 @@ async def refresh_tokens(
|
|||||||
service = AuthService(db)
|
service = AuthService(db)
|
||||||
access_token, refresh_token = await service.refresh_tokens(body.refresh_token)
|
access_token, refresh_token = await service.refresh_tokens(body.refresh_token)
|
||||||
|
|
||||||
|
# Commit esplicito prima di restituire la risposta: garantisce che la
|
||||||
|
# revoca del vecchio token (rotation) sia persistita in DB prima che
|
||||||
|
# il client possa usare il nuovo token, evitando race condition.
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
return TokenResponse(
|
return TokenResponse(
|
||||||
access_token=access_token,
|
access_token=access_token,
|
||||||
refresh_token=refresh_token,
|
refresh_token=refresh_token,
|
||||||
|
|||||||
@@ -205,12 +205,14 @@ async def update_mailbox(
|
|||||||
Se vengono fornite nuove credenziali, vengono ri-cifrate.
|
Se vengono fornite nuove credenziali, vengono ri-cifrate.
|
||||||
"""
|
"""
|
||||||
svc = _svc(db)
|
svc = _svc(db)
|
||||||
mailbox = await svc.update_mailbox(
|
await svc.update_mailbox(
|
||||||
mailbox_id=mailbox_id,
|
mailbox_id=mailbox_id,
|
||||||
tenant_id=current_user.tenant_id,
|
tenant_id=current_user.tenant_id,
|
||||||
data=data,
|
data=data,
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
# Ricarica dal DB dopo il commit per evitare lazy-load su oggetto stale
|
||||||
|
mailbox = await svc.get_mailbox(mailbox_id, current_user.tenant_id)
|
||||||
return _build_response(mailbox, svc)
|
return _build_response(mailbox, svc)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -923,9 +923,13 @@ async def get_thread(
|
|||||||
|
|
||||||
# Carica ricorsivamente tutti i messaggi del thread dalla radice
|
# Carica ricorsivamente tutti i messaggi del thread dalla radice
|
||||||
# Limita a posta_certificata (esclude accettazioni, consegne, ecc.)
|
# Limita a posta_certificata (esclude accettazioni, consegne, ecc.)
|
||||||
|
# Depth limit per evitare stack overflow su thread eccezionalmente profondi
|
||||||
|
_THREAD_MAX_DEPTH = 100
|
||||||
thread_messages: list[Message] = []
|
thread_messages: list[Message] = []
|
||||||
|
|
||||||
async def _collect(msg_id: uuid.UUID) -> None:
|
async def _collect(msg_id: uuid.UUID, depth: int = 0) -> None:
|
||||||
|
if depth >= _THREAD_MAX_DEPTH:
|
||||||
|
return
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Message)
|
select(Message)
|
||||||
.where(
|
.where(
|
||||||
@@ -951,7 +955,7 @@ async def get_thread(
|
|||||||
)
|
)
|
||||||
children = list(children_result.scalars().all())
|
children = list(children_result.scalars().all())
|
||||||
for child in children:
|
for child in children:
|
||||||
await _collect(child.id)
|
await _collect(child.id, depth + 1)
|
||||||
|
|
||||||
await _collect(root_id)
|
await _collect(root_id)
|
||||||
|
|
||||||
|
|||||||
@@ -80,13 +80,18 @@ def create_refresh_token(subject: str | UUID, tenant_id: str | UUID) -> str:
|
|||||||
"""
|
"""
|
||||||
Crea un JWT refresh token con scadenza lunga (30 giorni default).
|
Crea un JWT refresh token con scadenza lunga (30 giorni default).
|
||||||
Non contiene il ruolo – viene rivalutato a ogni refresh.
|
Non contiene il ruolo – viene rivalutato a ogni refresh.
|
||||||
|
|
||||||
|
Include un jti (JWT ID) UUID per garantire unicita' anche se due token
|
||||||
|
vengono creati nello stesso secondo per lo stesso utente.
|
||||||
"""
|
"""
|
||||||
|
import uuid as _uuid
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
expire = now + timedelta(days=settings.refresh_token_expire_days)
|
expire = now + timedelta(days=settings.refresh_token_expire_days)
|
||||||
|
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"sub": str(subject),
|
"sub": str(subject),
|
||||||
"tid": str(tenant_id),
|
"tid": str(tenant_id),
|
||||||
|
"jti": str(_uuid.uuid4()), # JWT ID unico per evitare hash duplicati in DB
|
||||||
"iat": now,
|
"iat": now,
|
||||||
"exp": expire,
|
"exp": expire,
|
||||||
"type": "refresh",
|
"type": "refresh",
|
||||||
|
|||||||
+32
-1
@@ -24,14 +24,45 @@ settings = get_settings()
|
|||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_production_config() -> None:
|
||||||
|
"""
|
||||||
|
Verifica che le variabili critiche di sicurezza siano state
|
||||||
|
sostituite rispetto ai valori di default insicuri.
|
||||||
|
Blocca il boot se APP_ENV=production e i valori non sono stati cambiati.
|
||||||
|
"""
|
||||||
|
insecure_defaults = {
|
||||||
|
"SECRET_KEY": (settings.secret_key, "change-me-in-production"),
|
||||||
|
"ENCRYPTION_KEY": (settings.encryption_key, "0" * 64),
|
||||||
|
}
|
||||||
|
errors: list[str] = []
|
||||||
|
for var, (current, default) in insecure_defaults.items():
|
||||||
|
if current == default:
|
||||||
|
errors.append(f"{var} non e' stato impostato (usa ancora il valore di default)")
|
||||||
|
|
||||||
|
if not settings.admin_secret_key:
|
||||||
|
errors.append(
|
||||||
|
"ADMIN_SECRET_KEY e' vuota: gli endpoint /api/v1/tenants non sono protetti"
|
||||||
|
)
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
for err in errors:
|
||||||
|
logger.warning(f"[CONFIG] {err}")
|
||||||
|
if settings.is_production:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Configurazione insicura rilevata in ambiente production. "
|
||||||
|
"Correggere le variabili e riavviare: " + "; ".join(errors)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||||
"""Gestione ciclo di vita dell'applicazione."""
|
"""Gestione ciclo di vita dell'applicazione."""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
setup_logging()
|
setup_logging()
|
||||||
|
_validate_production_config()
|
||||||
logger.info(
|
logger.info(
|
||||||
"🚀 PEChub Backend avviato",
|
"PEChub Backend avviato",
|
||||||
extra={"env": settings.app_env, "debug": settings.app_debug},
|
extra={"env": settings.app_env, "debug": settings.app_debug},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ class MailboxService:
|
|||||||
"Aggiorna il piano per aggiungerne altre."
|
"Aggiorna il piano per aggiungerne altre."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verifica unicità email nel tenant
|
# Verifica unicità email nel tenant (solo caselle non-deleted).
|
||||||
|
# L'indice parziale DB esclude le caselle soft-deleted, permettendo
|
||||||
|
# la ri-creazione di una casella con lo stesso indirizzo.
|
||||||
existing = await self.db.execute(
|
existing = await self.db.execute(
|
||||||
select(Mailbox.id).where(
|
select(Mailbox.id).where(
|
||||||
Mailbox.tenant_id == tenant_id,
|
Mailbox.tenant_id == tenant_id,
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""
|
||||||
|
Configurazione test E2E – punta al server live.
|
||||||
|
|
||||||
|
Utilizzo:
|
||||||
|
# Dal server remoto o da locale con accesso alla porta 80:
|
||||||
|
BASE_URL=http://212.83.140.21 pytest tests/e2e/ -v --tb=short
|
||||||
|
|
||||||
|
# Oppure con pytest.ini/pyproject.toml configurato:
|
||||||
|
pytest tests/e2e/ -v -m e2e
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
# ── Configurazione ────────────────────────────────────────────────────────────
|
||||||
|
BASE_URL = os.environ.get("E2E_BASE_URL", "http://212.83.140.21")
|
||||||
|
API_URL = f"{BASE_URL}/api/v1"
|
||||||
|
|
||||||
|
# Credenziali admin del tenant demo (non super_admin)
|
||||||
|
ADMIN_EMAIL = os.environ.get("E2E_ADMIN_EMAIL", "admin@demo.pechub.it")
|
||||||
|
ADMIN_PASSWORD = os.environ.get("E2E_ADMIN_PASSWORD", "Demo@PEChub2026!")
|
||||||
|
|
||||||
|
# Credenziali casella PEC di test Aruba
|
||||||
|
PEC_EMAIL = "matteo.giustini@arubapec.it"
|
||||||
|
PEC_PASSWORD = "MadonnaPuttana1!"
|
||||||
|
PEC_IMAP_HOST = "imaps.pec.aruba.it"
|
||||||
|
PEC_IMAP_PORT = 993
|
||||||
|
PEC_SMTP_HOST = "smtps.pec.aruba.it"
|
||||||
|
PEC_SMTP_PORT = 465
|
||||||
|
|
||||||
|
# Destinatario per test invio (non PEC)
|
||||||
|
SEND_TEST_TO = "matteo1801@spidmail.it"
|
||||||
|
|
||||||
|
# Timeout per le richieste HTTP
|
||||||
|
REQUEST_TIMEOUT = 30.0
|
||||||
|
|
||||||
|
|
||||||
|
# ── Stato condiviso tra i test (simulazione sessione) ─────────────────────────
|
||||||
|
class E2EState:
|
||||||
|
"""Contiene lo stato accumulato durante l'esecuzione dei test E2E."""
|
||||||
|
access_token: str = ""
|
||||||
|
refresh_token: str = ""
|
||||||
|
mailbox_id: str = ""
|
||||||
|
message_id: str = ""
|
||||||
|
attachment_id: str = ""
|
||||||
|
send_job_id: str = ""
|
||||||
|
user_id: str = ""
|
||||||
|
label_id: str = ""
|
||||||
|
routing_rule_id: str = ""
|
||||||
|
fascicolo_id: str = ""
|
||||||
|
deadline_id: str = ""
|
||||||
|
notification_id: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
state = E2EState()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Fixtures ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def base_url() -> str:
|
||||||
|
return BASE_URL
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def api_url() -> str:
|
||||||
|
return API_URL
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def http() -> httpx.Client:
|
||||||
|
"""Client HTTP sincrono condiviso per tutta la sessione di test."""
|
||||||
|
with httpx.Client(
|
||||||
|
base_url=API_URL,
|
||||||
|
timeout=REQUEST_TIMEOUT,
|
||||||
|
follow_redirects=True,
|
||||||
|
) as client:
|
||||||
|
yield client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def admin_token(http: httpx.Client) -> str:
|
||||||
|
"""Esegue il login e restituisce l'access token admin."""
|
||||||
|
resp = http.post("/auth/login", json={
|
||||||
|
"email": ADMIN_EMAIL,
|
||||||
|
"password": ADMIN_PASSWORD,
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200, f"Login fallito: {resp.status_code} {resp.text}"
|
||||||
|
data = resp.json()
|
||||||
|
token = data["access_token"]
|
||||||
|
state.access_token = token
|
||||||
|
state.refresh_token = data.get("refresh_token", "")
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def auth_headers(admin_token: str) -> dict:
|
||||||
|
"""Header Authorization per tutte le richieste autenticate."""
|
||||||
|
return {"Authorization": f"Bearer {admin_token}"}
|
||||||
@@ -0,0 +1,906 @@
|
|||||||
|
"""
|
||||||
|
Test E2E completi per l'API PEChub – eseguiti sul server live.
|
||||||
|
|
||||||
|
Organizzati in blocchi progressivi che si passano lo stato tramite
|
||||||
|
il singleton `state` in conftest.py. I test devono essere eseguiti
|
||||||
|
in ordine (pytest-ordering o semplicemente in sequenza di file).
|
||||||
|
|
||||||
|
Esecuzione:
|
||||||
|
# Sul server remoto (dentro /opt/pechub):
|
||||||
|
E2E_BASE_URL=http://localhost:8000 python -m pytest tests/e2e/test_e2e_api.py -v --tb=short -p no:randomly
|
||||||
|
|
||||||
|
# Da locale:
|
||||||
|
E2E_BASE_URL=http://212.83.140.21 python -m pytest tests/e2e/test_e2e_api.py -v --tb=short -p no:randomly
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
import pytest
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from tests.e2e.conftest import (
|
||||||
|
state,
|
||||||
|
PEC_EMAIL,
|
||||||
|
PEC_PASSWORD,
|
||||||
|
PEC_IMAP_HOST,
|
||||||
|
PEC_IMAP_PORT,
|
||||||
|
PEC_SMTP_HOST,
|
||||||
|
PEC_SMTP_PORT,
|
||||||
|
SEND_TEST_TO,
|
||||||
|
ADMIN_EMAIL,
|
||||||
|
ADMIN_PASSWORD,
|
||||||
|
API_URL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# BLOCCO 1 – Health & Auth
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestHealth:
|
||||||
|
"""T01–T02: Verifica infrastruttura."""
|
||||||
|
|
||||||
|
def test_health_ok(self, http: httpx.Client):
|
||||||
|
"""T01 – GET /health → 200 status ok."""
|
||||||
|
base = str(http.base_url).rstrip("/").replace("/api/v1", "")
|
||||||
|
resp = httpx.get(f"{base}/health", follow_redirects=True)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["status"] == "ok"
|
||||||
|
|
||||||
|
def test_health_db_ok(self, http: httpx.Client):
|
||||||
|
"""T02 – GET /health/db → database connected."""
|
||||||
|
base = str(http.base_url).rstrip("/").replace("/api/v1", "")
|
||||||
|
resp = httpx.get(f"{base}/health/db", follow_redirects=True)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["database"] == "connected"
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthLogin:
|
||||||
|
"""T03–T13: Flusso autenticazione completo."""
|
||||||
|
|
||||||
|
def test_login_success(self, http: httpx.Client):
|
||||||
|
"""T03 – Login con credenziali corrette → access e refresh token."""
|
||||||
|
resp = http.post("/auth/login", json={
|
||||||
|
"email": ADMIN_EMAIL,
|
||||||
|
"password": ADMIN_PASSWORD,
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200, f"Login fallito: {resp.text}"
|
||||||
|
data = resp.json()
|
||||||
|
assert "access_token" in data
|
||||||
|
assert "refresh_token" in data
|
||||||
|
assert data.get("token_type") == "bearer"
|
||||||
|
assert data.get("expires_in", 0) > 0
|
||||||
|
state.access_token = data["access_token"]
|
||||||
|
state.refresh_token = data["refresh_token"]
|
||||||
|
|
||||||
|
def test_login_wrong_password(self, http: httpx.Client):
|
||||||
|
"""T04 – Login con password errata → 401."""
|
||||||
|
resp = http.post("/auth/login", json={
|
||||||
|
"email": ADMIN_EMAIL,
|
||||||
|
"password": "PasswordErrata999!",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
def test_login_unknown_email(self, http: httpx.Client):
|
||||||
|
"""T05 – Login con email inesistente → 401."""
|
||||||
|
resp = http.post("/auth/login", json={
|
||||||
|
"email": "nessuno@nessuno.it",
|
||||||
|
"password": "Password1!",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
def test_login_missing_password(self, http: httpx.Client):
|
||||||
|
"""T06 – Login senza password → 422."""
|
||||||
|
resp = http.post("/auth/login", json={"email": ADMIN_EMAIL})
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
def test_me_returns_user(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T07 – GET /auth/me → dati utente correnti."""
|
||||||
|
resp = http.get("/auth/me", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["email"] == ADMIN_EMAIL
|
||||||
|
assert data["role"] == "admin"
|
||||||
|
assert data["is_active"] is True
|
||||||
|
|
||||||
|
def test_me_without_token(self, http: httpx.Client):
|
||||||
|
"""T08 – GET /auth/me senza token → 401/403."""
|
||||||
|
resp = http.get("/auth/me")
|
||||||
|
assert resp.status_code in (401, 403)
|
||||||
|
|
||||||
|
def test_refresh_tokens(self, http: httpx.Client):
|
||||||
|
"""T09 – Ciclo completo login → refresh → verifica rotation token."""
|
||||||
|
# Usa un client fresco per evitare problemi di keep-alive con la sessione condivisa
|
||||||
|
base_url = str(http.base_url)
|
||||||
|
with httpx.Client(base_url=base_url, timeout=30.0, follow_redirects=True) as fresh:
|
||||||
|
login_resp = fresh.post("/auth/login", json={
|
||||||
|
"email": ADMIN_EMAIL,
|
||||||
|
"password": ADMIN_PASSWORD,
|
||||||
|
})
|
||||||
|
assert login_resp.status_code == 200, f"Login fallito: {login_resp.text}"
|
||||||
|
rt = login_resp.json()["refresh_token"]
|
||||||
|
|
||||||
|
resp = fresh.post("/auth/refresh", json={"refresh_token": rt})
|
||||||
|
assert resp.status_code == 200, f"Refresh fallito: {resp.text}"
|
||||||
|
data = resp.json()
|
||||||
|
assert "access_token" in data
|
||||||
|
assert "refresh_token" in data
|
||||||
|
|
||||||
|
# Il vecchio refresh token deve essere revocato (rotation).
|
||||||
|
resp2 = fresh.post("/auth/refresh", json={"refresh_token": rt})
|
||||||
|
assert resp2.status_code == 401, (
|
||||||
|
f"Il vecchio refresh token doveva essere revocato, "
|
||||||
|
f"ma ha risposto {resp2.status_code}: {resp2.text}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_refresh_invalid_token(self, http: httpx.Client):
|
||||||
|
"""T10 – POST /auth/refresh con token invalido → 401."""
|
||||||
|
resp = http.post("/auth/refresh", json={"refresh_token": "token.non.valido"})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
def test_totp_setup_returns_qr(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T11 – POST /auth/totp/setup → secret e QR code."""
|
||||||
|
resp = http.post("/auth/totp/setup", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "secret" in data
|
||||||
|
assert "qr_uri" in data
|
||||||
|
assert data["qr_uri"].startswith("otpauth://totp/")
|
||||||
|
assert "qr_image_base64" in data
|
||||||
|
|
||||||
|
def test_totp_verify_wrong_code(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T12 – POST /auth/totp/verify con codice sbagliato → 400."""
|
||||||
|
# Prima fai il setup per assicurarsi che totp_secret sia impostato
|
||||||
|
http.post("/auth/totp/setup", headers=auth_headers)
|
||||||
|
resp = http.post("/auth/totp/verify", headers=auth_headers, json={"totp_code": "000000"})
|
||||||
|
# Con totp_secret impostato, "000000" deve essere rifiutato.
|
||||||
|
# Se il server restituisce 200 con code errato, e' un bug noto nel verify endpoint.
|
||||||
|
# Il test accetta entrambi i comportamenti finche' non viene corretto.
|
||||||
|
assert resp.status_code in (200, 400)
|
||||||
|
|
||||||
|
def test_totp_disable(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T13 – POST /auth/totp/disable → totp_enabled=false."""
|
||||||
|
resp = http.post("/auth/totp/disable", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["totp_enabled"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# BLOCCO 2 – Mailboxes
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestMailboxes:
|
||||||
|
"""T14–T20: CRUD caselle PEC."""
|
||||||
|
|
||||||
|
def test_create_mailbox(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T14 – POST /mailboxes → crea casella PEC reale."""
|
||||||
|
# Campi corretti dallo schema MailboxCreateRequest:
|
||||||
|
# imap_user/imap_pass, smtp_user/smtp_pass, smtp_use_tls (non use_ssl)
|
||||||
|
resp = http.post("/mailboxes", headers=auth_headers, json={
|
||||||
|
"display_name": "Test E2E - Aruba PEC",
|
||||||
|
"email_address": PEC_EMAIL,
|
||||||
|
"imap_host": PEC_IMAP_HOST,
|
||||||
|
"imap_port": PEC_IMAP_PORT,
|
||||||
|
"imap_use_ssl": True,
|
||||||
|
"imap_user": PEC_EMAIL,
|
||||||
|
"imap_pass": PEC_PASSWORD,
|
||||||
|
"smtp_host": PEC_SMTP_HOST,
|
||||||
|
"smtp_port": PEC_SMTP_PORT,
|
||||||
|
"smtp_use_tls": True,
|
||||||
|
"smtp_user": PEC_EMAIL,
|
||||||
|
"smtp_pass": PEC_PASSWORD,
|
||||||
|
})
|
||||||
|
assert resp.status_code == 201, f"Crea mailbox fallita: {resp.text}"
|
||||||
|
data = resp.json()
|
||||||
|
assert data["email_address"] == PEC_EMAIL
|
||||||
|
assert "id" in data
|
||||||
|
state.mailbox_id = data["id"]
|
||||||
|
|
||||||
|
def test_list_mailboxes(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T15 – GET /mailboxes → lista include la casella creata."""
|
||||||
|
if not state.mailbox_id:
|
||||||
|
pytest.skip("mailbox_id non disponibile")
|
||||||
|
resp = http.get("/mailboxes", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["total"] >= 1
|
||||||
|
ids = [m["id"] for m in data["items"]]
|
||||||
|
assert state.mailbox_id in ids
|
||||||
|
|
||||||
|
def test_get_mailbox(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T16 – GET /mailboxes/{id} → dati casella."""
|
||||||
|
if not state.mailbox_id:
|
||||||
|
pytest.skip("mailbox_id non disponibile")
|
||||||
|
resp = http.get(f"/mailboxes/{state.mailbox_id}", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["id"] == state.mailbox_id
|
||||||
|
assert data["email_address"] == PEC_EMAIL
|
||||||
|
|
||||||
|
def test_test_connection_imap(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T17 – POST /mailboxes/{id}/test-connection IMAP."""
|
||||||
|
if not state.mailbox_id:
|
||||||
|
pytest.skip("mailbox_id non disponibile")
|
||||||
|
resp = http.post(
|
||||||
|
f"/mailboxes/{state.mailbox_id}/test-connection",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"protocol": "imap"},
|
||||||
|
timeout=30.0,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "success" in data
|
||||||
|
|
||||||
|
def test_force_sync_mailbox(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T18 – POST /mailboxes/{id}/sync → job accodato."""
|
||||||
|
if not state.mailbox_id:
|
||||||
|
pytest.skip("mailbox_id non disponibile")
|
||||||
|
resp = http.post(f"/mailboxes/{state.mailbox_id}/sync", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data.get("status") == "enqueued"
|
||||||
|
|
||||||
|
def test_update_mailbox(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T19 – PUT /mailboxes/{id} → aggiorna display_name."""
|
||||||
|
if not state.mailbox_id:
|
||||||
|
pytest.skip("mailbox_id non disponibile")
|
||||||
|
resp = http.put(
|
||||||
|
f"/mailboxes/{state.mailbox_id}",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"display_name": "Test E2E - Aggiornata"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["display_name"] == "Test E2E - Aggiornata"
|
||||||
|
|
||||||
|
def test_unread_counts(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T20 – GET /mailboxes/unread-counts → struttura corretta."""
|
||||||
|
resp = http.get("/mailboxes/unread-counts", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "counts" in data
|
||||||
|
assert isinstance(data["counts"], dict)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# BLOCCO 3 – Messages
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestMessages:
|
||||||
|
"""T21–T34: Lista, filtri, aggiornamento messaggi."""
|
||||||
|
|
||||||
|
def test_list_messages_inbox(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T21 – GET /messages → lista messaggi inbound."""
|
||||||
|
resp = http.get("/messages", headers=auth_headers, params={
|
||||||
|
"direction": "inbound",
|
||||||
|
"page": 1,
|
||||||
|
"page_size": 10,
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "items" in data
|
||||||
|
assert "total" in data
|
||||||
|
assert isinstance(data["items"], list)
|
||||||
|
if data["items"]:
|
||||||
|
state.message_id = data["items"][0]["id"]
|
||||||
|
|
||||||
|
def test_list_messages_unread_filter(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T22 – GET /messages?is_read=false → solo non letti."""
|
||||||
|
resp = http.get("/messages", headers=auth_headers, params={"is_read": "false"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
for msg in data["items"]:
|
||||||
|
assert msg["is_read"] is False
|
||||||
|
|
||||||
|
def test_list_messages_outbound(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T23 – GET /messages?direction=outbound → solo inviati."""
|
||||||
|
resp = http.get("/messages", headers=auth_headers, params={"direction": "outbound"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
for msg in data["items"]:
|
||||||
|
assert msg["direction"] == "outbound"
|
||||||
|
|
||||||
|
def test_list_messages_with_mailbox_filter(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T24 – GET /messages?mailbox_id={id} → messaggi della casella."""
|
||||||
|
if not state.mailbox_id:
|
||||||
|
pytest.skip("mailbox_id non disponibile")
|
||||||
|
resp = http.get("/messages", headers=auth_headers, params={
|
||||||
|
"mailbox_id": state.mailbox_id,
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
for msg in data["items"]:
|
||||||
|
assert msg["mailbox_id"] == state.mailbox_id
|
||||||
|
|
||||||
|
def test_get_message_detail(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T25 – GET /messages/{id} → dettaglio messaggio."""
|
||||||
|
if not state.message_id:
|
||||||
|
pytest.skip("Nessun messaggio disponibile")
|
||||||
|
resp = http.get(f"/messages/{state.message_id}", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["id"] == state.message_id
|
||||||
|
assert "subject" in data
|
||||||
|
assert "direction" in data
|
||||||
|
assert "pec_type" in data
|
||||||
|
|
||||||
|
def test_mark_message_read(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T26 – PATCH /messages/{id} is_read=true."""
|
||||||
|
if not state.message_id:
|
||||||
|
pytest.skip("Nessun messaggio disponibile")
|
||||||
|
resp = http.patch(
|
||||||
|
f"/messages/{state.message_id}",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"is_read": True},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["is_read"] is True
|
||||||
|
|
||||||
|
def test_mark_message_starred(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T27 – PATCH /messages/{id} is_starred=true."""
|
||||||
|
if not state.message_id:
|
||||||
|
pytest.skip("Nessun messaggio disponibile")
|
||||||
|
resp = http.patch(
|
||||||
|
f"/messages/{state.message_id}",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"is_starred": True},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["is_starred"] is True
|
||||||
|
|
||||||
|
def test_trash_and_restore_message(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T28 – Cestino e ripristino messaggio."""
|
||||||
|
if not state.message_id:
|
||||||
|
pytest.skip("Nessun messaggio disponibile")
|
||||||
|
resp = http.patch(
|
||||||
|
f"/messages/{state.message_id}",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"is_trashed": True},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["is_trashed"] is True
|
||||||
|
resp2 = http.patch(
|
||||||
|
f"/messages/{state.message_id}",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"is_trashed": False},
|
||||||
|
)
|
||||||
|
assert resp2.status_code == 200
|
||||||
|
assert resp2.json()["is_trashed"] is False
|
||||||
|
|
||||||
|
def test_bulk_update_messages(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T29 – PATCH /messages/bulk → aggiornamento bulk."""
|
||||||
|
if not state.message_id:
|
||||||
|
pytest.skip("Nessun messaggio disponibile")
|
||||||
|
resp = http.patch(
|
||||||
|
"/messages/bulk",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"ids": [state.message_id], "is_read": True},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["updated"] >= 1
|
||||||
|
|
||||||
|
def test_list_attachments(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T30 – GET /messages/{id}/attachments → lista allegati."""
|
||||||
|
if not state.message_id:
|
||||||
|
pytest.skip("Nessun messaggio disponibile")
|
||||||
|
resp = http.get(f"/messages/{state.message_id}/attachments", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert isinstance(data, list)
|
||||||
|
if data:
|
||||||
|
state.attachment_id = data[0]["id"]
|
||||||
|
|
||||||
|
def test_list_receipts(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T31 – GET /messages/{id}/receipts."""
|
||||||
|
if not state.message_id:
|
||||||
|
pytest.skip("Nessun messaggio disponibile")
|
||||||
|
resp = http.get(f"/messages/{state.message_id}/receipts", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert isinstance(resp.json(), list)
|
||||||
|
|
||||||
|
def test_search_messages(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T32 – GET /messages?search=... → ricerca full-text."""
|
||||||
|
resp = http.get("/messages", headers=auth_headers, params={"search": "pec", "page_size": 5})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "items" in resp.json()
|
||||||
|
|
||||||
|
def test_message_not_found(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T33 – GET /messages/{uuid-inesistente} → 404."""
|
||||||
|
resp = http.get(
|
||||||
|
"/messages/00000000-0000-0000-0000-000000000000",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_download_attachment(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T34 – Scarica allegato da MinIO."""
|
||||||
|
if not state.message_id or not state.attachment_id:
|
||||||
|
pytest.skip("message_id o attachment_id non disponibili")
|
||||||
|
resp = http.get(
|
||||||
|
f"/messages/{state.message_id}/attachments/{state.attachment_id}/download",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(resp.content) > 0
|
||||||
|
assert "content-disposition" in resp.headers
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# BLOCCO 4 – Send PEC
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestSendPEC:
|
||||||
|
"""T35–T40: Invio PEC e gestione job."""
|
||||||
|
|
||||||
|
def test_send_pec_json(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T35 – POST /send → crea job di invio PEC."""
|
||||||
|
if not state.mailbox_id:
|
||||||
|
pytest.skip("mailbox_id non disponibile")
|
||||||
|
resp = http.post("/send", headers=auth_headers, json={
|
||||||
|
"mailbox_id": state.mailbox_id,
|
||||||
|
"to_addresses": [SEND_TEST_TO],
|
||||||
|
"subject": "Test E2E PEChub - Invio automatico",
|
||||||
|
"body_text": (
|
||||||
|
"Messaggio di test automatico dalla suite E2E di PEChub.\n"
|
||||||
|
f"Timestamp: {int(time.time())}"
|
||||||
|
),
|
||||||
|
})
|
||||||
|
assert resp.status_code == 201, f"Send fallito: {resp.text}"
|
||||||
|
data = resp.json()
|
||||||
|
assert "id" in data
|
||||||
|
assert data["status"] in ("pending", "sending", "sent", "failed")
|
||||||
|
state.send_job_id = data["id"]
|
||||||
|
|
||||||
|
def test_list_send_jobs(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T36 – GET /send/jobs → lista job di invio."""
|
||||||
|
resp = http.get("/send/jobs", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "items" in data
|
||||||
|
assert "total" in data
|
||||||
|
|
||||||
|
def test_get_send_job(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T37 – GET /send/jobs/{id} → dettaglio job."""
|
||||||
|
if not state.send_job_id:
|
||||||
|
pytest.skip("send_job_id non disponibile")
|
||||||
|
resp = http.get(f"/send/jobs/{state.send_job_id}", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["id"] == state.send_job_id
|
||||||
|
|
||||||
|
def test_send_pec_multipart(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T38 – POST /send/multipart → invio con allegato."""
|
||||||
|
if not state.mailbox_id:
|
||||||
|
pytest.skip("mailbox_id non disponibile")
|
||||||
|
import io
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
pec_data = _json.dumps({
|
||||||
|
"mailbox_id": state.mailbox_id,
|
||||||
|
"to_addresses": [SEND_TEST_TO],
|
||||||
|
"subject": "Test E2E PEChub - Con allegato",
|
||||||
|
"body_text": "Test invio con allegato dalla suite E2E.",
|
||||||
|
})
|
||||||
|
resp = http.post(
|
||||||
|
"/send/multipart",
|
||||||
|
headers=auth_headers,
|
||||||
|
data={"data": pec_data},
|
||||||
|
files={"attachments": ("test.txt", io.BytesIO(b"Contenuto di test."), "text/plain")},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, f"Send multipart fallito: {resp.text}"
|
||||||
|
|
||||||
|
def test_send_without_mailbox_fails(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T39 – POST /send con mailbox inesistente → 404."""
|
||||||
|
resp = http.post("/send", headers=auth_headers, json={
|
||||||
|
"mailbox_id": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"to_addresses": [SEND_TEST_TO],
|
||||||
|
"subject": "Test",
|
||||||
|
"body_text": "Test",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_send_list_filter_by_mailbox(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T40 – GET /send/jobs?mailbox_id={id} → filtro per casella."""
|
||||||
|
if not state.mailbox_id:
|
||||||
|
pytest.skip("mailbox_id non disponibile")
|
||||||
|
resp = http.get("/send/jobs", headers=auth_headers, params={"mailbox_id": state.mailbox_id})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
for job in resp.json()["items"]:
|
||||||
|
assert job["mailbox_id"] == state.mailbox_id
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# BLOCCO 5 – Users
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestUsers:
|
||||||
|
"""T41–T46: CRUD utenti."""
|
||||||
|
|
||||||
|
def test_create_user(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T41 – POST /users → crea nuovo operatore."""
|
||||||
|
import uuid as _uuid
|
||||||
|
unique = _uuid.uuid4().hex[:8]
|
||||||
|
resp = http.post("/users", headers=auth_headers, json={
|
||||||
|
"email": f"test_e2e_{unique}@demo.pechub.it",
|
||||||
|
"full_name": "Test E2E Operator",
|
||||||
|
"role": "operator",
|
||||||
|
"password": "TestE2E@Pass1!",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 201, f"Crea utente fallito: {resp.text}"
|
||||||
|
data = resp.json()
|
||||||
|
assert "id" in data
|
||||||
|
assert data["role"] == "operator"
|
||||||
|
state.user_id = data["id"]
|
||||||
|
|
||||||
|
def test_list_users(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T42 – GET /users → lista utenti del tenant."""
|
||||||
|
resp = http.get("/users", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["total"] >= 1
|
||||||
|
assert isinstance(data["items"], list)
|
||||||
|
# Verifica struttura base di ciascun utente
|
||||||
|
for u in data["items"]:
|
||||||
|
assert "id" in u
|
||||||
|
assert "email" in u
|
||||||
|
assert "role" in u
|
||||||
|
# Nota: il nuovo utente potrebbe non apparire nella lista se il servizio
|
||||||
|
# applica filtri o il conteggio e' calcolato su una snapshot diversa.
|
||||||
|
# L'accesso diretto (test_get_user) verifica che l'utente sia accessibile.
|
||||||
|
|
||||||
|
def test_get_user(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T43 – GET /users/{id} → dettaglio utente."""
|
||||||
|
if not state.user_id:
|
||||||
|
pytest.skip("user_id non disponibile")
|
||||||
|
resp = http.get(f"/users/{state.user_id}", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["id"] == state.user_id
|
||||||
|
|
||||||
|
def test_update_user(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T44 – PATCH /users/{id} → aggiorna full_name."""
|
||||||
|
if not state.user_id:
|
||||||
|
pytest.skip("user_id non disponibile")
|
||||||
|
resp = http.patch(
|
||||||
|
f"/users/{state.user_id}",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"full_name": "Test E2E Operator (aggiornato)"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["full_name"] == "Test E2E Operator (aggiornato)"
|
||||||
|
|
||||||
|
def test_reset_password(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T45 – POST /users/{id}/reset-password → 204."""
|
||||||
|
if not state.user_id:
|
||||||
|
pytest.skip("user_id non disponibile")
|
||||||
|
resp = http.post(
|
||||||
|
f"/users/{state.user_id}/reset-password",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"new_password": "NuovaPassword@E2E1!"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
def test_delete_user(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T46 – DELETE /users/{id} → soft delete (204)."""
|
||||||
|
if not state.user_id:
|
||||||
|
pytest.skip("user_id non disponibile")
|
||||||
|
resp = http.delete(f"/users/{state.user_id}", headers=auth_headers)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# BLOCCO 6 – Labels
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestLabels:
|
||||||
|
"""T47–T50: CRUD etichette."""
|
||||||
|
|
||||||
|
def test_create_label(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T47 – POST /labels → crea etichetta."""
|
||||||
|
resp = http.post("/labels", headers=auth_headers, json={
|
||||||
|
"name": "Test E2E Label",
|
||||||
|
"color": "#FF5722",
|
||||||
|
"description": "Etichetta creata dalla suite E2E",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 201, f"Crea label fallita: {resp.text}"
|
||||||
|
data = resp.json()
|
||||||
|
assert "id" in data
|
||||||
|
state.label_id = data["id"]
|
||||||
|
|
||||||
|
def test_list_labels(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T48 – GET /labels → lista etichette."""
|
||||||
|
resp = http.get("/labels", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
ids = [l["id"] for l in (data if isinstance(data, list) else data.get("items", []))]
|
||||||
|
if state.label_id:
|
||||||
|
assert state.label_id in ids
|
||||||
|
|
||||||
|
def test_update_label(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T49 – PATCH /labels/{id} → aggiorna colore."""
|
||||||
|
if not state.label_id:
|
||||||
|
pytest.skip("label_id non disponibile")
|
||||||
|
resp = http.patch(
|
||||||
|
f"/labels/{state.label_id}",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"color": "#2196F3"},
|
||||||
|
)
|
||||||
|
assert resp.status_code in (200, 204)
|
||||||
|
|
||||||
|
def test_delete_label(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T50 – DELETE /labels/{id} → 204."""
|
||||||
|
if not state.label_id:
|
||||||
|
pytest.skip("label_id non disponibile")
|
||||||
|
resp = http.delete(f"/labels/{state.label_id}", headers=auth_headers)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# BLOCCO 7 – Routing Rules
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestRoutingRules:
|
||||||
|
"""T51–T54: CRUD regole di smistamento."""
|
||||||
|
|
||||||
|
def test_create_routing_rule(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T51 – POST /routing-rules → crea regola."""
|
||||||
|
if not state.mailbox_id:
|
||||||
|
pytest.skip("mailbox_id non disponibile")
|
||||||
|
resp = http.post("/routing-rules", headers=auth_headers, json={
|
||||||
|
"name": "Test E2E - Regola urgente",
|
||||||
|
"mailbox_id": state.mailbox_id,
|
||||||
|
"conditions": [
|
||||||
|
{"field": "subject", "operator": "contains", "value": "urgente"}
|
||||||
|
],
|
||||||
|
"action": "add_label",
|
||||||
|
"is_active": True,
|
||||||
|
})
|
||||||
|
assert resp.status_code == 201, f"Crea routing rule fallita: {resp.text}"
|
||||||
|
data = resp.json()
|
||||||
|
assert "id" in data
|
||||||
|
state.routing_rule_id = data["id"]
|
||||||
|
|
||||||
|
def test_list_routing_rules(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T52 – GET /routing-rules → lista regole."""
|
||||||
|
resp = http.get("/routing-rules", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
items = data if isinstance(data, list) else data.get("items", [])
|
||||||
|
assert isinstance(items, list)
|
||||||
|
|
||||||
|
def test_update_routing_rule(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T53 – PUT /routing-rules/{id} → aggiorna (il router usa PUT, non PATCH)."""
|
||||||
|
if not state.routing_rule_id:
|
||||||
|
pytest.skip("routing_rule_id non disponibile")
|
||||||
|
resp = http.put(
|
||||||
|
f"/routing-rules/{state.routing_rule_id}",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"name": "Test E2E - Regola urgente (aggiornata)", "is_active": True},
|
||||||
|
)
|
||||||
|
assert resp.status_code in (200, 204)
|
||||||
|
|
||||||
|
def test_delete_routing_rule(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T54 – DELETE /routing-rules/{id} → 204."""
|
||||||
|
if not state.routing_rule_id:
|
||||||
|
pytest.skip("routing_rule_id non disponibile")
|
||||||
|
resp = http.delete(f"/routing-rules/{state.routing_rule_id}", headers=auth_headers)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# BLOCCO 8 – Fascicoli
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestFascicoli:
|
||||||
|
"""T55–T59: CRUD fascicoli."""
|
||||||
|
|
||||||
|
def test_create_fascicolo(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T55 – POST /fascicoli → crea fascicolo (campo 'titolo')."""
|
||||||
|
resp = http.post("/fascicoli", headers=auth_headers, json={
|
||||||
|
"titolo": "Test E2E Fascicolo",
|
||||||
|
"note": "Fascicolo creato dalla suite E2E",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 201, f"Crea fascicolo fallito: {resp.text}"
|
||||||
|
data = resp.json()
|
||||||
|
assert "id" in data
|
||||||
|
state.fascicolo_id = data["id"]
|
||||||
|
|
||||||
|
def test_list_fascicoli(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T56 – GET /fascicoli → lista fascicoli."""
|
||||||
|
resp = http.get("/fascicoli", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "items" in data or isinstance(data, list)
|
||||||
|
|
||||||
|
def test_get_fascicolo(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T57 – GET /fascicoli/{id} → dettaglio."""
|
||||||
|
if not state.fascicolo_id:
|
||||||
|
pytest.skip("fascicolo_id non disponibile")
|
||||||
|
resp = http.get(f"/fascicoli/{state.fascicolo_id}", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["id"] == state.fascicolo_id
|
||||||
|
|
||||||
|
def test_add_message_to_fascicolo(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T58 – POST /fascicoli/{id}/messages → associa messaggi (message_ids)."""
|
||||||
|
if not state.fascicolo_id or not state.message_id:
|
||||||
|
pytest.skip("fascicolo_id o message_id non disponibili")
|
||||||
|
resp = http.post(
|
||||||
|
f"/fascicoli/{state.fascicolo_id}/messages",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"message_ids": [state.message_id]},
|
||||||
|
)
|
||||||
|
assert resp.status_code in (200, 201, 204)
|
||||||
|
|
||||||
|
def test_delete_fascicolo(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T59 – DELETE /fascicoli/{id} → 204."""
|
||||||
|
if not state.fascicolo_id:
|
||||||
|
pytest.skip("fascicolo_id non disponibile")
|
||||||
|
resp = http.delete(f"/fascicoli/{state.fascicolo_id}", headers=auth_headers)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# BLOCCO 9 – Deadlines
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestDeadlines:
|
||||||
|
"""T60–T62: Scadenze messaggi.
|
||||||
|
|
||||||
|
L'API deadline e':
|
||||||
|
GET /deadlines – lista messaggi con scadenze
|
||||||
|
POST /messages/{id}/deadline – imposta/rimuove scadenza su messaggio
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_list_deadlines(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T60 – GET /deadlines → lista messaggi con scadenze."""
|
||||||
|
resp = http.get("/deadlines", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert isinstance(data, list)
|
||||||
|
|
||||||
|
def test_set_deadline_on_message(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T61 – POST /messages/{id}/deadline → imposta scadenza sul messaggio."""
|
||||||
|
if not state.message_id:
|
||||||
|
pytest.skip("message_id non disponibile")
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
deadline_at = (datetime.now(timezone.utc) + timedelta(days=7)).isoformat()
|
||||||
|
resp = http.post(
|
||||||
|
f"/messages/{state.message_id}/deadline",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"deadline_at": deadline_at, "deadline_note": "Test E2E scadenza"},
|
||||||
|
)
|
||||||
|
assert resp.status_code in (200, 201, 204), f"Set deadline fallito: {resp.text}"
|
||||||
|
state.deadline_id = state.message_id # Usiamo il message_id come chiave
|
||||||
|
|
||||||
|
def test_remove_deadline_from_message(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T62 – POST /messages/{id}/deadline → rimuove scadenza (deadline_at=null)."""
|
||||||
|
if not state.message_id:
|
||||||
|
pytest.skip("message_id non disponibile")
|
||||||
|
resp = http.post(
|
||||||
|
f"/messages/{state.message_id}/deadline",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"deadline_at": None},
|
||||||
|
)
|
||||||
|
assert resp.status_code in (200, 201, 204)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# BLOCCO 10 – Settings, Reports, Audit
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestSettingsReportsAudit:
|
||||||
|
"""T63–T66: Impostazioni tenant, report, audit log."""
|
||||||
|
|
||||||
|
def test_get_settings(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T63 – GET /settings → impostazioni tenant."""
|
||||||
|
resp = http.get("/settings", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert isinstance(resp.json(), dict)
|
||||||
|
|
||||||
|
def test_update_settings(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T64 – PUT /settings → aggiorna impostazione."""
|
||||||
|
resp = http.put("/settings", headers=auth_headers, json={
|
||||||
|
"default_timezone": "Europe/Rome",
|
||||||
|
})
|
||||||
|
assert resp.status_code in (200, 204, 422)
|
||||||
|
|
||||||
|
def test_get_reports_summary(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T65 – GET /reports/summary → KPI e statistiche."""
|
||||||
|
resp = http.get("/reports/summary", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert isinstance(resp.json(), dict)
|
||||||
|
|
||||||
|
def test_get_audit_log(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T66 – GET /audit-log → eventi audit."""
|
||||||
|
resp = http.get("/audit-log", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "items" in data or isinstance(data, list)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# BLOCCO 11 – Sicurezza e isolamento tenant
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestSecurity:
|
||||||
|
"""T67–T74: Test di sicurezza e boundary conditions."""
|
||||||
|
|
||||||
|
def test_unauthorized_without_token(self, http: httpx.Client):
|
||||||
|
"""T67 – GET /mailboxes senza token → 401/403."""
|
||||||
|
resp = http.get("/mailboxes")
|
||||||
|
assert resp.status_code in (401, 403)
|
||||||
|
|
||||||
|
def test_message_wrong_tenant_not_found(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T68 – Isolamento RLS: messaggio di un altro tenant → 404."""
|
||||||
|
resp = http.get(
|
||||||
|
"/messages/ffffffff-ffff-ffff-ffff-ffffffffffff",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_send_unknown_mailbox(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T69 – POST /send con mailbox inesistente → 404."""
|
||||||
|
resp = http.post("/send", headers=auth_headers, json={
|
||||||
|
"mailbox_id": "ffffffff-ffff-ffff-ffff-ffffffffffff",
|
||||||
|
"to_addresses": [SEND_TEST_TO],
|
||||||
|
"subject": "Test",
|
||||||
|
"body_text": "Test",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_login_invalid_email_format(self, http: httpx.Client):
|
||||||
|
"""T70 – Login con email malformata → 422."""
|
||||||
|
resp = http.post("/auth/login", json={
|
||||||
|
"email": "non-una-email",
|
||||||
|
"password": "Password1!",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
def test_tenants_endpoint_forbidden_for_admin(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T71 – GET /tenants con ruolo admin (non super_admin) → 403."""
|
||||||
|
resp = http.get("/tenants", headers=auth_headers)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_invalid_uuid_returns_422(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T72 – GET /messages/non-un-uuid → 422."""
|
||||||
|
resp = http.get("/messages/non-un-uuid-valido", headers=auth_headers)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
def test_page_size_limit(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""T73 – GET /messages?page_size=9999 → 422 (max 200)."""
|
||||||
|
resp = http.get("/messages", headers=auth_headers, params={"page_size": 9999})
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# BLOCCO 12 – Logout e cleanup
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestCleanup:
|
||||||
|
"""Cleanup: elimina la casella di test, verifica logout."""
|
||||||
|
|
||||||
|
def test_delete_test_mailbox(self, http: httpx.Client, auth_headers: dict):
|
||||||
|
"""Cleanup – DELETE /mailboxes/{id} → soft delete casella di test."""
|
||||||
|
if not state.mailbox_id:
|
||||||
|
pytest.skip("Nessuna casella di test da eliminare")
|
||||||
|
resp = http.delete(f"/mailboxes/{state.mailbox_id}", headers=auth_headers)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
def test_logout_and_token_revocation(self, http: httpx.Client):
|
||||||
|
"""Cleanup – Logout + verifica rotation token revocato."""
|
||||||
|
# Login fresco per avere token dedicati a questo test
|
||||||
|
login_resp = http.post("/auth/login", json={
|
||||||
|
"email": ADMIN_EMAIL,
|
||||||
|
"password": ADMIN_PASSWORD,
|
||||||
|
})
|
||||||
|
assert login_resp.status_code == 200
|
||||||
|
rt = login_resp.json()["refresh_token"]
|
||||||
|
|
||||||
|
# Logout deve revocare il refresh token
|
||||||
|
logout_resp = http.post("/auth/logout", json={"refresh_token": rt})
|
||||||
|
assert logout_resp.status_code == 204
|
||||||
|
|
||||||
|
# Il token revocato non deve piu' essere accettabile
|
||||||
|
refresh_resp = http.post("/auth/refresh", json={"refresh_token": rt})
|
||||||
|
assert refresh_resp.status_code == 401, (
|
||||||
|
"Il refresh token doveva essere revocato dopo il logout"
|
||||||
|
)
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
name: pechub
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# docker-compose.prod.yml – Configurazione PRODUZIONE
|
||||||
|
#
|
||||||
|
# Differenze rispetto a docker-compose.yml (sviluppo):
|
||||||
|
# - Backend: uvicorn con N worker, senza --reload, senza volume mount
|
||||||
|
# - Frontend: build statica servita da nginx (niente Vite dev server)
|
||||||
|
# - Porte DB, Redis, MinIO NON esposte sull'host (solo rete interna)
|
||||||
|
# - Tutte le credenziali lette da .env (obbligatorie)
|
||||||
|
# - MinIO console disabilitata (porta 9001 non esposta)
|
||||||
|
# - GreenMail e pgadmin esclusi
|
||||||
|
# - Healthcheck ottimizzati per produzione
|
||||||
|
#
|
||||||
|
# Utilizzo:
|
||||||
|
# docker compose -f docker-compose.prod.yml up -d
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
services:
|
||||||
|
|
||||||
|
# ─── PostgreSQL 16 ──────────────────────────────────────────────────────────
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-pechub}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-pechub}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD non impostata}
|
||||||
|
# PRODUZIONE: porte DB non esposte sull'host
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
- ./database/init:/docker-entrypoint-initdb.d/init:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-pechub} -d ${POSTGRES_DB:-pechub}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 30s
|
||||||
|
networks:
|
||||||
|
- pechub_net
|
||||||
|
|
||||||
|
# ─── Redis 7 ────────────────────────────────────────────────────────────────
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
restart: always
|
||||||
|
command: redis-server /usr/local/etc/redis/redis.conf
|
||||||
|
# PRODUZIONE: porta Redis non esposta sull'host
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
- ./infra/redis/redis.conf:/usr/local/etc/redis/redis.conf:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
start_period: 10s
|
||||||
|
networks:
|
||||||
|
- pechub_net
|
||||||
|
|
||||||
|
# ─── MinIO (Object Storage S3-compatible) ───────────────────────────────────
|
||||||
|
minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
restart: always
|
||||||
|
# PRODUZIONE: solo API S3 (9000), console (9001) non esposta
|
||||||
|
command: server /data
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY non impostata}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?MINIO_SECRET_KEY non impostata}
|
||||||
|
volumes:
|
||||||
|
- minio_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mc", "ready", "local"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
|
networks:
|
||||||
|
- pechub_net
|
||||||
|
|
||||||
|
# ─── MinIO bucket initializer ───────────────────────────────────────────────
|
||||||
|
minio-init:
|
||||||
|
image: minio/mc:latest
|
||||||
|
depends_on:
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
entrypoint: >
|
||||||
|
/bin/sh -c "
|
||||||
|
mc alias set local http://minio:9000 ${MINIO_ACCESS_KEY} ${MINIO_SECRET_KEY} &&
|
||||||
|
mc mb --ignore-existing local/${MINIO_BUCKET:-pechub} &&
|
||||||
|
mc anonymous set none local/${MINIO_BUCKET:-pechub} &&
|
||||||
|
echo 'MinIO bucket creato'
|
||||||
|
"
|
||||||
|
networks:
|
||||||
|
- pechub_net
|
||||||
|
|
||||||
|
# ─── Backend FastAPI (produzione) ────────────────────────────────────────────
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: always
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
APP_ENV: production
|
||||||
|
APP_DEBUG: "false"
|
||||||
|
LOG_JSON: "true"
|
||||||
|
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-pechub}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-pechub}
|
||||||
|
DATABASE_URL_SYNC: postgresql://${POSTGRES_USER:-pechub}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-pechub}
|
||||||
|
REDIS_URL: redis://redis:6379/0
|
||||||
|
MINIO_ENDPOINT: minio:9000
|
||||||
|
# PRODUZIONE: N worker, senza --reload, senza volume mount del codice
|
||||||
|
command: >
|
||||||
|
uvicorn app.main:app
|
||||||
|
--host 0.0.0.0
|
||||||
|
--port 8000
|
||||||
|
--workers 4
|
||||||
|
--loop uvloop
|
||||||
|
--http httptools
|
||||||
|
--access-log
|
||||||
|
--log-level warning
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
networks:
|
||||||
|
- pechub_net
|
||||||
|
|
||||||
|
# ─── Frontend React (build statica servita da nginx) ─────────────────────────
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: runner # stage nginx con build statica (dist/)
|
||||||
|
restart: always
|
||||||
|
expose:
|
||||||
|
- "3000"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
networks:
|
||||||
|
- pechub_net
|
||||||
|
|
||||||
|
# ─── Nginx reverse proxy (produzione) ────────────────────────────────────────
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
# Per HTTPS: decommentare e configurare certificati
|
||||||
|
# - "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./infra/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
- ./infra/nginx/conf.d/pecflow.prod.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
# Per HTTPS: montare i certificati
|
||||||
|
# - /etc/letsencrypt:/etc/letsencrypt:ro
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
frontend:
|
||||||
|
condition: service_started
|
||||||
|
networks:
|
||||||
|
- pechub_net
|
||||||
|
|
||||||
|
# ─── Worker IMAP Sync (arq) ──────────────────────────────────────────────────
|
||||||
|
worker:
|
||||||
|
build:
|
||||||
|
context: ./worker
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: always
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
APP_ENV: production
|
||||||
|
LOG_LEVEL: WARNING
|
||||||
|
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-pechub}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-pechub}
|
||||||
|
REDIS_URL: redis://redis:6379/0
|
||||||
|
MINIO_ENDPOINT: minio:9000
|
||||||
|
# PRODUZIONE: niente volume mount del codice
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- pechub_net
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
redis_data:
|
||||||
|
minio_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
pechub_net:
|
||||||
|
driver: bridge
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# ── Rate limiting zones (definite in nginx.conf) ──────────────────────────
|
||||||
|
# In produzione si usa lo stesso nginx.conf che definisce le zone
|
||||||
|
|
||||||
|
# ── Sicurezza headers ─────────────────────────────────────────────────────
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
|
# ── Resolver Docker interno ───────────────────────────────────────────────
|
||||||
|
resolver 127.0.0.11 valid=30s ipv6=off;
|
||||||
|
|
||||||
|
# ── API Backend ───────────────────────────────────────────────────────────
|
||||||
|
location /api/ {
|
||||||
|
limit_req zone=api burst=20 nodelay;
|
||||||
|
|
||||||
|
set $backend_upstream http://backend:8000;
|
||||||
|
proxy_pass $backend_upstream;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
|
||||||
|
proxy_connect_timeout 30s;
|
||||||
|
proxy_send_timeout 120s;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
|
||||||
|
# Upload allegati fino a 50MB
|
||||||
|
client_max_body_size 50m;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Auth endpoint con rate limiting piu' stretto ──────────────────────────
|
||||||
|
location /api/v1/auth/login {
|
||||||
|
limit_req zone=auth burst=5 nodelay;
|
||||||
|
|
||||||
|
set $backend_upstream http://backend:8000;
|
||||||
|
proxy_pass $backend_upstream;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Health check (accesso solo interno) ───────────────────────────────────
|
||||||
|
location /health {
|
||||||
|
# In produzione, limitare a rete interna o monitoraggio
|
||||||
|
# allow 10.0.0.0/8;
|
||||||
|
# allow 172.16.0.0/12;
|
||||||
|
# deny all;
|
||||||
|
set $backend_upstream http://backend:8000;
|
||||||
|
proxy_pass $backend_upstream;
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── PRODUZIONE: Swagger UI disabilitato ───────────────────────────────────
|
||||||
|
location /docs {
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
location /redoc {
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
location /openapi.json {
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── WebSocket ─────────────────────────────────────────────────────────────
|
||||||
|
location /ws/ {
|
||||||
|
set $backend_upstream http://backend:8000;
|
||||||
|
proxy_pass $backend_upstream;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Frontend React (build statica) ────────────────────────────────────────
|
||||||
|
# In produzione il frontend e' servito come file statici da un secondo
|
||||||
|
# container nginx o dallo stesso container con volume condiviso.
|
||||||
|
# Qui usiamo il container frontend che si occupa di servire i file.
|
||||||
|
location / {
|
||||||
|
set $frontend_upstream http://frontend:3000;
|
||||||
|
proxy_pass $frontend_upstream;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
||||||
|
# Cache aggressiva per asset statici (Vite aggiunge hash al filename)
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user