""" Test di integrazione IMAP con GreenMail (server IMAP mock via Docker). Prerequisiti: - GreenMail in esecuzione: docker compose --profile greenmail up -d greenmail - Endpoint IMAP: localhost:3143 (plain) o localhost:3993 (SSL) - Credenziali test: test@example.com / secret Esecuzione: cd worker && pytest tests/integration/test_imap_sync.py -v I test sono contrassegnati con @pytest.mark.integration e saltati se GREENMAIL_HOST non è raggiungibile. """ import asyncio import os import socket import uuid from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock, patch import pytest # ─── Skip automatico se GreenMail non disponibile ───────────────────────────── GREENMAIL_HOST = os.getenv("GREENMAIL_HOST", "localhost") GREENMAIL_IMAP_PORT = int(os.getenv("GREENMAIL_IMAP_PORT", "3143")) def _is_greenmail_running() -> bool: """Verifica se GreenMail IMAP è raggiungibile.""" try: with socket.create_connection((GREENMAIL_HOST, GREENMAIL_IMAP_PORT), timeout=2): return True except OSError: return False skip_if_no_greenmail = pytest.mark.skipif( not _is_greenmail_running(), reason=f"GreenMail non disponibile su {GREENMAIL_HOST}:{GREENMAIL_IMAP_PORT}", ) # ─── Fixture: mailbox mock ──────────────────────────────────────────────────── def make_mock_mailbox( email_address: str = "test@example.com", host: str = GREENMAIL_HOST, port: int = GREENMAIL_IMAP_PORT, user: str = "test@example.com", password: str = "secret", use_ssl: bool = False, last_sync_uid: int | None = None, ) -> MagicMock: """Crea un mock di Mailbox con credenziali cifrate per GreenMail.""" from app.imap.connection import _decrypt from app.config import get_settings import base64 from cryptography.hazmat.primitives.ciphers.aead import AESGCM settings = get_settings() key = settings.encryption_key_bytes aesgcm = AESGCM(key) def encrypt(value: str) -> str: import os as _os nonce = _os.urandom(12) ct = aesgcm.encrypt(nonce, value.encode(), None) return base64.b64encode(nonce + ct).decode() mailbox = MagicMock() mailbox.id = uuid.uuid4() mailbox.tenant_id = uuid.uuid4() mailbox.email_address = email_address mailbox.status = "active" mailbox.last_sync_uid = last_sync_uid mailbox.sync_error_count = 0 mailbox.sync_error_msg = None mailbox.imap_host_enc = encrypt(host) mailbox.imap_port_enc = encrypt(str(port)) mailbox.imap_user_enc = encrypt(user) mailbox.imap_pass_enc = encrypt(password) mailbox.imap_use_ssl = use_ssl return mailbox # ─── Test: connessione IMAP a GreenMail ────────────────────────────────────── @skip_if_no_greenmail @pytest.mark.asyncio async def test_imap_connect_greenmail(): """Verifica che IMAPConnection si connetta correttamente a GreenMail.""" import aioimaplib from app.imap.connection import IMAPConnection mailbox = make_mock_mailbox() creds = IMAPConnection._decrypt_creds(mailbox) conn = IMAPConnection.__new__(IMAPConnection) client = await conn._connect(creds) assert client is not None # Verifica che SELECT INBOX abbia funzionato status, _ = await client.select("INBOX") assert status == "OK" await client.logout() @skip_if_no_greenmail @pytest.mark.asyncio async def test_imap_search_empty_inbox(): """SEARCH su inbox vuota restituisce lista vuota.""" import aioimaplib from app.imap.connection import IMAPConnection mailbox = make_mock_mailbox(last_sync_uid=0) creds = IMAPConnection._decrypt_creds(mailbox) conn = IMAPConnection.__new__(IMAPConnection) client = await conn._connect(creds) # Cerca UID > 0 (tutti) status, data = await client.uid("SEARCH", "UID", "1:*") # Non deve sollevare eccezione assert status in ("OK", "NO") await client.logout() # ─── Test: sync con DB mockato ──────────────────────────────────────────────── @pytest.mark.asyncio async def test_sync_new_messages_empty(): """sync_new_messages con inbox vuota restituisce 0.""" from app.imap.sync import sync_new_messages # Mock IMAP client che risponde con lista vuota mock_client = AsyncMock() mock_client.uid = AsyncMock(return_value=("OK", [b""])) # Mock mailbox mailbox = MagicMock() mailbox.id = uuid.uuid4() mailbox.tenant_id = uuid.uuid4() mailbox.email_address = "test@example.com" mailbox.last_sync_uid = 0 # Mock DB mock_db = AsyncMock() mock_db.execute = AsyncMock(return_value=MagicMock(scalar_one_or_none=lambda: None)) # Mock Redis mock_redis = AsyncMock() n = await sync_new_messages(mock_client, mailbox, mock_db, mock_redis) assert n == 0 @pytest.mark.asyncio async def test_sync_skips_duplicate_uid(): """sync_new_messages salta UID già presenti nel DB.""" from app.imap.sync import sync_new_messages from sqlalchemy.engine import Result existing_uid = 42 mock_client = AsyncMock() # SEARCH restituisce UID 42 mock_client.uid = AsyncMock(return_value=("OK", [f"{existing_uid}".encode()])) mailbox = MagicMock() mailbox.id = uuid.uuid4() mailbox.tenant_id = uuid.uuid4() mailbox.email_address = "test@example.com" mailbox.last_sync_uid = 41 # DB: UID 42 già presente mock_result = MagicMock() mock_result.scalar_one_or_none.return_value = uuid.uuid4() # esiste già mock_db = AsyncMock() mock_db.execute = AsyncMock(return_value=mock_result) mock_db.flush = AsyncMock() mock_db.commit = AsyncMock() mock_redis = AsyncMock() n = await sync_new_messages(mock_client, mailbox, mock_db, mock_redis) # Deve restituire 0 perché il messaggio è già in DB assert n == 0 # ─── Test: parsing EML ──────────────────────────────────────────────────────── def test_parse_pec_message(): """Parsing di un EML PEC tipico.""" from app.imap.sync import _parse_eml raw = b"""From: mittente@aruba.pec.it To: destinatario@pec.it Subject: Comunicazione ufficiale n. 2026/001 Date: Wed, 18 Mar 2026 09:00:00 +0100 Message-ID: <20260318090000.1234@aruba.pec.it> X-Ricevuta: posta-certificata Content-Type: text/plain; charset=utf-8 Gentile destinatario, in riferimento alla pratica n. 2026/001... """ parsed = _parse_eml(raw) assert parsed["subject"] == "Comunicazione ufficiale n. 2026/001" assert parsed["from_address"] == "mittente@aruba.pec.it" assert parsed["pec_type"] == "posta_certificata" assert parsed["sent_at"] is not None assert "destinatario@pec.it" in parsed["to_addresses"] def test_parse_ricevuta_avvenuta_consegna(): """Parsing di una ricevuta di avvenuta consegna.""" from app.imap.sync import _parse_eml raw = b"""From: posta-certificata@aruba.pec.it To: mittente@aruba.pec.it Subject: AVVENUTA CONSEGNA: Comunicazione n. 001 Date: Wed, 18 Mar 2026 09:05:00 +0100 Message-ID: X-Riferimento-Message-ID: <20260318090000.1234@aruba.pec.it> X-TipoRicevuta: avvenuta-consegna Content-Type: text/plain; charset=utf-8 Il messaggio e' stato consegnato. """ parsed = _parse_eml(raw) assert parsed["pec_type"] == "avvenuta_consegna" assert "AVVENUTA CONSEGNA" in parsed["subject"] # ─── Test: backoff con connessione reale ───────────────────────────────────── @skip_if_no_greenmail @pytest.mark.asyncio async def test_reconnect_after_logout(): """Il sistema si riconnette dopo una disconnessione forzata.""" from app.imap.connection import IMAPConnection mailbox = make_mock_mailbox() creds = IMAPConnection._decrypt_creds(mailbox) conn = IMAPConnection.__new__(IMAPConnection) # Prima connessione client1 = await conn._connect(creds) assert client1 is not None # Disconnessione forzata await client1.logout() # Seconda connessione (simula riconnessione) client2 = await conn._connect(creds) assert client2 is not None await client2.logout()