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",
|
||||
description="Autentica l'utente. Se 2FA è attivo, richiede anche il codice TOTP.",
|
||||
)
|
||||
@limiter.limit(settings.rate_limit_auth)
|
||||
async def login(
|
||||
request: Request,
|
||||
body: LoginRequest,
|
||||
@@ -64,6 +65,11 @@ async def login(
|
||||
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(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
@@ -83,6 +89,11 @@ async def refresh_tokens(
|
||||
service = AuthService(db)
|
||||
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(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
|
||||
@@ -205,12 +205,14 @@ async def update_mailbox(
|
||||
Se vengono fornite nuove credenziali, vengono ri-cifrate.
|
||||
"""
|
||||
svc = _svc(db)
|
||||
mailbox = await svc.update_mailbox(
|
||||
await svc.update_mailbox(
|
||||
mailbox_id=mailbox_id,
|
||||
tenant_id=current_user.tenant_id,
|
||||
data=data,
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -923,9 +923,13 @@ async def get_thread(
|
||||
|
||||
# Carica ricorsivamente tutti i messaggi del thread dalla radice
|
||||
# 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] = []
|
||||
|
||||
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(
|
||||
select(Message)
|
||||
.where(
|
||||
@@ -951,7 +955,7 @@ async def get_thread(
|
||||
)
|
||||
children = list(children_result.scalars().all())
|
||||
for child in children:
|
||||
await _collect(child.id)
|
||||
await _collect(child.id, depth + 1)
|
||||
|
||||
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).
|
||||
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)
|
||||
expire = now + timedelta(days=settings.refresh_token_expire_days)
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"sub": str(subject),
|
||||
"tid": str(tenant_id),
|
||||
"jti": str(_uuid.uuid4()), # JWT ID unico per evitare hash duplicati in DB
|
||||
"iat": now,
|
||||
"exp": expire,
|
||||
"type": "refresh",
|
||||
|
||||
+32
-1
@@ -24,14 +24,45 @@ settings = get_settings()
|
||||
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
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""Gestione ciclo di vita dell'applicazione."""
|
||||
import asyncio
|
||||
|
||||
setup_logging()
|
||||
_validate_production_config()
|
||||
logger.info(
|
||||
"🚀 PEChub Backend avviato",
|
||||
"PEChub Backend avviato",
|
||||
extra={"env": settings.app_env, "debug": settings.app_debug},
|
||||
)
|
||||
|
||||
|
||||
@@ -53,7 +53,9 @@ class MailboxService:
|
||||
"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(
|
||||
select(Mailbox.id).where(
|
||||
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