"""Tests d'intégration pour le canal XMPP (end-to-end sans réseau). Ce module valide les critères d'acceptation de la milestone M10 (GUIDE_DEV_PYTHON.md, TODO.md §M10) pour le canal XMPP, en mode end-to-end avec mock de slixmpp. Les tests couvrent : - L'envoi réussi d'un message formaté via SyncXmppChannel - La dégradation des erreurs XMPP en retour False (jamais d'exception non gérée) - L'absence de fuite de secrets dans les logs XMPP - Le flag dry_run ne crée jamais ClientXMPP Tous les tests sont exécutés sans réseau grâce à des mocks de slixmpp.ClientXMPP. """ from __future__ import annotations import asyncio from collections.abc import Callable from datetime import date, datetime from typing import Any from unittest.mock import patch import pytest from pydantic import SecretStr from pronote_sync.channels import get_channel from pronote_sync.channels.xmpp import XmppMessage from pronote_sync.config.settings import XmppSettings from pronote_sync.models.agenda import Lesson from pronote_sync.models.blog import BlogArticle, ExternalInfo from pronote_sync.models.diff import AgendaChange, AgendaChangeType from pronote_sync.models.homework import Homework from pronote_sync.models.message import Message, MessageType # Sentinelles pour tests de non-fuite de secrets dans les logs INTEG_JID_SENTINEL = "INTEG_JID_SENTINEL@xmpp.example" INTEG_PASS_SENTINEL = "INTEG_PASS_SENTINEL" INTEG_TO_SENTINEL = "INTEG_TO_SENTINEL@xmpp.example" class FakeClientXMPP: """Faux client XMPP avec signatures fidèles à slixmpp 1.17.0.""" instances: list[FakeClientXMPP] = [] def __init__(self, jid: str, password: str) -> None: self.jid = jid self.password = password self.enable_starttls: bool = True self.enable_direct_tls: bool = True self.connected: bool = False self.disconnected: bool = False self.handlers: dict[str, list[Callable[..., Any]]] = {} self.messages_sent: list[dict[str, object]] = [] self._host_used: str | None = None self._port_used: int | None = None FakeClientXMPP.instances.append(self) @classmethod def reset(cls) -> None: cls.instances.clear() def add_event_handler(self, name: str, handler: Callable[..., Any]) -> None: """Enregistre un gestionnaire d'événement. :param name: Nom de l'événement (ex: 'session_start'). :param handler: Fonction gestionnaire. :raises: AssertionError si l'événement n'est pas supporté. """ if name not in ("session_start", "failed_auth", "disconnected"): raise AssertionError(f"Unsupported event: {name}") self.handlers.setdefault(name, []).append(handler) def connect(self, host: str | None = None, port: int | None = None) -> asyncio.Future[bool]: """Simule la connexion au serveur XMPP. Déclenche les handlers appropriés selon le scénario de test. :param host: Hôte de connexion. :param port: Port de connexion. :return: Future résolue à True. """ loop = asyncio.get_event_loop() future: asyncio.Future[bool] = loop.create_future() self.connected = True self._host_used = host self._port_used = port # Déclencher session_start par défaut loop.call_soon(self._fire_events) future.set_result(True) return future def _fire_events(self) -> None: for handler in self.handlers.get("session_start", []): handler({}) def disconnect( self, wait: float = 2.0, reason: str | None = None, ignore_send_queue: bool = False ) -> asyncio.Future[bool]: """Simule la déconnexion du serveur XMPP. :param wait: Temps d'attente. :param reason: Raison de la déconnexion. :param ignore_send_queue: Ignorer la file d'envoi. :return: Future résolue à True. """ loop = asyncio.get_event_loop() future: asyncio.Future[bool] = loop.create_future() self.disconnected = True future.set_result(True) return future def send_message( self, mto: object, mbody: str | None = None, mtype: str | None = None, **kwargs: object ) -> None: """Simule l'envoi d'un message. :param mto: Destinataire. :param mbody: Corps du message. :param mtype: Type de message. :param kwargs: Arguments supplémentaires. """ self.messages_sent.append({"mto": mto, "mbody": mbody, "mtype": mtype, **kwargs}) @pytest.fixture def xmpp_settings_enabled() -> XmppSettings: """Fixture fournissant des paramètres XMPP valides et activés. :return: Instance de XmppSettings avec des valeurs par défaut valides. :rtype: XmppSettings """ return XmppSettings( enabled=True, jid="bot@example.com", password=SecretStr("secret123"), host="xmpp.example.com", port=5222, to="parent@example.com", resource="pronote-sync", use_tls=True, timeout=30, ) @pytest.fixture def xmpp_message_populated() -> XmppMessage: """Fixture fournissant un message XMPP complet avec toutes les sections. :return: Instance de XmppMessage avec tous les champs remplis. :rtype: XmppMessage """ homework = Homework( id="hw1", subject="Mathématiques", teachers=("M. Dupont",), assigned_on=date(2025, 9, 1), due_on=date(2025, 9, 15), text="Faire l'exercice 5 page 42", html="

