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