Cause racine (#25) : use_tls=True (défaut) activait le direct TLS sur le port 5222 (conventionnellement STARTTLS). Le client envoyait un ClientHello TLS sur un port attendant un stream XMPP en clair, le serveur ne voyait jamais l'identité configurée, et la session expirait après 30 s. Corrections : - Remplacer use_tls (bool) par tls_mode: Literal[direct|starttls|disabled] (défaut starttls, compatible port 5222). use_tls conservé comme alias déprécié avec DeprecationWarning. - Ajouter connect_timeout (15 s) et cleanup_timeout (10 s) distincts du timeout de session (30 s). - Gérer l'événement connection_failed de Slixmpp pour échouer rapidement au lieu d'attendre le timeout de session. - Borner await connect_future et await disconnect_future par leurs timeouts respectifs (anti-blocage). - Attendre connect_future et session_future conjointement (asyncio.wait, FIRST_COMPLETED) pour détecter connection_failed avant l'expiration du connect_timeout. - Annuler les tâches pending sur tous les chemins de retour, y compris CancelledError et Exception. - Redact tous les redact_exception avec extra_secrets=_secret_values(). - Construire ClientXMPP dans le try (contrat « never raises »). - Enrichir FakeClientXMPP avec modes connect/disconnect configurables. - 25 nouveaux tests (timeout connexion, connection_failed, cleanup bloqué, CancelledError, TLS mismatch, fuite secrets). Couverture 96 %. - Mettre à jour .env.example, GUIDE_DEV_PYTHON.md, README.LLM.md. Co-authored-by: OpenCode <opencode@antoineve.me>
388 lines
14 KiB
Python
388 lines
14 KiB
Python
"""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", "connection_failed", "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="<p>Faire l'exercice 5 page 42</p>",
|
|
)
|
|
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="<p>Sortie prévue le 20/09.</p>",
|
|
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
|