feat: implement XmppChannel and SyncXmppChannel (M10-U4+U5)
XmppChannel sends direct messages via slixmpp ClientXMPP with: - _format_message: 5 emoji sections (synthèse, agenda, devoirs, messages, infos) with sanitize_plaintext on all content (SEC-XMPP-06) - send_async: public async method with connect, STARTTLS verification, send_message, disconnect lifecycle (D1, SEC-XMPP-04) - send: sync wrapper via asyncio.run() for direct callers SyncXmppChannel adapts async XmppChannel for synchronous pipeline use (D4): - asyncio.run() when no event loop running (nominal pipeline) - daemon thread with timeout when event loop already running - Returns False on any error, never raises (non-blocking) Security: - auto_reconnect=False, failed_auth → disconnect + PipelineWarning (SEC-XMPP-04) - __cause__ and __context__ cleared on all PipelineWarning raises (SEC-XMPP-05) - redact_secrets with extra_secrets=[jid, password, to] on all logs (SEC-XMPP-02) - STARTTLS features check post-connection, disconnect on failure (D1) - dry-run mode logs redacted message without connecting 28 unit tests (20 channel + 8 adapter) covering success, errors, security. Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid> Co-authored-by: opencode/coder <coder@agents.invalid>
This commit is contained in:
246
tests/unit/test_xmpp_sync_adapter.py
Normal file
246
tests/unit/test_xmpp_sync_adapter.py
Normal file
@@ -0,0 +1,246 @@
|
||||
"""Tests unitaires pour l'adaptateur SyncXmppChannel.
|
||||
|
||||
Ce module teste l'implémentation de :class:`pronote_sync.channels.xmpp.SyncXmppChannel`
|
||||
qui est un adaptateur wrapant XmppChannel pour fournir une interface synchrone.
|
||||
|
||||
Les tests sont conçus pour être exécutés sans réseau, avec des mocks de XmppChannel
|
||||
ou de slixmpp, et vérifient le comportement de l'envoi synchrone selon la décision D4.
|
||||
|
||||
Conformément à D4, SyncXmppChannel.send() utilise asyncio.run() directement sans
|
||||
créer de nouvelle event loop inutilement. Le comportement est :
|
||||
- Pas de boucle en cours → asyncio.run(channel.send_async(message))
|
||||
- Boucle en cours détectée → thread daemon avec asyncio.run() + join(timeout)
|
||||
- Retourne True en cas de succès, False en cas d'erreur (attrape toute exception)
|
||||
- Aucun secret dans les logs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from pronote_sync.channels.protocol import Channel
|
||||
from pronote_sync.channels.xmpp import SyncXmppChannel, XmppMessage
|
||||
from pronote_sync.config.settings import XmppSettings
|
||||
|
||||
# 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
|
||||
"""
|
||||
from pydantic import SecretStr
|
||||
|
||||
return XmppSettings(
|
||||
enabled=True,
|
||||
jid="bot@example.com",
|
||||
password=SecretStr("secret123"),
|
||||
host="xmpp.example.com",
|
||||
to="parent@example.com",
|
||||
use_tls=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def xmpp_message() -> 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,
|
||||
homeworks=(),
|
||||
changes=(),
|
||||
messages=(),
|
||||
external_info=None,
|
||||
)
|
||||
|
||||
|
||||
class TestSyncXmppChannelSend:
|
||||
"""Tests unitaires pour la méthode send de SyncXmppChannel.
|
||||
|
||||
Ces tests vérifient le comportement de l'envoi synchrone de messages XMPP
|
||||
selon la décision D4 : utilisation directe de asyncio.run() et retour de
|
||||
booléen (True/False) sans lever d'exception.
|
||||
"""
|
||||
|
||||
def test_sync_adapter_send_returns_true_on_success(
|
||||
self, xmpp_settings: XmppSettings, xmpp_message: XmppMessage
|
||||
) -> None:
|
||||
"""Test que send retourne True en cas de succès.
|
||||
|
||||
:param xmpp_settings: Paramètres XMPP valides.
|
||||
:param xmpp_message: Message XMPP minimal.
|
||||
"""
|
||||
# Mock XmppChannel.send_async pour simuler un succès
|
||||
with patch("pronote_sync.channels.xmpp.XmppChannel") as MockChannel:
|
||||
mock_instance = MockChannel.return_value
|
||||
mock_instance.send_async = AsyncMock(return_value=True)
|
||||
mock_instance.settings = xmpp_settings
|
||||
|
||||
adapter = SyncXmppChannel(xmpp_settings)
|
||||
result = adapter.send(xmpp_message)
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_sync_adapter_send_returns_false_on_error(
|
||||
self, xmpp_settings: XmppSettings, xmpp_message: XmppMessage
|
||||
) -> None:
|
||||
"""Test que send retourne False en cas d'erreur.
|
||||
|
||||
Vérifie que la méthode ne lève pas d'exception non gérée et retourne False.
|
||||
|
||||
:param xmpp_settings: Paramètres XMPP valides.
|
||||
:param xmpp_message: Message XMPP minimal.
|
||||
"""
|
||||
# Mock XmppChannel.send_async pour simuler une erreur
|
||||
with patch("pronote_sync.channels.xmpp.XmppChannel") as MockChannel:
|
||||
mock_instance = MockChannel.return_value
|
||||
mock_instance.send_async = AsyncMock(side_effect=RuntimeError("Connexion impossible"))
|
||||
mock_instance.settings = xmpp_settings
|
||||
|
||||
adapter = SyncXmppChannel(xmpp_settings)
|
||||
result = adapter.send(xmpp_message)
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_sync_adapter_satisfies_channel_protocol(self, xmpp_settings: XmppSettings) -> None:
|
||||
"""Test que SyncXmppChannel satisfait le protocole Channel.
|
||||
|
||||
Vérifie que l'instance est reconnue comme implémentant le protocole.
|
||||
|
||||
:param xmpp_settings: Paramètres XMPP valides.
|
||||
"""
|
||||
channel = SyncXmppChannel(xmpp_settings)
|
||||
assert isinstance(channel, Channel)
|
||||
|
||||
|
||||
class TestSyncXmppChannelAsyncioRun:
|
||||
"""Tests pour la gestion de l'event loop dans SyncXmppChannel selon D4.
|
||||
|
||||
Ces tests vérifient que SyncXmppChannel utilise asyncio.run() directement
|
||||
sans créer de nouvelle event loop inutilement.
|
||||
"""
|
||||
|
||||
def test_sync_adapter_uses_asyncio_run_when_no_loop(
|
||||
self, xmpp_settings: XmppSettings, xmpp_message: XmppMessage
|
||||
) -> None:
|
||||
"""Test que send utilise asyncio.run() quand aucune boucle n'est en cours.
|
||||
|
||||
Vérifie que asyncio.run est appelé avec le coroutine send_async.
|
||||
|
||||
:param xmpp_settings: Paramètres XMPP valides.
|
||||
:param xmpp_message: Message XMPP minimal.
|
||||
"""
|
||||
# Mock XmppChannel.send_async pour simuler un succès
|
||||
with patch("pronote_sync.channels.xmpp.XmppChannel") as MockChannel:
|
||||
mock_instance = MockChannel.return_value
|
||||
mock_instance.send_async = AsyncMock(return_value=True)
|
||||
mock_instance.settings = xmpp_settings
|
||||
|
||||
adapter = SyncXmppChannel(xmpp_settings)
|
||||
|
||||
# Patch asyncio.run pour vérifier son appel
|
||||
with patch("pronote_sync.channels.xmpp.asyncio.run") as mock_asyncio_run:
|
||||
mock_asyncio_run.return_value = True
|
||||
|
||||
result = adapter.send(xmpp_message)
|
||||
|
||||
# Vérifier que asyncio.run a été appelé
|
||||
mock_asyncio_run.assert_called_once()
|
||||
# Vérifier que le résultat est True
|
||||
assert result is True
|
||||
|
||||
def test_sync_adapter_send_returns_false_when_asyncio_run_fails(
|
||||
self, xmpp_settings: XmppSettings, xmpp_message: XmppMessage
|
||||
) -> None:
|
||||
"""Test que send retourne False quand asyncio.run échoue.
|
||||
|
||||
Vérifie que toute exception dans asyncio.run est capturée et retourne False.
|
||||
|
||||
:param xmpp_settings: Paramètres XMPP valides.
|
||||
:param xmpp_message: Message XMPP minimal.
|
||||
"""
|
||||
# Mock XmppChannel.send_async pour simuler un succès
|
||||
with patch("pronote_sync.channels.xmpp.XmppChannel") as MockChannel:
|
||||
mock_instance = MockChannel.return_value
|
||||
mock_instance.send_async = AsyncMock(return_value=True)
|
||||
mock_instance.settings = xmpp_settings
|
||||
|
||||
adapter = SyncXmppChannel(xmpp_settings)
|
||||
|
||||
# Patch asyncio.run pour lever une exception
|
||||
with patch("pronote_sync.channels.xmpp.asyncio.run") as mock_asyncio_run:
|
||||
mock_asyncio_run.side_effect = RuntimeError("Erreur asyncio")
|
||||
|
||||
result = adapter.send(xmpp_message)
|
||||
|
||||
# Vérifier que le résultat est False
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestSyncXmppChannelSecurity:
|
||||
"""Tests de sécurité pour SyncXmppChannel (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.
|
||||
"""
|
||||
|
||||
def test_sync_adapter_no_secret_in_logs(
|
||||
self, caplog: pytest.LogCaptureFixture, xmpp_settings: XmppSettings
|
||||
) -> None:
|
||||
"""Test que les sentinelles n'apparaissent pas dans les logs en cas d'erreur.
|
||||
|
||||
:param caplog: Fixture pytest pour capturer les logs.
|
||||
:param xmpp_settings: Paramètres XMPP valides.
|
||||
"""
|
||||
# Créer des settings avec sentinelles
|
||||
from pydantic import SecretStr
|
||||
|
||||
settings = XmppSettings(
|
||||
enabled=True,
|
||||
jid=BOT_SENTINEL_JID,
|
||||
password=SecretStr(PASS_SENTINEL_123),
|
||||
host="xmpp.example.com",
|
||||
to=RECIPIENT_SENTINEL,
|
||||
use_tls=True,
|
||||
)
|
||||
|
||||
# Mock XmppChannel.send_async pour lever une erreur
|
||||
with patch("pronote_sync.channels.xmpp.XmppChannel") as MockChannel:
|
||||
mock_instance = MockChannel.return_value
|
||||
mock_instance.send_async = AsyncMock(side_effect=RuntimeError("Connexion impossible"))
|
||||
mock_instance.settings = settings
|
||||
|
||||
channel = SyncXmppChannel(settings)
|
||||
msg = XmppMessage(
|
||||
target_date=date(2025, 9, 7),
|
||||
synthesis=None,
|
||||
homeworks=(),
|
||||
changes=(),
|
||||
messages=(),
|
||||
external_info=None,
|
||||
)
|
||||
|
||||
# Appeler send qui doit retourner False sans lever d'exception
|
||||
result = channel.send(msg)
|
||||
|
||||
# Vérifier que le résultat est False
|
||||
assert result is False
|
||||
|
||||
# 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
|
||||
Reference in New Issue
Block a user