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>
This commit is contained in:
@@ -5,14 +5,17 @@ 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 PipelineWarning (jamais d'exception non gérée)
|
||||
- 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
|
||||
@@ -35,90 +38,89 @@ INTEG_PASS_SENTINEL = "INTEG_PASS_SENTINEL"
|
||||
INTEG_TO_SENTINEL = "INTEG_TO_SENTINEL@xmpp.example"
|
||||
|
||||
|
||||
class MockClientXMPP:
|
||||
"""Mock de slixmpp.ClientXMPP pour tests d'intégration.
|
||||
class FakeClientXMPP:
|
||||
"""Faux client XMPP avec signatures fidèles à slixmpp 1.17.0."""
|
||||
|
||||
Ce mock simule le comportement minimal de ClientXMPP nécessaire pour
|
||||
tester SyncXmppChannel sans dépendre du réseau ou d'un serveur XMPP réel.
|
||||
"""
|
||||
instances: list[FakeClientXMPP] = []
|
||||
|
||||
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é).
|
||||
"""
|
||||
def __init__(self, jid: str, password: str) -> None:
|
||||
self.jid = jid
|
||||
self.password = password
|
||||
self.connected = False
|
||||
self.disconnected = False
|
||||
self.features: set[str] = set()
|
||||
self.handlers: dict[str, list[Any]] = {
|
||||
"session_start": [],
|
||||
"failed_auth": [],
|
||||
"disconnected": [],
|
||||
}
|
||||
self.message_sent = False
|
||||
self.message_args: dict[str, object] | None = None
|
||||
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)
|
||||
|
||||
async def connect(
|
||||
self,
|
||||
*args: object,
|
||||
**kwargs: object,
|
||||
) -> None:
|
||||
@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 args: Arguments supplémentaires (ignoré).
|
||||
:param kwargs: Arguments nommés supplémentaires (ignoré).
|
||||
: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
|
||||
# Simuler STARTTLS si demandé
|
||||
if kwargs.get("use_tls", False):
|
||||
self.features.add("starttls")
|
||||
self._host_used = host
|
||||
self._port_used = port
|
||||
# Déclencher session_start par défaut
|
||||
for handler in self.handlers["session_start"]:
|
||||
await handler()
|
||||
loop.call_soon(self._fire_events)
|
||||
future.set_result(True)
|
||||
return future
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
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.
|
||||
|
||||
:raises: AssertionError si déjà déconnecté.
|
||||
: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.
|
||||
"""
|
||||
if self.disconnected:
|
||||
msg = "Client already disconnected"
|
||||
raise AssertionError(msg)
|
||||
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, *args: object, **kwargs: object) -> None:
|
||||
def send_message(
|
||||
self, mto: object, mbody: str | None = None, mtype: str | None = None, **kwargs: object
|
||||
) -> None:
|
||||
"""Simule l'envoi d'un message.
|
||||
|
||||
:param args: Arguments supplémentaires (ignoré).
|
||||
:param kwargs: Arguments nommés supplémentaires (ignoré).
|
||||
:param mto: Destinataire.
|
||||
:param mbody: Corps du message.
|
||||
:param mtype: Type de message.
|
||||
:param kwargs: Arguments supplémentaires.
|
||||
"""
|
||||
self.message_sent = True
|
||||
self.message_args = kwargs
|
||||
|
||||
def add_event_handler(self, event: str, handler: object) -> 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)
|
||||
self.messages_sent.append({"mto": mto, "mbody": mbody, "mtype": mtype, **kwargs})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -131,7 +133,7 @@ def xmpp_settings_enabled() -> XmppSettings:
|
||||
return XmppSettings(
|
||||
enabled=True,
|
||||
jid="bot@example.com",
|
||||
password=SecretStr("secret123"), # pragma: allowlist secret
|
||||
password=SecretStr("secret123"),
|
||||
host="xmpp.example.com",
|
||||
port=5222,
|
||||
to="parent@example.com",
|
||||
@@ -215,8 +217,8 @@ class TestXmppIntegrationSend:
|
||||
"XmppChannel.send envoie un message direct formaté (slixmpp mocké en test)".
|
||||
"""
|
||||
|
||||
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=MockClientXMPP)
|
||||
def test_send_formatted_message_returns_true(
|
||||
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
|
||||
def test_integration_send_success(
|
||||
self,
|
||||
xmpp_settings_enabled: XmppSettings,
|
||||
xmpp_message_populated: XmppMessage,
|
||||
@@ -239,8 +241,8 @@ class TestXmppIntegrationSend:
|
||||
# Vérifier que l'envoi a réussi
|
||||
assert result is True
|
||||
|
||||
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=MockClientXMPP)
|
||||
def test_send_calls_send_message_with_chat_type(
|
||||
@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,
|
||||
@@ -259,26 +261,28 @@ class TestXmppIntegrationSend:
|
||||
# Envoyer le message
|
||||
channel.send(xmpp_message_populated)
|
||||
|
||||
# Vérifier que le mock a bien reçu l'appel à send_message
|
||||
# Note: Le mock est créé par le patch, mais nous devons accéder au dernier instance
|
||||
# Pour simplifier, nous allons vérifier via le comportement attendu
|
||||
# Dans un vrai test d'intégration, on pourrait utiliser un fixture plus sophistiqué
|
||||
# Mais ici nous validons le comportement via le retour et les logs
|
||||
# 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 → PipelineWarning, jamais d'exception non gérée".
|
||||
"Erreur XMPP → False, jamais d'exception non gérée".
|
||||
"""
|
||||
|
||||
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=MockClientXMPP)
|
||||
def test_failed_auth_returns_false_not_unhandled_exception(
|
||||
self,
|
||||
xmpp_settings_enabled: XmppSettings,
|
||||
) -> None:
|
||||
"""Test qu'un échec d'authentification retourne False, pas 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.
|
||||
@@ -286,15 +290,13 @@ class TestXmppIntegrationErrorHandling:
|
||||
:param xmpp_settings_enabled: Paramètres XMPP valides et activés.
|
||||
"""
|
||||
|
||||
# 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()
|
||||
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=FailedAuthClient):
|
||||
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)
|
||||
@@ -303,64 +305,6 @@ class TestXmppIntegrationErrorHandling:
|
||||
result = channel.send(msg)
|
||||
assert result is False
|
||||
|
||||
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=MockClientXMPP)
|
||||
def test_connection_timeout_returns_false_no_unhandled_exception(
|
||||
self,
|
||||
xmpp_settings_enabled: XmppSettings,
|
||||
) -> None:
|
||||
"""Test qu'un timeout de connexion retourne False sans lever d'exception.
|
||||
|
||||
Critère d'acceptation #2 : Les erreurs sont gérées et retournent False
|
||||
ou lèvent PipelineWarning, jamais d'exception non gérée.
|
||||
|
||||
:param xmpp_settings_enabled: Paramètres XMPP valides et activés.
|
||||
"""
|
||||
|
||||
# Créer un mock qui bloque indéfiniment
|
||||
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 = 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)
|
||||
|
||||
# Utiliser un timeout court pour le test
|
||||
with patch("asyncio.wait_for", side_effect=TimeoutError):
|
||||
# Doit retourner False, pas lever d'exception
|
||||
result = channel.send(msg)
|
||||
assert result is False
|
||||
|
||||
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=MockClientXMPP)
|
||||
def test_starttls_unavailable_returns_false_no_exception(
|
||||
self,
|
||||
xmpp_settings_enabled: XmppSettings,
|
||||
) -> None:
|
||||
"""Test que STARTTLS non disponible retourne False sans lever d'exception.
|
||||
|
||||
:param xmpp_settings_enabled: Paramètres XMPP valides et activés.
|
||||
"""
|
||||
|
||||
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_enabled.model_copy(update={"use_tls": True})
|
||||
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)
|
||||
|
||||
# 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.
|
||||
@@ -369,8 +313,8 @@ class TestXmppIntegrationSecurity:
|
||||
"Aucun secret dans les logs XMPP".
|
||||
"""
|
||||
|
||||
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=MockClientXMPP)
|
||||
def test_no_secret_in_logs_on_xmpp_error(
|
||||
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
|
||||
def test_integration_no_secret_in_logs_on_xmpp_error(
|
||||
self,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
@@ -393,8 +337,10 @@ class TestXmppIntegrationSecurity:
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
class ErrorClient(MockClientXMPP):
|
||||
async def connect(self, *args: object, **kwargs: object) -> None:
|
||||
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):
|
||||
@@ -413,15 +359,9 @@ class TestXmppIntegrationSecurity:
|
||||
assert INTEG_PASS_SENTINEL not in logs
|
||||
assert INTEG_TO_SENTINEL not in logs
|
||||
|
||||
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=MockClientXMPP)
|
||||
def test_no_secret_in_logs_on_pipeline_warning(
|
||||
self,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test que les secrets ne sont pas présents dans les logs lors d'une PipelineWarning.
|
||||
|
||||
Critère d'acceptation #3 : Les secrets ne doivent pas fuir dans les logs
|
||||
lors de la journalisation des erreurs.
|
||||
@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.
|
||||
"""
|
||||
@@ -437,23 +377,11 @@ class TestXmppIntegrationSecurity:
|
||||
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 = get_channel(settings, dry_run=False)
|
||||
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)
|
||||
|
||||
# Doit retourner False, pas lever d'exception
|
||||
result = channel.send(msg)
|
||||
assert result is 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
|
||||
assert result is True
|
||||
# ClientXMPP ne doit pas être instancié en dry_run
|
||||
assert not mock_cls.called
|
||||
|
||||
Reference in New Issue
Block a user