Files
college-infos/tests/integration/test_xmpp_integration.py
Antoine Van Elstraete b2106e75ac fix(M10): apply FIXME_M10 corrections (transport, dry_run, format, security)
Fix all 8 findings from the independent review (FIXME_M10.md):

#1 Transport compatible with slixmpp 1.17.0 (D5):
  - Use real ClientXMPP type (remove Any), JID with resource
  - connect(host, port) explicit, no use_tls kwarg
  - enable_direct_tls/enable_starttls configured before connect
  - Single timeout via asyncio.Future for session_start/failed_auth/disconnected
  - Remove premature 'starttls' in features check, remove auto_reconnect
  - try/finally guarantees disconnect on all paths (#4)

#2 Factory dry_run no longer bypassed (D6):
  - Single send() entry point in SyncXmppChannel
  - dry_run check before any ClientXMPP creation
  - Remove XmppChannel.send() dual implementation

#3 Thread daemon removed — single asyncio.run(), documented limitation

#5 Richer message format:
  - Target date header, change type [Ajouté/Supprimé/Modifié]
  - Lesson times, homework due date, message author
  - No pronote_messages duplication (external_info = blog + other_info only)

#6 Error contract unified (D6):
  - Channel.send() -> bool never raises PipelineWarning
  - Errors logged with redaction, returns False
  - PipelineWarning(step='xmpp') will be created by pipeline M11

#7 Tests faithful to slixmpp 1.17.0 API:
  - FakeClientXMPP with real connect(host,port)/disconnect() signatures
  - Assertions on host, port, resource, mtype='chat'
  - No RuntimeWarning from unawaited coroutines

#8 .secrets.baseline restored from main

Coverage: 96.44% on channels/, 600 tests pass, pre-commit all-files green.

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
2026-09-08 02:16:28 +02:00

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", "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