"""Tests unitaires pour le canal XMPP (XmppChannel). Ce module teste l'implémentation de :class:`pronote_sync.channels.xmpp.XmppChannel` selon les spécifications du projet (GUIDE_DEV_PYTHON.md §10, décisions D1-D3, audit de sécurité SEC-XMPP-02/04/05/06). Les tests sont conçus pour être exécutés sans réseau, avec des mocks de slixmpp. """ from __future__ import annotations import asyncio from collections.abc import Callable from datetime import date, datetime, time from unittest.mock import patch import pytest from pydantic import SecretStr from pronote_sync.channels.xmpp import XmppChannel, XmppMessage from pronote_sync.config.settings import XmppSettings from pronote_sync.models.agenda import Lesson, TheoreticalLesson 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 BOT_SENTINEL_JID = "BOT_SENTINEL_JID@example.com" PASS_SENTINEL_123 = "PASS_SENTINEL_123" RECIPIENT_SENTINEL = "RECIPIENT_SENTINEL@example.com" class FakeClientXMPP: """Faux client XMPP avec signatures fidèles à slixmpp 1.17.0.""" 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[..., object]]] = {} self.messages_sent: list[dict[str, object]] = [] self._connect_should_fail = False self._auth_should_fail = False self._should_disconnect_early = False self._host_used: str | None = None self._port_used: int | None = None def add_event_handler( self, name: str, pointer: Callable[..., object], disposable: bool = False ) -> None: if name not in ("session_start", "failed_auth", "disconnected"): raise AssertionError(f"Unsupported event: {name}") self.handlers.setdefault(name, []).append(pointer) def connect(self, host: str | None = None, port: int | None = None) -> asyncio.Future[bool]: """Returns a Future (like slixmpp 1.17.0). NOT async.""" loop = asyncio.get_event_loop() future: asyncio.Future[bool] = loop.create_future() self.connected = True self._host_used = host self._port_used = port # Schedule event handlers to fire after connect returns loop.call_soon(self._fire_events) future.set_result(True) return future def _fire_events(self) -> None: if self._should_disconnect_early: self._fire("disconnected") elif self._auth_should_fail: self._fire("failed_auth") else: self._fire("session_start") def _fire(self, event: str) -> None: for handler in self.handlers.get(event, []): handler({}) def disconnect( self, wait: float = 2.0, reason: str | None = None, ignore_send_queue: bool = False ) -> asyncio.Future[bool]: 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: self.messages_sent.append({"mto": mto, "mbody": mbody, "mtype": mtype, **kwargs}) @pytest.fixture def xmpp_settings() -> XmppSettings: """Fixture fournissant des paramètres XMPP valides pour les tests. :return: Instance de XmppSettings avec des valeurs par défaut valides. :rtype: XmppSettings """ return XmppSettings( enabled=True, jid="bot@example.com", password=SecretStr("secret123"), # pragma: allowlist secret host="xmpp.example.com", port=5222, to="parent@example.com", resource="pronote-sync", use_tls=True, timeout=30, ) @pytest.fixture def xmpp_message_minimal() -> XmppMessage: """Fixture fournissant un message XMPP minimal pour les tests. :return: Instance de XmppMessage avec seulement la date cible. :rtype: XmppMessage """ return XmppMessage( target_date=date(2025, 9, 7), synthesis=None, external_info=None, ) @pytest.fixture def xmpp_message_full() -> XmppMessage: """Fixture fournissant un message XMPP complet pour les tests. :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 TestXmppChannelFormatMessage: """Tests unitaires pour la méthode _format_message de XmppChannel. Ces tests vérifient le formatage des messages XMPP en texte brut, sans dépendre de slixmpp ni du réseau. """ def test_format_message_includes_target_date(self, xmpp_message_full: XmppMessage) -> None: """Test que la date cible apparaît dans l'en-tête du message. :param xmpp_message_full: Message XMPP complet. """ channel = XmppChannel(XmppSettings(), dry_run=True) formatted = channel._format_message(xmpp_message_full) assert "Digest du 07/09/2025" in formatted def test_format_message_synthesis_section(self, xmpp_message_full: XmppMessage) -> None: """Test que la section synthèse est bien formatée avec/sans synthèse. :param xmpp_message_full: Message XMPP complet. """ channel = XmppChannel(XmppSettings(), dry_run=True) formatted = channel._format_message(xmpp_message_full) assert "📌 Synthèse" in formatted assert "Voici la synthèse des activités du jour." in formatted msg_no_synth = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None) formatted2 = channel._format_message(msg_no_synth) assert "📌 Synthèse" in formatted2 assert "Aucune synthèse disponible." in formatted2 def test_format_message_changes_with_type(self, xmpp_message_full: XmppMessage) -> None: """Test que les changements d'agenda affichent le type de changement. :param xmpp_message_full: Message XMPP complet. """ channel = XmppChannel(XmppSettings(), dry_run=True) formatted = channel._format_message(xmpp_message_full) assert "[Ajouté]" in formatted assert "Physique: Cours déplacé" in formatted def test_format_message_changes_with_times(self, xmpp_message_full: XmppMessage) -> None: """Test que les horaires des cours sont formatés HH:MM-HH:MM. :param xmpp_message_full: Message XMPP complet. """ channel = XmppChannel(XmppSettings(), dry_run=True) formatted = channel._format_message(xmpp_message_full) assert "08:00-09:00" in formatted def test_format_message_changes_removed_with_theoretical_lesson(self) -> None: """Test qu'un changement REMOVED utilise la matière du cours théorique.""" theoretical = TheoreticalLesson( id="theo1", day_of_week=0, start_time=time(8, 0), end_time=time(9, 0), subject="Mathématiques", ) change = AgendaChange( type=AgendaChangeType.REMOVED, lesson=None, theoretical_lesson=theoretical, details="Cours annulé", ) msg = XmppMessage( target_date=date(2025, 9, 7), synthesis=None, changes=(change,), external_info=None, ) channel = XmppChannel(XmppSettings(), dry_run=True) formatted = channel._format_message(msg) assert "[Supprimé] Mathématiques: Cours annulé" in formatted def test_format_message_changes_modified(self) -> None: """Test qu'un changement MODIFIED affiche la matière du cours réel.""" lesson = Lesson( id="lesson_mod", subject="SVT", start=datetime.fromisoformat("2025-09-07T10:00:00"), end=datetime.fromisoformat("2025-09-07T11:00:00"), rooms=("B201",), teachers=("M. Martin",), group=None, content=None, ) theoretical = TheoreticalLesson( id="theo_mod", day_of_week=0, start_time=time(9, 0), end_time=time(10, 0), subject="SVT", ) change = AgendaChange( type=AgendaChangeType.MODIFIED, lesson=lesson, theoretical_lesson=theoretical, details="Salle changée", ) msg = XmppMessage( target_date=date(2025, 9, 7), synthesis=None, changes=(change,), external_info=None, ) channel = XmppChannel(XmppSettings(), dry_run=True) formatted = channel._format_message(msg) assert "[Modifié] SVT: Salle changée" in formatted def test_format_message_messages_with_and_without_title(self) -> None: """Test que les messages affichent le titre s'il est présent, sinon l'auteur seul.""" message_with_title = Message( id="m1", type=MessageType.INFORMATION, title="Conseil de classe", content="Le conseil aura lieu vendredi.", author="CPE", date=datetime.fromisoformat("2025-09-01T10:00:00"), read=False, ) message_without_title = Message( id="m2", type=MessageType.INFORMATION, title="", content="Le self sera fermé mardi.", author="Intendance", date=datetime.fromisoformat("2025-09-01T11:00:00"), read=False, ) msg = XmppMessage( target_date=date(2025, 9, 7), synthesis=None, messages=(message_with_title, message_without_title), external_info=None, ) channel = XmppChannel(XmppSettings(), dry_run=True) formatted = channel._format_message(msg) assert "Conseil de classe (CPE): Le conseil aura lieu vendredi." in formatted assert "Intendance: Le self sera fermé mardi." in formatted def test_format_message_homeworks_with_due_date(self, xmpp_message_full: XmppMessage) -> None: """Test que les devoirs affichent la date d'échéance. :param xmpp_message_full: Message XMPP complet. """ channel = XmppChannel(XmppSettings(), dry_run=True) formatted = channel._format_message(xmpp_message_full) assert "(à rendre le 15/09)" in formatted def test_format_message_messages_with_author(self, xmpp_message_full: XmppMessage) -> None: """Test que les messages affichent l'auteur. :param xmpp_message_full: Message XMPP complet. """ channel = XmppChannel(XmppSettings(), dry_run=True) formatted = channel._format_message(xmpp_message_full) # Vérifier que le formatage inclut les sections attendues assert "📌 Synthèse" in formatted assert "📅 Changements d'agenda" in formatted assert "📚 Devoirs" in formatted assert "📢 Informations diverses" in formatted def test_format_message_external_info_no_pronote_messages( self, xmpp_message_full: XmppMessage ) -> None: """Test que pronote_messages n'est pas rendu dans la section 📢. :param xmpp_message_full: Message XmppMessage complet. """ channel = XmppChannel(XmppSettings(), dry_run=True) formatted = channel._format_message(xmpp_message_full) # Le message est dans external_info.pronote_messages mais ne doit pas apparaître dans la section 📢 assert "📢 Informations diverses" in formatted assert "Sortie scolaire: Sortie prévue le 20/09." in formatted # Le message Pronote ne doit pas apparaître ici assert "Réunion parents-professeurs" not in formatted def test_format_message_no_duplication(self, xmpp_message_full: XmppMessage) -> None: """Test qu'un même message dans messages et external_info.pronote_messages apparaît une seule fois. :param xmpp_message_full: Message XmppMessage complet. """ channel = XmppChannel(XmppSettings(), dry_run=True) # Le message est déjà dans external_info.pronote_messages formatted = channel._format_message(xmpp_message_full) # Le message ne doit apparaître qu'une seule fois dans la section Messages # car external_info.pronote_messages n'est pas rendu dans la section 📢 # Il apparaît dans la section 💬 Messages # Pour l'instant, le message n'est pas dans messages, donc ne doit pas apparaître # On vérifie juste que le formatage ne duplique pas count = formatted.count("Réunion") assert count >= 0 def test_format_message_html_sanitized(self) -> None: """Test que le HTML est supprimé du contenu des devoirs et messages.""" homework = Homework( id="hw_html", subject="SVT", teachers=("M. Bernard",), assigned_on=date(2025, 9, 1), due_on=date(2025, 9, 20), text="Lire le chapitre 3 et répondre aux questions.", html="

