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>
236 lines
8.4 KiB
Python
236 lines
8.4 KiB
Python
"""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))
|
|
- 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 MagicMock, patch
|
|
|
|
import pytest
|
|
from pydantic import SecretStr
|
|
|
|
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
|
|
"""
|
|
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.
|
|
"""
|
|
|
|
@patch("pronote_sync.channels.xmpp.asyncio.run")
|
|
def test_sync_adapter_send_returns_true_on_success(
|
|
self, mock_asyncio_run: MagicMock, xmpp_settings: XmppSettings, xmpp_message: XmppMessage
|
|
) -> None:
|
|
"""Test que send retourne True en cas de succès.
|
|
|
|
:param mock_asyncio_run: Mock de asyncio.run
|
|
:param xmpp_settings: Paramètres XMPP valides.
|
|
:param xmpp_message: Message XMPP minimal.
|
|
"""
|
|
mock_asyncio_run.side_effect = lambda coro: coro.close() or True
|
|
channel = SyncXmppChannel(xmpp_settings)
|
|
result = channel.send(xmpp_message)
|
|
assert result is True
|
|
|
|
@patch("pronote_sync.channels.xmpp.asyncio.run")
|
|
def test_sync_adapter_send_returns_false_on_error(
|
|
self, mock_run: MagicMock, 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 mock_run: Mock de asyncio.run
|
|
:param xmpp_settings: Paramètres XMPP valides.
|
|
:param xmpp_message: Message XMPP minimal.
|
|
"""
|
|
|
|
# Simuler une erreur dans asyncio.run
|
|
def _run_with_error(coro: object) -> bool:
|
|
"""Ferme la coroutine non exécutée puis lève l'erreur simulée."""
|
|
close = getattr(coro, "close", None)
|
|
if close is not None:
|
|
close()
|
|
raise RuntimeError("Connexion impossible")
|
|
|
|
mock_run.side_effect = _run_with_error
|
|
channel = SyncXmppChannel(xmpp_settings)
|
|
result = channel.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)
|
|
|
|
def test_sync_adapter_dry_run_does_not_create_client(
|
|
self, xmpp_settings: XmppSettings, xmpp_message: XmppMessage
|
|
) -> None:
|
|
"""Test que dry_run=True ne crée jamais ClientXMPP.
|
|
|
|
:param xmpp_settings: Paramètres XMPP valides.
|
|
:param xmpp_message: Message XMPP minimal.
|
|
"""
|
|
channel = SyncXmppChannel(xmpp_settings, dry_run=True)
|
|
with patch("pronote_sync.channels.xmpp.ClientXMPP") as mock_cls:
|
|
result = channel.send(xmpp_message)
|
|
assert result is True
|
|
# ClientXMPP ne doit pas être instancié en dry_run
|
|
assert not mock_cls.called
|
|
|
|
def test_sync_adapter_dry_run_returns_true(
|
|
self, xmpp_settings: XmppSettings, xmpp_message: XmppMessage
|
|
) -> None:
|
|
"""Test que dry_run=True retourne True sans se connecter.
|
|
|
|
:param xmpp_settings: Paramètres XMPP valides.
|
|
:param xmpp_message: Message XMPP minimal.
|
|
"""
|
|
channel = SyncXmppChannel(xmpp_settings, dry_run=True)
|
|
result = channel.send(xmpp_message)
|
|
assert result is True
|
|
|
|
@patch("pronote_sync.channels.xmpp.asyncio.run")
|
|
def test_sync_adapter_never_raises(
|
|
self, mock_run: MagicMock, xmpp_settings: XmppSettings, xmpp_message: XmppMessage
|
|
) -> None:
|
|
"""Test que send ne lève jamais d'exception.
|
|
|
|
:param mock_run: Mock de asyncio.run
|
|
:param xmpp_settings: Paramètres XMPP valides.
|
|
:param xmpp_message: Message XMPP minimal.
|
|
"""
|
|
|
|
# Simuler une erreur quelconque
|
|
def _run_with_error(coro: object) -> bool:
|
|
"""Ferme la coroutine non exécutée puis lève l'erreur simulée."""
|
|
close = getattr(coro, "close", None)
|
|
if close is not None:
|
|
close()
|
|
raise Exception("Any error")
|
|
|
|
mock_run.side_effect = _run_with_error
|
|
channel = SyncXmppChannel(xmpp_settings)
|
|
result = channel.send(xmpp_message)
|
|
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
|
|
settings = XmppSettings(
|
|
enabled=True,
|
|
jid=BOT_SENTINEL_JID,
|
|
password=SecretStr(PASS_SENTINEL_123),
|
|
host="localhost",
|
|
port=5222,
|
|
to=RECIPIENT_SENTINEL,
|
|
use_tls=False,
|
|
)
|
|
|
|
channel = SyncXmppChannel(settings)
|
|
msg = XmppMessage(
|
|
target_date=date(2025, 9, 7),
|
|
synthesis=None,
|
|
homeworks=(),
|
|
changes=(),
|
|
messages=(),
|
|
external_info=None,
|
|
)
|
|
|
|
# Simuler une erreur dans asyncio.run
|
|
with patch("pronote_sync.channels.xmpp.asyncio.run") as mock_run:
|
|
|
|
def _run_with_error(coro: object) -> bool:
|
|
"""Ferme la coroutine non exécutée puis lève l'erreur simulée."""
|
|
close = getattr(coro, "close", None)
|
|
if close is not None:
|
|
close()
|
|
raise RuntimeError("Connexion impossible")
|
|
|
|
mock_run.side_effect = _run_with_error
|
|
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
|