Faire l'exercice 5 page 42

", ) lesson = Lesson( id="lesson1", subject="Physique", start=datetime.fromisoformat("2025-09-07T08:00:00"), end=datetime.fromisoformat("2025-09-07T09:00:00"), rooms=("B201",), teachers=("M. Martin",), group=None, content=None, ) change = AgendaChange( type=AgendaChangeType.ADDED, lesson=lesson, theoretical_lesson=None, details="Cours déplacé", ) message = Message( id="msg1", type=MessageType.INFORMATION, title="Réunion parents-professeurs", content="Une réunion est organisée le 15/09 à 18h.", author="CPE", date=datetime.fromisoformat("2025-09-01T10:00:00"), read=False, ) article = BlogArticle( id="art1", title="Sortie scolaire", url="https://blog.example.com/sortie", published_at=datetime.fromisoformat("2025-09-01T09:00:00"), updated_at=None, category="Actualités", author="Collège", content_html="

Sortie prévue le 20/09.

", content_text="Sortie prévue le 20/09.", ) external = ExternalInfo( blog_articles=(article,), pronote_messages=(message,), other_info=("Info supplémentaire",), ) return XmppMessage( target_date=date(2025, 9, 7), synthesis="Voici la synthèse des activités du jour.", homeworks=(homework,), changes=(change,), messages=(), external_info=external, ) class TestXmppIntegrationSend: """Tests d'intégration pour l'envoi de messages XMPP via SyncXmppChannel. Ces tests valident le critère d'acceptation #1 de M10 : "XmppChannel.send envoie un message direct formaté (slixmpp mocké en test)". """ @patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP) def test_integration_send_success( self, xmpp_settings_enabled: XmppSettings, xmpp_message_populated: XmppMessage, ) -> None: """Test que SyncXmppChannel.send envoie un message formaté et retourne True. Critère d'acceptation #1 : L'envoi réussi retourne True et le message est envoyé avec mtype="chat". :param xmpp_settings_enabled: Paramètres XMPP valides et activés. :param xmpp_message_populated: Message XMPP complet. """ # Obtenir le canal via la fabrique channel = get_channel(xmpp_settings_enabled, dry_run=False) assert channel is not None # Envoyer le message result = channel.send(xmpp_message_populated) # Vérifier que l'envoi a réussi assert result is True @patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP) def test_integration_send_calls_send_message_with_chat_type( self, xmpp_settings_enabled: XmppSettings, xmpp_message_populated: XmppMessage, ) -> None: """Test que send_message est appelé avec mtype='chat' sur succès. Critère d'acceptation #1 : Le message est envoyé en mode direct (chat). :param xmpp_settings_enabled: Paramètres XMPP valides et activés. :param xmpp_message_populated: Message XMPP complet. """ # Obtenir le canal via la fabrique channel = get_channel(xmpp_settings_enabled, dry_run=False) assert channel is not None # Envoyer le message channel.send(xmpp_message_populated) # Vérifier que send_message a été appelé avec mtype="chat" # Le mock ClientXMPP a été patché, FakeClientXMPP.instances contient les instances instances = FakeClientXMPP.instances assert len(instances) > 0, "No FakeClientXMPP instance created" client_instance = instances[-1] # Vérifier que send_message a été appelé via messages_sent assert len(client_instance.messages_sent) > 0, "No message sent" # Vérifier que mtype="chat" a été passé found_chat = any(msg.get("mtype") == "chat" for msg in client_instance.messages_sent) assert found_chat, "send_message should have been called with mtype='chat'" class TestXmppIntegrationErrorHandling: """Tests d'intégration pour la gestion des erreurs XMPP. Ces tests valident le critère d'acceptation #2 de M10 : "Erreur XMPP → False, jamais d'exception non gérée". """ @patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP) def test_integration_error_degradation(self, xmpp_settings_enabled: XmppSettings) -> None: """Test qu'une erreur retourne False sans lever d'exception non gérée. Critère d'acceptation #2 : Les erreurs sont dégradées et retournent False, jamais d'exception non gérée qui s'échappe. :param xmpp_settings_enabled: Paramètres XMPP valides et activés. """ class ErrorClient(FakeClientXMPP): def connect( self, host: str | None = None, port: int | None = None ) -> asyncio.Future[bool]: raise RuntimeError("Connexion impossible") with patch("pronote_sync.channels.xmpp.ClientXMPP", new=ErrorClient): channel = get_channel(xmpp_settings_enabled, dry_run=False) assert channel is not None msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None) # Doit retourner False, pas lever d'exception result = channel.send(msg) assert result is False class TestXmppIntegrationSecurity: """Tests de sécurité pour le canal XMPP en intégration. Ces tests valident le critère d'acceptation #3 de M10 : "Aucun secret dans les logs XMPP". """ @patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP) def test_integration_no_secret_in_logs_on_xmpp_error( self, caplog: pytest.LogCaptureFixture, ) -> None: """Test qu'aucun secret n'apparaît dans les logs en cas d'erreur XMPP. Critère d'acceptation #3 : Les secrets (JID, mot de passe, destinataire) ne doivent jamais apparaître dans les logs. :param caplog: Fixture pytest pour capturer les logs. """ settings = XmppSettings( enabled=True, jid=INTEG_JID_SENTINEL, password=SecretStr(INTEG_PASS_SENTINEL), # pragma: allowlist secret host="xmpp.example.com", port=5222, to=INTEG_TO_SENTINEL, resource="pronote-sync", use_tls=True, timeout=30, ) class ErrorClient(FakeClientXMPP): def connect( self, host: str | None = None, port: int | None = None ) -> asyncio.Future[bool]: raise RuntimeError("Connexion impossible") with patch("pronote_sync.channels.xmpp.ClientXMPP", new=ErrorClient): channel = get_channel(settings, dry_run=False) assert channel is not None msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None) try: channel.send(msg) except Exception: pass # On s'attend à une PipelineWarning ou False # Vérifier que les sentinelles n'apparaissent pas dans les logs logs = caplog.text assert INTEG_JID_SENTINEL not in logs assert INTEG_PASS_SENTINEL not in logs assert INTEG_TO_SENTINEL not in logs @patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP) def test_integration_dry_run_no_connection(self, caplog: pytest.LogCaptureFixture) -> None: """Test que dry_run=True ne crée jamais ClientXMPP. :param caplog: Fixture pytest pour capturer les logs. """ settings = XmppSettings( enabled=True, jid=INTEG_JID_SENTINEL, password=SecretStr(INTEG_PASS_SENTINEL), host="xmpp.example.com", port=5222, to=INTEG_TO_SENTINEL, resource="pronote-sync", use_tls=True, timeout=30, ) with patch("pronote_sync.channels.xmpp.ClientXMPP") as mock_cls: channel = get_channel(settings, dry_run=True) assert channel is not None msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None) result = channel.send(msg) assert result is True # ClientXMPP ne doit pas être instancié en dry_run assert not mock_cls.called