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