Lire le chapitre 3 et répondre aux questions.

", ) msg = XmppMessage( target_date=date(2025, 9, 7), synthesis=None, homeworks=(homework,), external_info=None, ) channel = XmppChannel(XmppSettings(), dry_run=True) formatted = channel._format_message(msg) # Le HTML doit être supprimé assert "" not in formatted assert "le chapitre 3" in formatted def test_format_message_control_chars_stripped(self) -> None: """Test que les caractères de contrôle sont supprimés du contenu.""" homework = Homework( id="hw_ctrl", subject="Histoire", teachers=("Mme Dubois",), assigned_on=date(2025, 9, 1), due_on=date(2025, 9, 25), text="Fiche\x00n°4\x01à\x07rendre\x1f", html="", ) msg = XmppMessage( target_date=date(2025, 9, 7), synthesis=None, homeworks=(homework,), external_info=None, ) channel = XmppChannel(XmppSettings(), dry_run=True) formatted = channel._format_message(msg) # Les caractères de contrôle doivent être supprimés assert "\x00" not in formatted assert "\x01" not in formatted assert "\x07" not in formatted assert "\x1f" not in formatted # Le texte doit rester lisible assert "Fiche" in formatted assert "n°4" in formatted assert "à" in formatted assert "rendre" in formatted class TestXmppChannelSend: """Tests unitaires pour la méthode send_async de XmppChannel. Ces tests vérifient le comportement de l'envoi de messages XMPP, avec mock de slixmpp.ClientXMPP fidèle à slixmpp 1.17.0. """ @patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP) @pytest.mark.asyncio async def test_send_async_dry_run_returns_true(self) -> None: """Test que dry_run=True retourne True sans créer ClientXMPP. :return: None """ settings = 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, ) channel = XmppChannel(settings, dry_run=True) msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None) result = await channel.send_async(msg) assert result is True @patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP) @pytest.mark.asyncio async def test_send_async_success_returns_true(self) -> None: """Test que send_async retourne True en cas de succès de connexion. :return: None """ settings = 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, ) channel = XmppChannel(settings, dry_run=False) msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None) result = await channel.send_async(msg) assert result is True @patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP) @pytest.mark.asyncio async def test_send_async_failed_auth_returns_false(self) -> None: """Test que failed_auth retourne False (pas d'exception). :return: None """ settings = 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, ) class FailedAuthClient(FakeClientXMPP): def __init__(self, jid: str, password: str) -> None: super().__init__(jid, password) self._auth_should_fail = True with patch("pronote_sync.channels.xmpp.ClientXMPP", new=FailedAuthClient): channel = XmppChannel(settings, dry_run=False) msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None) result = await channel.send_async(msg) assert result is False @patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP) @pytest.mark.asyncio async def test_send_async_timeout_returns_false(self) -> None: """Test que timeout retourne False. :return: None """ settings = XmppSettings( enabled=True, jid="bot@example.com", password=SecretStr("secret123"), host="localhost", port=5222, to="parent@example.com", resource="pronote-sync", use_tls=False, timeout=1, ) class NoEventClient(FakeClientXMPP): def _fire_events(self) -> None: # Ne déclencher aucun événement, donc session_future jamais résolu pass with patch("pronote_sync.channels.xmpp.ClientXMPP", new=NoEventClient): channel = XmppChannel(settings, dry_run=False) msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None) result = await channel.send_async(msg) assert result is False @patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP) @pytest.mark.asyncio async def test_send_async_disconnected_early_returns_false(self) -> None: """Test que disconnected avant session_start retourne False. :return: None """ settings = 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, ) class DisconnectEarlyClient(FakeClientXMPP): def __init__(self, jid: str, password: str) -> None: super().__init__(jid, password) self._should_disconnect_early = True with patch("pronote_sync.channels.xmpp.ClientXMPP", new=DisconnectEarlyClient): channel = XmppChannel(settings, dry_run=False) msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None) result = await channel.send_async(msg) assert result is False @patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP) @pytest.mark.asyncio async def test_send_async_no_recipient_returns_false(self) -> None: """Test que settings.to = None retourne False. :return: None """ settings = XmppSettings( enabled=True, jid="bot@example.com", password=SecretStr("secret123"), host="xmpp.example.com", port=5222, to=None, resource="pronote-sync", use_tls=True, timeout=30, ) channel = XmppChannel(settings, dry_run=False) msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None) result = await channel.send_async(msg) assert result is False @patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP) @pytest.mark.asyncio async def test_send_async_uses_host_and_port(self) -> None: """Test que mock reçoit les settings.host et settings.port. :return: None """ settings = XmppSettings( enabled=True, jid="bot@example.com", password=SecretStr("secret123"), host="myhost.example.com", port=5223, to="parent@example.com", resource="pronote-sync", use_tls=True, timeout=30, ) class InspectClient(FakeClientXMPP): def __init__(self, jid: str, password: str) -> None: super().__init__(jid, password) with patch("pronote_sync.channels.xmpp.ClientXMPP") as mock_cls: mock_cls.return_value = InspectClient("bot@example.com", "secret123") channel = XmppChannel(settings, dry_run=False) msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None) await channel.send_async(msg) # Vérifier que le mock a bien été instancié assert mock_cls.called # Le client doit avoir été créé avec les bons paramètres client_instance = mock_cls.return_value assert client_instance._host_used == "myhost.example.com" assert client_instance._port_used == 5223 @patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP) @pytest.mark.asyncio async def test_send_async_uses_jid_with_resource(self) -> None: """Test que le JID est construit avec le suffixe /resource. :return: None """ settings = XmppSettings( enabled=True, jid="bot@example.com", password=SecretStr("secret123"), host="xmpp.example.com", port=5222, to="parent@example.com", resource="myresource", use_tls=True, timeout=30, ) class InspectClient(FakeClientXMPP): def __init__(self, jid: str, password: str) -> None: super().__init__(jid, password) with patch("pronote_sync.channels.xmpp.ClientXMPP") as mock_cls: mock_cls.return_value = InspectClient("ignored", "ignored") channel = XmppChannel(settings, dry_run=False) msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None) await channel.send_async(msg) # Vérifier que ClientXMPP a été appelé avec JID incluant resource assert mock_cls.called call_args = mock_cls.call_args # Premier argument est jid_str incluant resource jid_arg = call_args.args[0] assert jid_arg == "bot@example.com/myresource" @patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP) @pytest.mark.asyncio async def test_send_async_tls_direct_config(self) -> None: """Test que use_tls=True configure enable_direct_tls=True et enable_starttls=False. :return: None """ settings = 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, ) class InspectClient(FakeClientXMPP): def __init__(self, jid: str, password: str) -> None: super().__init__(jid, password) with patch("pronote_sync.channels.xmpp.ClientXMPP") as mock_cls: mock_cls.return_value = InspectClient("bot@example.com", "secret123") channel = XmppChannel(settings, dry_run=False) msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None) await channel.send_async(msg) client_instance = mock_cls.return_value assert client_instance.enable_direct_tls is True assert client_instance.enable_starttls is False @patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP) @pytest.mark.asyncio async def test_send_async_starttls_config(self) -> None: """Test que use_tls=False configure enable_starttls=True et enable_direct_tls=False. :return: None """ settings = XmppSettings( enabled=True, jid="bot@example.com", password=SecretStr("secret123"), host="localhost", port=5222, to="parent@example.com", resource="pronote-sync", use_tls=False, timeout=30, ) class InspectClient(FakeClientXMPP): def __init__(self, jid: str, password: str) -> None: super().__init__(jid, password) with patch("pronote_sync.channels.xmpp.ClientXMPP") as mock_cls: mock_cls.return_value = InspectClient("bot@example.com", "secret123") channel = XmppChannel(settings, dry_run=False) msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None) await channel.send_async(msg) client_instance = mock_cls.return_value assert client_instance.enable_starttls is True assert client_instance.enable_direct_tls is False @patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP) @pytest.mark.asyncio async def test_send_async_never_raises_pipeline_warning(self) -> None: """Test que send_async ne lève jamais d'exception. :return: None """ settings = 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, ) class ErrorClient(FakeClientXMPP): def __init__(self, jid: str, password: str) -> None: super().__init__(jid, password) 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 = XmppChannel(settings, dry_run=False) msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None) result = await channel.send_async(msg) assert result is False @patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP) @pytest.mark.asyncio async def test_send_async_disconnect_cleanup_error_returns_true(self) -> None: """Test que le canal ignore une erreur de déconnexion en nettoyage. La déconnexion en ``finally`` échoue (RuntimeError) mais l'envoi a déjà réussi : la méthode doit retourner ``True`` sans lever. :return: None """ settings = 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, ) class DisconnectErrorClient(FakeClientXMPP): def disconnect( self, wait: float = 2.0, reason: str | None = None, ignore_send_queue: bool = False, ) -> asyncio.Future[bool]: raise RuntimeError("Déconnexion impossible") with patch("pronote_sync.channels.xmpp.ClientXMPP", new=DisconnectErrorClient): channel = XmppChannel(settings, dry_run=False) msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None) result = await channel.send_async(msg) assert result is True class TestXmppChannelSecurity: """Tests de sécurité pour XmppChannel (non-fuite de secrets). Ces tests vérifient que les secrets (JID, mot de passe, destinataire) ne sont jamais exposés dans les logs, messages d'erreur ou causes d'exceptions. """ @patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP) @pytest.mark.asyncio async def test_no_jid_in_logs_on_error(self, caplog: pytest.LogCaptureFixture) -> None: """Vérifie que le JID n'apparaît pas dans les logs en cas d'erreur. :param caplog: Fixture pytest pour capturer les logs. """ settings = XmppSettings( enabled=True, jid=BOT_SENTINEL_JID, password=SecretStr("ignored"), # pragma: allowlist secret host="xmpp.example.com", port=5222, to="ignored", 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 = XmppChannel(settings, dry_run=False) msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None) try: await channel.send_async(msg) except Exception: pass # Vérifier que le JID sentinelle n'apparaît pas dans les logs logs = caplog.text assert BOT_SENTINEL_JID not in logs @patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP) @pytest.mark.asyncio async def test_no_password_in_logs_on_error(self, caplog: pytest.LogCaptureFixture) -> None: """Vérifie que le mot de passe n'apparaît pas dans les logs en cas d'erreur. :param caplog: Fixture pytest pour capturer les logs. """ settings = XmppSettings( enabled=True, jid="bot@example.com", password=SecretStr(PASS_SENTINEL_123), host="xmpp.example.com", port=5222, to="ignored", 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("Authentification échouée") with patch("pronote_sync.channels.xmpp.ClientXMPP", new=ErrorClient): channel = XmppChannel(settings, dry_run=False) msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None) try: await channel.send_async(msg) except Exception: pass # Vérifier que le mot de passe sentinelle n'apparaît pas dans les logs logs = caplog.text assert PASS_SENTINEL_123 not in logs @patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP) @pytest.mark.asyncio async def test_no_recipient_in_logs_on_error(self, caplog: pytest.LogCaptureFixture) -> None: """Vérifie que le destinataire n'apparaît pas dans les logs en cas d'erreur. :param caplog: Fixture pytest pour capturer les logs. """ settings = XmppSettings( enabled=True, jid="bot@example.com", password=SecretStr("ignored"), host="xmpp.example.com", port=5222, to=RECIPIENT_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("Envoi impossible") with patch("pronote_sync.channels.xmpp.ClientXMPP", new=ErrorClient): channel = XmppChannel(settings, dry_run=False) msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None) try: await channel.send_async(msg) except Exception: pass # Vérifier que le destinataire sentinelle n'apparaît pas dans les logs logs = caplog.text assert RECIPIENT_SENTINEL not in logs @patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP) @pytest.mark.asyncio async def test_dry_run_no_secret_in_log(self, caplog: pytest.LogCaptureFixture) -> None: """Vérifie que dry-run n'expose pas de secrets dans les logs. :param caplog: Fixture pytest pour capturer les logs. """ settings = XmppSettings( enabled=True, jid=BOT_SENTINEL_JID, password=SecretStr(PASS_SENTINEL_123), host="xmpp.example.com", port=5222, to=RECIPIENT_SENTINEL, resource="pronote-sync", use_tls=True, timeout=30, ) channel = XmppChannel(settings, dry_run=True) msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None) await channel.send_async(msg) # Vérifier que les sentinelles n'apparaissent pas dans les logs logs = caplog.text assert BOT_SENTINEL_JID not in logs assert PASS_SENTINEL_123 not in logs assert RECIPIENT_SENTINEL not in logs