"""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 Awaitable, 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.errors import PipelineWarning 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" @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 MockClientXMPP: """Mock de slixmpp.ClientXMPP pour tests unitaires. Ce mock simule le comportement minimal de ClientXMPP nécessaire pour tester XmppChannel sans dépendre du réseau ou d'un serveur XMPP réel. """ def __init__( self, jid: str, password: str, *args: object, **kwargs: object, ) -> None: """Initialise le mock ClientXMPP. :param jid: JID du compte bot. :param password: Mot de passe du compte bot. :param args: Arguments positionnels supplémentaires (ignoré). :param kwargs: Arguments nommés supplémentaires (ignoré). """ self.jid = jid self.password = password self.connected = False self.disconnected = False self.features: set[str] = set() self.handlers: dict[str, list[Callable[..., Awaitable[None]]]] = { "session_start": [], "failed_auth": [], "disconnected": [], } async def connect( self, *args: object, **kwargs: object, ) -> None: """Simule la connexion au serveur XMPP. Déclenche les handlers appropriés selon le scénario de test. :param args: Arguments supplémentaires (ignoré). :param kwargs: Arguments nommés supplémentaires (ignoré). """ self.connected = True # Simuler STARTTLS si demandé if kwargs.get("use_tls", False): self.features.add("starttls") # Déclencher session_start par défaut for handler in self.handlers["session_start"]: await handler() async def disconnect(self) -> None: """Simule la déconnexion du serveur XMPP. :raises: AssertionError si déjà déconnecté. """ if self.disconnected: msg = "Client already disconnected" raise AssertionError(msg) self.disconnected = True def send_message(self, *args: object, **kwargs: object) -> None: """Simule l'envoi d'un message. :param args: Arguments supplémentaires (ignoré). :param kwargs: Arguments nommés supplémentaires (ignoré). """ # Pas d'implémentation réelle nécessaire pour les tests def add_event_handler(self, event: str, handler: Callable[..., Awaitable[None]]) -> None: """Enregistre un gestionnaire d'événement. :param event: Nom de l'événement (ex: 'session_start'). :param handler: Fonction gestionnaire. :raises: AssertionError si l'événement n'est pas supporté. """ if event not in self.handlers: msg = f"Unsupported event: {event}" raise AssertionError(msg) self.handlers[event].append(handler) @pytest.mark.usefixtures("xmpp_settings") 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_with_synthesis(self, xmpp_message_full: XmppMessage) -> None: """Test que la synthèse est bien formatée avec l'emoji dédié. :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 def test_format_message_without_synthesis(self, xmpp_message_minimal: XmppMessage) -> None: """Test que l'absence de synthèse affiche le texte par défaut. :param xmpp_message_minimal: Message XMPP minimal. """ channel = XmppChannel(XmppSettings(), dry_run=True) formatted = channel._format_message(xmpp_message_minimal) assert "📌 Synthèse" in formatted assert "Aucune synthèse disponible." in formatted def test_format_message_with_homeworks(self, xmpp_message_full: XmppMessage) -> None: """Test que les devoirs sont bien formatés avec l'emoji dédié. :param xmpp_message_full: Message XMPP complet. """ channel = XmppChannel(XmppSettings(), dry_run=True) formatted = channel._format_message(xmpp_message_full) assert "📚 Devoirs" in formatted assert "Mathématiques" in formatted assert "Faire l'exercice 5 page 42" in formatted def test_format_message_without_homeworks(self, xmpp_message_minimal: XmppMessage) -> None: """Test que l'absence de devoirs affiche le texte par défaut. :param xmpp_message_minimal: Message XMPP minimal. """ channel = XmppChannel(XmppSettings(), dry_run=True) formatted = channel._format_message(xmpp_message_minimal) assert "📚 Devoirs" in formatted assert "Aucun devoir." in formatted def test_format_message_with_changes(self, xmpp_message_full: XmppMessage) -> None: """Test que les changements d'agenda sont bien formatés. :param xmpp_message_full: Message XMPP complet. """ channel = XmppChannel(XmppSettings(), dry_run=True) formatted = channel._format_message(xmpp_message_full) assert "📅 Changements d'agenda" in formatted assert "Cours déplacé" in formatted def test_format_message_with_messages(self, xmpp_message_full: XmppMessage) -> None: """Test que les messages sont bien formatés. :param xmpp_message_full: Message XMPP complet. """ channel = XmppChannel(XmppSettings(), dry_run=True) formatted = channel._format_message(xmpp_message_full) assert "💬 Messages" in formatted def test_format_message_with_external_info(self, xmpp_message_full: XmppMessage) -> None: """Test que les informations externes sont bien formatées. :param xmpp_message_full: Message XMPP complet. """ channel = XmppChannel(XmppSettings(), dry_run=True) formatted = channel._format_message(xmpp_message_full) assert "📢 Informations diverses" in formatted assert "Sortie prévue le 20/09." in formatted def test_format_message_empty(self) -> None: """Test que tous les sections vides affichent le texte par défaut.""" msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None) channel = XmppChannel(XmppSettings(), dry_run=True) formatted = channel._format_message(msg) assert "📌 Synthèse\nAucune synthèse disponible." in formatted assert "📚 Devoirs\nAucun devoir." in formatted assert "📅 Changements d'agenda\nAucun changement." in formatted assert "💬 Messages\nAucun message." in formatted assert "📢 Informations diverses\nAucune information." in formatted def test_format_message_with_theoretical_lesson(self) -> None: """Test que la matière d'un changement REMOVED provient du cours théorique. Couvre le cas où ``lesson`` est absent et où le sujet est extrait de ``theoretical_lesson`` dans ``_format_changes``. """ theoretical = TheoreticalLesson( id="th1", day_of_week=0, start_time=time(8, 0), end_time=time(9, 0), subject="Anglais", ) 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 "Anglais" in formatted assert "Cours annulé" in formatted def test_format_message_with_message_content(self) -> None: """Test que les messages Pronote sont formatés avec titre et contenu. Couvre la branche non vide de ``_format_messages``. """ 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, ) msg = XmppMessage( target_date=date(2025, 9, 7), synthesis=None, messages=(message,), external_info=None, ) channel = XmppChannel(XmppSettings(), dry_run=True) formatted = channel._format_message(msg) assert "Réunion parents-professeurs" in formatted assert "Une réunion est organisée le 15/09 à 18h." in formatted 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 @pytest.mark.usefixtures("xmpp_settings") class TestXmppChannelSend: """Tests unitaires pour la méthode send de XmppChannel. Ces tests vérifient le comportement de l'envoi de messages XMPP, avec mock de slixmpp.ClientXMPP. """ @patch("pronote_sync.channels.xmpp.ClientXMPP", new=MockClientXMPP) def test_send_dry_run_returns_true(self, xmpp_settings: XmppSettings) -> None: """Test que dry_run=True retourne True sans appeler slixmpp. :param xmpp_settings: Paramètres XMPP valides. """ channel = XmppChannel(xmpp_settings, dry_run=True) msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None) result = channel.send(msg) assert result is True @patch("pronote_sync.channels.xmpp.ClientXMPP", new=MockClientXMPP) def test_send_success_returns_true(self, xmpp_settings: XmppSettings) -> None: """Test que send retourne True en cas de succès de connexion. :param xmpp_settings: Paramètres XMPP valides. """ channel = XmppChannel(xmpp_settings, dry_run=False) msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None) result = channel.send(msg) assert result is True @patch("pronote_sync.channels.xmpp.ClientXMPP", new=MockClientXMPP) def test_send_failed_auth_raises_pipeline_warning(self, xmpp_settings: XmppSettings) -> None: """Vérifie qu'un échec d'authentification lève un PipelineWarning. :param xmpp_settings: Paramètres XMPP valides. """ # Créer un mock qui déclenche failed_auth class FailedAuthClient(MockClientXMPP): async def connect(self, *args: object, **kwargs: object) -> None: self.connected = True # Déclencher failed_auth au lieu de session_start for handler in self.handlers["failed_auth"]: await handler() with patch("pronote_sync.channels.xmpp.ClientXMPP", new=FailedAuthClient): channel = XmppChannel(xmpp_settings, dry_run=False) msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None) with pytest.raises(PipelineWarning): channel.send(msg) @patch("pronote_sync.channels.xmpp.ClientXMPP", new=MockClientXMPP) def test_send_connection_timeout_returns_false(self, xmpp_settings: XmppSettings) -> None: """Test que send retourne False en cas de timeout de connexion. :param xmpp_settings: Paramètres XMPP valides. """ # Créer un mock qui bloque indéfiniment : méthode non-asynchrone pour # éviter de créer une coroutine jamais attendue (RuntimeWarning), # ``asyncio.wait_for`` étant patché pour lever immédiatement. class TimeoutClient(MockClientXMPP): def connect(self, *args: object, **kwargs: object) -> None: # type: ignore[override] # Ne jamais terminer la connexion -> timeout return None with patch("pronote_sync.channels.xmpp.ClientXMPP", new=TimeoutClient): channel = XmppChannel(xmpp_settings, dry_run=False) msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None) # Utiliser un timeout court pour le test with patch("asyncio.wait_for", side_effect=asyncio.TimeoutError): result = channel.send(msg) assert result is False @patch("pronote_sync.channels.xmpp.ClientXMPP", new=MockClientXMPP) def test_send_starttls_not_available(self, xmpp_settings: XmppSettings) -> None: """Test que send retourne False si STARTTLS non disponible avec use_tls=True. :param xmpp_settings: Paramètres XMPP valides. """ class NoTLSClient(MockClientXMPP): async def connect(self, *args: object, **kwargs: object) -> None: self.connected = True # Ne pas ajouter 'starttls' aux features for handler in self.handlers["session_start"]: await handler() with patch("pronote_sync.channels.xmpp.ClientXMPP", new=NoTLSClient): # Force use_tls=True settings = xmpp_settings.model_copy(update={"use_tls": True}) channel = XmppChannel(settings, dry_run=False) msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None) result = channel.send(msg) assert result is False def test_send_incomplete_config_raises_pipeline_warning(self) -> None: """Test qu'une configuration XMPP incomplète lève un PipelineWarning. Vérifie que le canal refuse l'envoi (sans tentative réseau) lorsque le JID, le mot de passe ou le destinataire sont absents. """ settings = XmppSettings( enabled=True, jid=None, password=None, to=None, host="xmpp.example.com", ) channel = XmppChannel(settings, dry_run=False) msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None) with pytest.raises(PipelineWarning): channel.send(msg) def test_dry_run_without_secrets_logs_safely(self) -> None: """Test que le dry-run fonctionne sans champs sensibles renseignés. Vérifie que ``_secret_values`` gère des champs ``jid``, ``password`` et ``to`` absents (branches ``None`` de la collecte des secrets). """ settings = XmppSettings(enabled=True, host="xmpp.example.com") channel = XmppChannel(settings, dry_run=True) msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None) result = channel.send(msg) assert result is True def test_send_disconnected_event_runs_handler(self, xmpp_settings: XmppSettings) -> None: """Test que le handler ``disconnected`` est bien exécuté lors de l'envoi. :param xmpp_settings: Paramètres XMPP valides. """ class DisconnectClient(MockClientXMPP): async def connect(self, *args: object, **kwargs: object) -> None: self.connected = True if kwargs.get("use_tls", False): self.features.add("starttls") for handler in self.handlers["disconnected"]: await handler() for handler in self.handlers["session_start"]: await handler() with patch("pronote_sync.channels.xmpp.ClientXMPP", new=DisconnectClient): channel = XmppChannel(xmpp_settings, dry_run=False) msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None) result = channel.send(msg) assert result is True def test_send_failed_auth_after_connect_raises_pipeline_warning( self, xmpp_settings: XmppSettings ) -> None: """Test qu'un échec d'authentification après connect() lève un PipelineWarning. Le client établit la session puis l'événement ``failed_auth`` survient avant la fin de l'attente de session : le canal doit se déconnecter proprement et lever un PipelineWarning. :param xmpp_settings: Paramètres XMPP valides. """ client_ref: list[MockClientXMPP] = [] def _factory(jid: str, password: str, *args: object, **kwargs: object) -> MockClientXMPP: instance = MockClientXMPP(jid, password, *args, **kwargs) client_ref.append(instance) return instance async def _wait_with_failed_auth( fs: list[asyncio.Task[bool]], *, timeout: object = None, return_when: object = asyncio.FIRST_COMPLETED, ) -> tuple[set[asyncio.Task[bool]], set[asyncio.Task[bool]]]: # Déclenche failed_auth, puis nettoie les tâches de send_async. for handler in client_ref[0].handlers["failed_auth"]: await handler() for task in fs: task.cancel() await asyncio.gather(*fs, return_exceptions=True) return (set(), set()) with patch("pronote_sync.channels.xmpp.ClientXMPP", new=_factory): channel = XmppChannel(xmpp_settings, dry_run=False) msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None) with patch("asyncio.wait", new=_wait_with_failed_auth): with pytest.raises(PipelineWarning): channel.send(msg) def test_send_session_timeout_raises_pipeline_warning( self, xmpp_settings: XmppSettings ) -> None: """Test qu'un délai d'attente de session dépassé lève un PipelineWarning. Aucun événement n'établit la session : l'attente bornée expire et le canal se déconnecte proprement avant de lever un PipelineWarning. :param xmpp_settings: Paramètres XMPP valides. """ class NoEventClient(MockClientXMPP): async def connect(self, *args: object, **kwargs: object) -> None: self.connected = True if kwargs.get("use_tls", False): self.features.add("starttls") async def _empty_wait( fs: list[asyncio.Task[bool]], *, timeout: object = None, return_when: object = asyncio.FIRST_COMPLETED, ) -> tuple[set[asyncio.Task[bool]], set[asyncio.Task[bool]]]: # Aucun événement ne survient : on retourne une attente vide. for task in fs: task.cancel() await asyncio.gather(*fs, return_exceptions=True) return (set(), set()) with patch("pronote_sync.channels.xmpp.ClientXMPP", new=NoEventClient): channel = XmppChannel(xmpp_settings, dry_run=False) msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None) with patch("asyncio.wait", new=_empty_wait): with pytest.raises(PipelineWarning): channel.send(msg) @pytest.mark.usefixtures("xmpp_settings") 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=MockClientXMPP) 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(MockClientXMPP): async def connect(self, *args: object, **kwargs: object) -> None: 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: channel.send(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=MockClientXMPP) 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(MockClientXMPP): async def connect(self, *args: object, **kwargs: object) -> None: 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: channel.send(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=MockClientXMPP) 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(MockClientXMPP): async def connect(self, *args: object, **kwargs: object) -> None: 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: channel.send(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=MockClientXMPP) def test_no_secret_in_exception_cause(self) -> None: """Vérifie que les secrets ne sont pas présents dans __cause__ d'une PipelineWarning.""" 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, ) class AuthErrorClient(MockClientXMPP): async def connect(self, *args: object, **kwargs: object) -> None: self.connected = True for handler in self.handlers["failed_auth"]: await handler() with patch("pronote_sync.channels.xmpp.ClientXMPP", new=AuthErrorClient): channel = XmppChannel(settings, dry_run=False) msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None) with pytest.raises(PipelineWarning) as exc_info: channel.send(msg) # Vérifier que l'exception levée est bien une PipelineWarning assert isinstance(exc_info.value, PipelineWarning) # Vérifier que les secrets ne sont pas dans __cause__ cause_str = str(exc_info.value.__cause__) if exc_info.value.__cause__ else "" assert BOT_SENTINEL_JID not in cause_str assert PASS_SENTINEL_123 not in cause_str assert RECIPIENT_SENTINEL not in cause_str @patch("pronote_sync.channels.xmpp.ClientXMPP", new=MockClientXMPP) 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) channel.send(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