Files
PecHub/backend/tests/e2e/test_e2e_api.py
T
2026-06-18 15:14:10 +02:00

907 lines
38 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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:
"""T01T02: 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:
"""T03T13: 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:
"""T14T20: 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:
"""T21T34: 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:
"""T35T40: 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:
"""T41T46: 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:
"""T47T50: 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:
"""T51T54: 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:
"""T55T59: 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:
"""T60T62: 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:
"""T63T66: 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:
"""T67T74: 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"
)