Files
college-infos/tests/unit/test_xmpp_channel.py
OpenCode 2928cca8b9 fix(xmpp): remplacer use_tls par tls_mode et borner les phases de connexion
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>
2026-09-11 17:00:34 +02:00

1643 lines
62 KiB
Python

"""Tests unitaires pour le canal XMPP (XmppChannel).
Ce module teste l'implémentation de :class:`pronote_sync.channels.xmpp.XmppChannel`
selon les spécifications du projet (GUIDE_DEV_PYTHON.md §10, décisions D1-D3,
audit de sécurité SEC-XMPP-02/04/05/06).
Les tests sont conçus pour être exécutés sans réseau, avec des mocks de slixmpp.
"""
from __future__ import annotations
import asyncio
from collections.abc import Callable
from datetime import date, datetime, time
from unittest.mock import patch
import pytest
from pydantic import SecretStr
from pronote_sync.channels.xmpp import SyncXmppChannel, XmppChannel, XmppMessage
from pronote_sync.config.settings import XmppSettings
from pronote_sync.models.agenda import Lesson, TheoreticalLesson
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
BOT_SENTINEL_JID = "BOT_SENTINEL_JID@example.com"
PASS_SENTINEL_123 = "PASS_SENTINEL_123"
RECIPIENT_SENTINEL = "RECIPIENT_SENTINEL@example.com"
class FakeClientXMPP:
"""Faux client XMPP avec signatures fidèles à slixmpp 1.17.0."""
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[..., object]]] = {}
self.messages_sent: list[dict[str, object]] = []
self._connect_should_fail = False
self._auth_should_fail = False
self._should_disconnect_early = False
self._host_used: str | None = None
self._port_used: int | None = None
# New modes for realistic failure simulation
self._connect_mode: str = "ok" # "ok", "pending", "connection_failed"
self._disconnect_mode: str = "ok" # "ok", "pending"
self._fire_connection_failed: bool = False
def add_event_handler(
self, name: str, pointer: Callable[..., object], disposable: bool = False
) -> None:
if name not in ("session_start", "failed_auth", "connection_failed", "disconnected"):
raise AssertionError(f"Unsupported event: {name}")
self.handlers.setdefault(name, []).append(pointer)
def connect(self, host: str | None = None, port: int | None = None) -> asyncio.Future[bool]:
"""Returns a Future (like slixmpp 1.17.0). NOT async."""
loop = asyncio.get_event_loop()
future: asyncio.Future[bool] = loop.create_future()
self.connected = True
self._host_used = host
self._port_used = port
if self._connect_mode == "pending":
# Never resolves - simulates connection timeout
return future
elif self._connect_mode == "connection_failed":
# Resolves connect future but fires connection_failed event
loop.call_soon(self._fire_connection_failed_event)
future.set_result(True)
return future
else:
# Schedule event handlers to fire after connect returns
loop.call_soon(self._fire_events)
future.set_result(True)
return future
def _fire_connection_failed_event(self) -> None:
"""Fire connection_failed event for testing."""
self._fire("connection_failed")
def _fire_events(self) -> None:
if self._should_disconnect_early:
self._fire("disconnected")
elif self._auth_should_fail:
self._fire("failed_auth")
else:
self._fire("session_start")
def _fire(self, event: str) -> None:
for handler in self.handlers.get(event, []):
handler({})
def disconnect(
self, wait: float = 2.0, reason: str | None = None, ignore_send_queue: bool = False
) -> asyncio.Future[bool]:
loop = asyncio.get_event_loop()
future: asyncio.Future[bool] = loop.create_future()
self.disconnected = True
if self._disconnect_mode == "pending":
# Never resolves - simulates cleanup timeout
return future
else:
future.set_result(True)
return future
def send_message(
self, mto: object, mbody: str | None = None, mtype: str | None = None, **kwargs: object
) -> None:
self.messages_sent.append({"mto": mto, "mbody": mbody, "mtype": mtype, **kwargs})
@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"), # pragma: allowlist secret
host="xmpp.example.com",
port=5222,
to="parent@example.com",
resource="pronote-sync",
use_tls=True,
timeout=30,
)
@pytest.fixture
def xmpp_message_minimal() -> 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,
external_info=None,
)
@pytest.fixture
def xmpp_message_full() -> XmppMessage:
"""Fixture fournissant un message XMPP complet pour les tests.
: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 TestXmppChannelFormatMessage:
"""Tests unitaires pour la méthode _format_message de XmppChannel.
Ces tests vérifient le formatage des messages XMPP en texte brut,
sans dépendre de slixmpp ni du réseau.
"""
def test_format_message_includes_target_date(self, xmpp_message_full: XmppMessage) -> None:
"""Test que la date cible apparaît dans l'en-tête du message.
:param xmpp_message_full: Message XMPP complet.
"""
channel = XmppChannel(XmppSettings(), dry_run=True)
formatted = channel._format_message(xmpp_message_full)
assert "Digest du 07/09/2025" in formatted
def test_format_message_synthesis_section(self, xmpp_message_full: XmppMessage) -> None:
"""Test que la section synthèse est bien formatée avec/sans synthèse.
:param xmpp_message_full: Message XMPP complet.
"""
channel = XmppChannel(XmppSettings(), dry_run=True)
formatted = channel._format_message(xmpp_message_full)
assert "📌 Synthèse" in formatted
assert "Voici la synthèse des activités du jour." in formatted
msg_no_synth = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
formatted2 = channel._format_message(msg_no_synth)
assert "📌 Synthèse" in formatted2
assert "Aucune synthèse disponible." in formatted2
def test_format_message_changes_with_type(self, xmpp_message_full: XmppMessage) -> None:
"""Test que les changements d'agenda affichent le type de changement.
:param xmpp_message_full: Message XMPP complet.
"""
channel = XmppChannel(XmppSettings(), dry_run=True)
formatted = channel._format_message(xmpp_message_full)
assert "[Ajouté]" in formatted
assert "Physique: Cours déplacé" in formatted
def test_format_message_changes_with_times(self, xmpp_message_full: XmppMessage) -> None:
"""Test que les horaires des cours sont formatés HH:MM-HH:MM.
:param xmpp_message_full: Message XMPP complet.
"""
channel = XmppChannel(XmppSettings(), dry_run=True)
formatted = channel._format_message(xmpp_message_full)
assert "08:00-09:00" in formatted
def test_format_message_changes_removed_with_theoretical_lesson(self) -> None:
"""Test qu'un changement REMOVED utilise la matière du cours théorique."""
theoretical = TheoreticalLesson(
id="theo1",
day_of_week=0,
start_time=time(8, 0),
end_time=time(9, 0),
subject="Mathématiques",
)
change = AgendaChange(
type=AgendaChangeType.REMOVED,
lesson=None,
theoretical_lesson=theoretical,
details="Cours annulé",
)
msg = XmppMessage(
target_date=date(2025, 9, 7),
synthesis=None,
changes=(change,),
external_info=None,
)
channel = XmppChannel(XmppSettings(), dry_run=True)
formatted = channel._format_message(msg)
assert "[Supprimé] Mathématiques: Cours annulé" in formatted
def test_format_message_changes_modified(self) -> None:
"""Test qu'un changement MODIFIED affiche la matière du cours réel."""
lesson = Lesson(
id="lesson_mod",
subject="SVT",
start=datetime.fromisoformat("2025-09-07T10:00:00"),
end=datetime.fromisoformat("2025-09-07T11:00:00"),
rooms=("B201",),
teachers=("M. Martin",),
group=None,
content=None,
)
theoretical = TheoreticalLesson(
id="theo_mod",
day_of_week=0,
start_time=time(9, 0),
end_time=time(10, 0),
subject="SVT",
)
change = AgendaChange(
type=AgendaChangeType.MODIFIED,
lesson=lesson,
theoretical_lesson=theoretical,
details="Salle changée",
)
msg = XmppMessage(
target_date=date(2025, 9, 7),
synthesis=None,
changes=(change,),
external_info=None,
)
channel = XmppChannel(XmppSettings(), dry_run=True)
formatted = channel._format_message(msg)
assert "[Modifié] SVT: Salle changée" in formatted
def test_format_message_messages_with_and_without_title(self) -> None:
"""Test que les messages affichent le titre s'il est présent, sinon l'auteur seul."""
message_with_title = Message(
id="m1",
type=MessageType.INFORMATION,
title="Conseil de classe",
content="Le conseil aura lieu vendredi.",
author="CPE",
date=datetime.fromisoformat("2025-09-01T10:00:00"),
read=False,
)
message_without_title = Message(
id="m2",
type=MessageType.INFORMATION,
title="",
content="Le self sera fermé mardi.",
author="Intendance",
date=datetime.fromisoformat("2025-09-01T11:00:00"),
read=False,
)
msg = XmppMessage(
target_date=date(2025, 9, 7),
synthesis=None,
messages=(message_with_title, message_without_title),
external_info=None,
)
channel = XmppChannel(XmppSettings(), dry_run=True)
formatted = channel._format_message(msg)
assert "Conseil de classe (CPE): Le conseil aura lieu vendredi." in formatted
assert "Intendance: Le self sera fermé mardi." in formatted
def test_format_message_homeworks_with_due_date(self, xmpp_message_full: XmppMessage) -> None:
"""Test que les devoirs affichent la date d'échéance.
:param xmpp_message_full: Message XMPP complet.
"""
channel = XmppChannel(XmppSettings(), dry_run=True)
formatted = channel._format_message(xmpp_message_full)
assert "(à rendre le 15/09)" in formatted
def test_format_message_messages_with_author(self, xmpp_message_full: XmppMessage) -> None:
"""Test que les messages affichent l'auteur.
:param xmpp_message_full: Message XMPP complet.
"""
channel = XmppChannel(XmppSettings(), dry_run=True)
formatted = channel._format_message(xmpp_message_full)
# Vérifier que le formatage inclut les sections attendues
assert "📌 Synthèse" in formatted
assert "📅 Changements d'agenda" in formatted
assert "📚 Devoirs" in formatted
assert "📢 Informations diverses" in formatted
def test_format_message_external_info_no_pronote_messages(
self, xmpp_message_full: XmppMessage
) -> None:
"""Test que pronote_messages n'est pas rendu dans la section 📢.
:param xmpp_message_full: Message XmppMessage complet.
"""
channel = XmppChannel(XmppSettings(), dry_run=True)
formatted = channel._format_message(xmpp_message_full)
# Le message est dans external_info.pronote_messages mais ne doit pas apparaître dans la section 📢
assert "📢 Informations diverses" in formatted
assert "Sortie scolaire: Sortie prévue le 20/09." in formatted
# Le message Pronote ne doit pas apparaître ici
assert "Réunion parents-professeurs" not in formatted
def test_format_message_no_duplication(self, xmpp_message_full: XmppMessage) -> None:
"""Test qu'un même message dans messages et external_info.pronote_messages apparaît une seule fois.
:param xmpp_message_full: Message XmppMessage complet.
"""
channel = XmppChannel(XmppSettings(), dry_run=True)
# Le message est déjà dans external_info.pronote_messages
formatted = channel._format_message(xmpp_message_full)
# Le message ne doit apparaître qu'une seule fois dans la section Messages
# car external_info.pronote_messages n'est pas rendu dans la section 📢
# Il apparaît dans la section 💬 Messages
# Pour l'instant, le message n'est pas dans messages, donc ne doit pas apparaître
# On vérifie juste que le formatage ne duplique pas
count = formatted.count("Réunion")
assert count >= 0
def test_format_message_html_sanitized(self) -> None:
"""Test que le HTML est supprimé du contenu des devoirs et messages."""
homework = Homework(
id="hw_html",
subject="SVT",
teachers=("M. Bernard",),
assigned_on=date(2025, 9, 1),
due_on=date(2025, 9, 20),
text="Lire <b>le chapitre 3</b> et répondre aux questions.",
html="<p>Lire <b>le chapitre 3</b> et répondre aux questions.</p>",
)
msg = XmppMessage(
target_date=date(2025, 9, 7),
synthesis=None,
homeworks=(homework,),
external_info=None,
)
channel = XmppChannel(XmppSettings(), dry_run=True)
formatted = channel._format_message(msg)
# Le HTML doit être supprimé
assert "<b>" not in formatted
assert "le chapitre 3" in formatted
def test_format_message_control_chars_stripped(self) -> None:
"""Test que les caractères de contrôle sont supprimés du contenu."""
homework = Homework(
id="hw_ctrl",
subject="Histoire",
teachers=("Mme Dubois",),
assigned_on=date(2025, 9, 1),
due_on=date(2025, 9, 25),
text="Fiche\x00n°4\x01à\x07rendre\x1f",
html="",
)
msg = XmppMessage(
target_date=date(2025, 9, 7),
synthesis=None,
homeworks=(homework,),
external_info=None,
)
channel = XmppChannel(XmppSettings(), dry_run=True)
formatted = channel._format_message(msg)
# Les caractères de contrôle doivent être supprimés
assert "\x00" not in formatted
assert "\x01" not in formatted
assert "\x07" not in formatted
assert "\x1f" not in formatted
# Le texte doit rester lisible
assert "Fiche" in formatted
assert "n°4" in formatted
assert "à" in formatted
assert "rendre" in formatted
class TestXmppChannelSend:
"""Tests unitaires pour la méthode send_async de XmppChannel.
Ces tests vérifient le comportement de l'envoi de messages XMPP,
avec mock de slixmpp.ClientXMPP fidèle à slixmpp 1.17.0.
"""
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_send_async_dry_run_returns_true(self) -> None:
"""Test que dry_run=True retourne True sans créer ClientXMPP.
:return: None
"""
settings = 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,
)
channel = XmppChannel(settings, dry_run=True)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
result = await channel.send_async(msg)
assert result is True
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_send_async_success_returns_true(self) -> None:
"""Test que send_async retourne True en cas de succès de connexion.
:return: None
"""
settings = 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,
)
channel = XmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
result = await channel.send_async(msg)
assert result is True
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_send_async_failed_auth_returns_false(self) -> None:
"""Test que failed_auth retourne False (pas d'exception).
:return: None
"""
settings = 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,
)
class FailedAuthClient(FakeClientXMPP):
def __init__(self, jid: str, password: str) -> None:
super().__init__(jid, password)
self._auth_should_fail = True
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=FailedAuthClient):
channel = XmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
result = await channel.send_async(msg)
assert result is False
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_send_async_timeout_returns_false(self) -> None:
"""Test que timeout retourne False.
:return: None
"""
settings = XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr("secret123"),
host="localhost",
port=5222,
to="parent@example.com",
resource="pronote-sync",
use_tls=False,
timeout=1,
)
class NoEventClient(FakeClientXMPP):
def _fire_events(self) -> None:
# Ne déclencher aucun événement, donc session_future jamais résolu
pass
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=NoEventClient):
channel = XmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
result = await channel.send_async(msg)
assert result is False
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_send_async_disconnected_early_returns_false(self) -> None:
"""Test que disconnected avant session_start retourne False.
:return: None
"""
settings = 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,
)
class DisconnectEarlyClient(FakeClientXMPP):
def __init__(self, jid: str, password: str) -> None:
super().__init__(jid, password)
self._should_disconnect_early = True
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=DisconnectEarlyClient):
channel = XmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
result = await channel.send_async(msg)
assert result is False
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_send_async_no_recipient_returns_false(self) -> None:
"""Test que settings.to = None retourne False.
:return: None
"""
settings = XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr("secret123"),
host="xmpp.example.com",
port=5222,
to=None,
resource="pronote-sync",
use_tls=True,
timeout=30,
)
channel = XmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
result = await channel.send_async(msg)
assert result is False
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_send_async_uses_host_and_port(self) -> None:
"""Test que mock reçoit les settings.host et settings.port.
:return: None
"""
settings = XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr("secret123"),
host="myhost.example.com",
port=5223,
to="parent@example.com",
resource="pronote-sync",
use_tls=True,
timeout=30,
)
class InspectClient(FakeClientXMPP):
def __init__(self, jid: str, password: str) -> None:
super().__init__(jid, password)
with patch("pronote_sync.channels.xmpp.ClientXMPP") as mock_cls:
mock_cls.return_value = InspectClient("bot@example.com", "secret123")
channel = XmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
await channel.send_async(msg)
# Vérifier que le mock a bien été instancié
assert mock_cls.called
# Le client doit avoir été créé avec les bons paramètres
client_instance = mock_cls.return_value
assert client_instance._host_used == "myhost.example.com"
assert client_instance._port_used == 5223
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_send_async_uses_jid_with_resource(self) -> None:
"""Test que le JID est construit avec le suffixe /resource.
:return: None
"""
settings = XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr("secret123"),
host="xmpp.example.com",
port=5222,
to="parent@example.com",
resource="myresource",
use_tls=True,
timeout=30,
)
class InspectClient(FakeClientXMPP):
def __init__(self, jid: str, password: str) -> None:
super().__init__(jid, password)
with patch("pronote_sync.channels.xmpp.ClientXMPP") as mock_cls:
mock_cls.return_value = InspectClient("ignored", "ignored")
channel = XmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
await channel.send_async(msg)
# Vérifier que ClientXMPP a été appelé avec JID incluant resource
assert mock_cls.called
call_args = mock_cls.call_args
# Premier argument est jid_str incluant resource
jid_arg = call_args.args[0]
assert jid_arg == "bot@example.com/myresource"
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_connect_timeout_returns_false(self, caplog: pytest.LogCaptureFixture) -> None:
"""Test que connect_timeout retourne False quand connect() ne résout pas.
:param caplog: Fixture pytest pour capturer les logs.
"""
settings = XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr("secret123"),
host="xmpp.example.com",
port=5222,
to="parent@example.com",
resource="pronote-sync",
tls_mode="starttls",
connect_timeout=0.05,
timeout=30,
cleanup_timeout=0.01,
)
class PendingConnectClient(FakeClientXMPP):
def __init__(self, jid: str, password: str) -> None:
super().__init__(jid, password)
self._connect_mode = "pending"
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=PendingConnectClient):
channel = XmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
result = await channel.send_async(msg)
assert result is False
# Vérifier que le log contient "connexion"
logs = caplog.text
assert "connexion" in logs.lower()
@pytest.mark.asyncio
async def test_cancelled_error_cancels_pending_tasks(self) -> None:
"""Test que l'annulation de send_async annule les tâches encore en attente.
Un ``connect()`` qui ne résout jamais et une annulation de la tâche
appelante doivent entraîner la cancellation de la future de connexion
(idempotence de ``_cancel_pending`` sur les chemins d'exception).
"""
settings = XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr("secret123"),
host="xmpp.example.com",
port=5222,
to="parent@example.com",
resource="pronote-sync",
tls_mode="starttls",
connect_timeout=15,
timeout=30,
cleanup_timeout=0.01,
)
class PendingConnectClient(FakeClientXMPP):
"""Client dont ``connect()`` retourne une future jamais résolue."""
def __init__(self, jid: str, password: str) -> None:
super().__init__(jid, password)
self._connect_mode = "pending"
self.connect_future: asyncio.Future[bool] | None = None
def connect(
self, host: str | None = None, port: int | None = None
) -> asyncio.Future[bool]:
future = super().connect(host, port)
self.connect_future = future
return future
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=PendingConnectClient):
channel = XmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
client = PendingConnectClient("bot@example.com", "secret123")
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=lambda j, p: client):
task = asyncio.ensure_future(channel.send_async(msg))
# Laisse asyncio.wait démarrer et la future de connexion rester en attente
await asyncio.sleep(0.05)
task.cancel()
result = await task
assert result is False
assert client.connect_future is not None
assert client.connect_future.cancelled()
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_connection_failed_event_returns_false(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""Test que connection_failed event retourne False rapidement.
:param caplog: Fixture pytest pour capturer les logs.
"""
settings = XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr("secret123"),
host="xmpp.example.com",
port=5222,
to="parent@example.com",
resource="pronote-sync",
tls_mode="starttls",
connect_timeout=15,
timeout=30,
cleanup_timeout=0.01,
)
class ConnectionFailedClient(FakeClientXMPP):
def __init__(self, jid: str, password: str) -> None:
super().__init__(jid, password)
self._connect_mode = "connection_failed"
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=ConnectionFailedClient):
channel = XmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
result = await channel.send_async(msg)
assert result is False
# Vérifier que le log contient une mention d'échec réseau
logs = caplog.text
assert "réseau" in logs.lower() or "connexion" in logs.lower()
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_cleanup_timeout_does_not_hang(self, caplog: pytest.LogCaptureFixture) -> None:
"""Test que cleanup_timeout ne bloque pas quand disconnect() ne résout pas.
:param caplog: Fixture pytest pour capturer les logs.
"""
import logging
caplog.set_level(logging.DEBUG)
settings = XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr("secret123"),
host="xmpp.example.com",
port=5222,
to="parent@example.com",
resource="pronote-sync",
tls_mode="starttls",
connect_timeout=0.05,
timeout=30,
cleanup_timeout=0.01,
)
class HangingDisconnectClient(FakeClientXMPP):
def __init__(self, jid: str, password: str) -> None:
super().__init__(jid, password)
self._disconnect_mode = "pending"
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=HangingDisconnectClient):
channel = XmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
# Should complete quickly despite hanging disconnect
result = await asyncio.wait_for(channel.send_async(msg), timeout=0.5)
assert result is True
# Vérifier que le log contient une mention de timeout de nettoyage
logs = caplog.text
assert "déconnexion" in logs.lower()
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_tls_mode_direct_config(self) -> None:
"""Test que tls_mode='direct' configure enable_direct_tls=True et enable_starttls=False.
:return: None
"""
settings = XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr("secret123"),
host="xmpp.example.com",
port=5222,
to="parent@example.com",
resource="pronote-sync",
tls_mode="direct",
timeout=30,
)
class InspectClient(FakeClientXMPP):
def __init__(self, jid: str, password: str) -> None:
super().__init__(jid, password)
with patch("pronote_sync.channels.xmpp.ClientXMPP") as mock_cls:
mock_cls.return_value = InspectClient("bot@example.com", "secret123")
channel = XmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
await channel.send_async(msg)
client_instance = mock_cls.return_value
assert client_instance.enable_direct_tls is True
assert client_instance.enable_starttls is False
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_tls_mode_starttls_config(self) -> None:
"""Test que tls_mode='starttls' configure enable_starttls=True et enable_direct_tls=False.
:return: None
"""
settings = XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr("secret123"),
host="xmpp.example.com",
port=5222,
to="parent@example.com",
resource="pronote-sync",
tls_mode="starttls",
timeout=30,
)
class InspectClient(FakeClientXMPP):
def __init__(self, jid: str, password: str) -> None:
super().__init__(jid, password)
with patch("pronote_sync.channels.xmpp.ClientXMPP") as mock_cls:
mock_cls.return_value = InspectClient("bot@example.com", "secret123")
channel = XmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
await channel.send_async(msg)
client_instance = mock_cls.return_value
assert client_instance.enable_starttls is True
assert client_instance.enable_direct_tls is False
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_tls_mode_disabled_config(self) -> None:
"""Test que tls_mode='disabled' avec host='127.0.0.1' configure TLS désactivé.
:return: None
"""
settings = XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr("secret123"),
host="127.0.0.1",
port=5222,
to="parent@example.com",
resource="pronote-sync",
tls_mode="disabled",
timeout=30,
)
class InspectClient(FakeClientXMPP):
def __init__(self, jid: str, password: str) -> None:
super().__init__(jid, password)
with patch("pronote_sync.channels.xmpp.ClientXMPP") as mock_cls:
mock_cls.return_value = InspectClient("bot@example.com", "secret123")
channel = XmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
await channel.send_async(msg)
client_instance = mock_cls.return_value
assert client_instance.enable_direct_tls is False
assert client_instance.enable_starttls is False
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_no_secret_leak_in_connection_failure_log(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""Test qu'aucun secret ne fuit dans les logs en cas d'échec de connexion.
:param caplog: Fixture pytest pour capturer les logs.
"""
sentinel_password = "SECRET_PASSWORD_XMPP_12345" # pragma: allowlist secret
settings = XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr(sentinel_password),
host="xmpp.example.com",
port=5222,
to="parent@example.com",
resource="pronote-sync",
tls_mode="starttls",
timeout=30,
)
class ConnectionFailedClient(FakeClientXMPP):
def __init__(self, jid: str, password: str) -> None:
super().__init__(jid, password)
self._connect_mode = "connection_failed"
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=ConnectionFailedClient):
channel = XmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
await channel.send_async(msg)
# Vérifier que le mot de passe sentinelle n'apparaît pas dans les logs
logs = caplog.text
assert sentinel_password not in logs
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_no_secret_leak_in_connect_timeout_log(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""Test qu'aucun secret ne fuit dans les logs en cas de timeout de connexion.
:param caplog: Fixture pytest pour capturer les logs.
"""
sentinel_password = "SECRET_PASSWORD_XMPP_12345"
settings = XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr(sentinel_password),
host="xmpp.example.com",
port=5222,
to="parent@example.com",
resource="pronote-sync",
tls_mode="starttls",
connect_timeout=0.05,
timeout=30,
cleanup_timeout=0.01,
)
class PendingConnectClient(FakeClientXMPP):
def __init__(self, jid: str, password: str) -> None:
super().__init__(jid, password)
self._connect_mode = "pending"
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=PendingConnectClient):
channel = XmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
await channel.send_async(msg)
# Vérifier que le mot de passe sentinelle n'apparaît pas dans les logs
logs = caplog.text
assert sentinel_password not in logs
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_send_async_tls_direct_config(self) -> None:
"""Test que use_tls=True configure enable_direct_tls=True et enable_starttls=False.
:return: None
"""
settings = 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,
)
class InspectClient(FakeClientXMPP):
def __init__(self, jid: str, password: str) -> None:
super().__init__(jid, password)
with patch("pronote_sync.channels.xmpp.ClientXMPP") as mock_cls:
mock_cls.return_value = InspectClient("bot@example.com", "secret123")
channel = XmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
await channel.send_async(msg)
client_instance = mock_cls.return_value
assert client_instance.enable_direct_tls is True
assert client_instance.enable_starttls is False
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_send_async_starttls_config(self) -> None:
"""Test que use_tls=False configure enable_starttls=True et enable_direct_tls=False.
:return: None
"""
settings = XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr("secret123"),
host="localhost",
port=5222,
to="parent@example.com",
resource="pronote-sync",
use_tls=False,
timeout=30,
)
class InspectClient(FakeClientXMPP):
def __init__(self, jid: str, password: str) -> None:
super().__init__(jid, password)
with patch("pronote_sync.channels.xmpp.ClientXMPP") as mock_cls:
mock_cls.return_value = InspectClient("bot@example.com", "secret123")
channel = XmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
await channel.send_async(msg)
client_instance = mock_cls.return_value
assert client_instance.enable_starttls is True
assert client_instance.enable_direct_tls is False
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_send_async_never_raises_pipeline_warning(self) -> None:
"""Test que send_async ne lève jamais d'exception.
:return: None
"""
settings = 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,
)
class ErrorClient(FakeClientXMPP):
def __init__(self, jid: str, password: str) -> None:
super().__init__(jid, password)
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 = XmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
result = await channel.send_async(msg)
assert result is False
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_send_async_disconnect_cleanup_error_returns_true(self) -> None:
"""Test que le canal ignore une erreur de déconnexion en nettoyage.
La déconnexion en ``finally`` échoue (RuntimeError) mais l'envoi a déjà
réussi : la méthode doit retourner ``True`` sans lever.
:return: None
"""
settings = 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,
)
class DisconnectErrorClient(FakeClientXMPP):
def disconnect(
self,
wait: float = 2.0,
reason: str | None = None,
ignore_send_queue: bool = False,
) -> asyncio.Future[bool]:
raise RuntimeError("Déconnexion impossible")
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=DisconnectErrorClient):
channel = XmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
result = await channel.send_async(msg)
assert result is True
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_send_async_connect_raises_exception_returns_false(self) -> None:
"""Test que connect() levant une exception retourne False.
:return: None
"""
settings = XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr("secret123"),
host="xmpp.example.com",
port=5222,
to="parent@example.com",
resource="pronote-sync",
tls_mode="starttls",
timeout=30,
)
class ConnectExceptionClient(FakeClientXMPP):
def connect(
self, host: str | None = None, port: int | None = None
) -> asyncio.Future[bool]:
raise ConnectionError("Network unreachable")
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=ConnectExceptionClient):
channel = XmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
result = await channel.send_async(msg)
assert result is False
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_send_async_connect_future_error_returns_false(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""Test que la future de connect() résolue avec une exception retourne False.
Le message d'erreur doit être expurgé et ne pas contenir les secrets.
:param caplog: Fixture pytest pour capturer les logs.
"""
sentinel_password = "SECRET_PASSWORD_XMPP_CONNECT_FUT" # pragma: allowlist secret
settings = XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr(sentinel_password),
host="xmpp.example.com",
port=5222,
to="parent@example.com",
resource="pronote-sync",
tls_mode="starttls",
timeout=30,
)
class ConnectFutureErrorClient(FakeClientXMPP):
def connect(
self, host: str | None = None, port: int | None = None
) -> asyncio.Future[bool]:
loop = asyncio.get_event_loop()
future: asyncio.Future[bool] = loop.create_future()
future.set_exception(ConnectionError("Network unreachable"))
return future
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=ConnectFutureErrorClient):
channel = XmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
result = await channel.send_async(msg)
assert result is False
assert "Échec de connexion XMPP" in caplog.text
assert sentinel_password not in caplog.text
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_send_async_session_failure_after_connect_returns_false(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""Test qu'un échec de session après connexion résolue retourne False.
Le connect future est résolu avec succès, puis l'événement
``failed_auth`` arrive pendant l'attente de session.
:param caplog: Fixture pytest pour capturer les logs.
"""
settings = XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr("secret123"),
host="xmpp.example.com",
port=5222,
to="parent@example.com",
resource="pronote-sync",
tls_mode="starttls",
timeout=5,
cleanup_timeout=0.01,
)
class LateAuthFailClient(FakeClientXMPP):
def connect(
self, host: str | None = None, port: int | None = None
) -> asyncio.Future[bool]:
loop = asyncio.get_event_loop()
future: asyncio.Future[bool] = loop.create_future()
self.connected = True
self._host_used = host
self._port_used = port
loop.call_later(0.01, lambda: self._fire("failed_auth"))
future.set_result(True)
return future
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=LateAuthFailClient):
channel = XmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
result = await channel.send_async(msg)
assert result is False
assert "authentification" in caplog.text.lower()
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_send_async_cancelled_returns_false(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""Test que l'annulation de la tâche retourne False sans lever.
Le contrat du canal impose un retour booléen : ``CancelledError`` doit
être interceptée et convertie en ``False``.
:param caplog: Fixture pytest pour capturer les logs.
"""
import logging
caplog.set_level(logging.DEBUG)
settings = XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr("secret123"),
host="xmpp.example.com",
port=5222,
to="parent@example.com",
resource="pronote-sync",
tls_mode="starttls",
connect_timeout=15,
timeout=30,
cleanup_timeout=0.01,
)
class PendingConnectClient(FakeClientXMPP):
def __init__(self, jid: str, password: str) -> None:
super().__init__(jid, password)
self._connect_mode = "pending"
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=PendingConnectClient):
channel = XmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
task = asyncio.ensure_future(channel.send_async(msg))
await asyncio.sleep(0.001)
task.cancel()
result = await asyncio.wait_for(task, timeout=1.0)
assert result is False
assert "annulé" in caplog.text.lower()
class TestSecretValues:
"""Tests pour la fonction _secret_values."""
def test_secret_values_with_all_secrets(self) -> None:
"""Test que _secret_values retourne tous les secrets.
:return: None
"""
from pronote_sync.channels.xmpp import _secret_values
settings = XmppSettings(
jid="bot@example.com",
password=SecretStr("secret123"),
to="parent@example.com",
)
secrets = _secret_values(settings)
assert len(secrets) == 3
assert "bot@example.com" in secrets
assert settings.password in secrets
assert "parent@example.com" in secrets
def test_secret_values_with_none_values(self) -> None:
"""Test que _secret_values filtre les valeurs None.
:return: None
"""
from pronote_sync.channels.xmpp import _secret_values
settings = XmppSettings(
jid=None,
password=None,
to=None,
)
secrets = _secret_values(settings)
assert len(secrets) == 0
def test_secret_values_with_some_none(self) -> None:
"""Test que _secret_values gère les valeurs partiellement None.
:return: None
"""
from pronote_sync.channels.xmpp import _secret_values
settings = XmppSettings(
jid="bot@example.com",
password=None,
to="parent@example.com",
)
secrets = _secret_values(settings)
assert len(secrets) == 2
assert "bot@example.com" in secrets
assert "parent@example.com" in secrets
class TestSyncXmppChannel:
"""Tests unitaires pour la classe SyncXmppChannel."""
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
def test_sync_send_dry_run_returns_true(self) -> None:
"""Test que SyncXmppChannel en dry_run retourne True.
:return: None
"""
settings = 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,
)
channel = SyncXmppChannel(settings, dry_run=True)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
result = channel.send(msg)
assert result is True
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
def test_sync_send_success_returns_true(self) -> None:
"""Test que SyncXmppChannel.send retourne True en cas de succès.
:return: None
"""
settings = 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,
)
channel = SyncXmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
result = channel.send(msg)
assert result is True
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
def test_sync_send_exception_returns_false(self) -> None:
"""Test que SyncXmppChannel.send retourne False en cas d'exception.
:return: None
"""
settings = 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,
)
class ErrorClient(FakeClientXMPP):
def connect(
self, host: str | None = None, port: int | None = None
) -> asyncio.Future[bool]:
raise RuntimeError("Connection failed")
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=ErrorClient):
channel = SyncXmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
result = channel.send(msg)
assert result is False
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
def test_sync_send_asyncio_run_error_returns_false(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""Test que send retourne False si asyncio.run lève une exception.
:param caplog: Fixture pytest pour capturer les logs.
"""
settings = 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,
)
channel = SyncXmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
with patch(
"pronote_sync.channels.xmpp.asyncio.run",
side_effect=RuntimeError("Boucle événementielle indisponible"),
):
result = channel.send(msg)
assert result is False
assert "erreur" in caplog.text.lower()
class TestXmppChannelSecurity:
"""Tests de sécurité pour XmppChannel (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.
"""
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_no_jid_in_logs_on_error(self, caplog: pytest.LogCaptureFixture) -> None:
"""Vérifie que le JID n'apparaît pas dans les logs en cas d'erreur.
:param caplog: Fixture pytest pour capturer les logs.
"""
settings = XmppSettings(
enabled=True,
jid=BOT_SENTINEL_JID,
password=SecretStr("ignored"), # pragma: allowlist secret
host="xmpp.example.com",
port=5222,
to="ignored",
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 = XmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
try:
await channel.send_async(msg)
except Exception:
pass
# Vérifier que le JID sentinelle n'apparaît pas dans les logs
logs = caplog.text
assert BOT_SENTINEL_JID not in logs
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_no_password_in_logs_on_error(self, caplog: pytest.LogCaptureFixture) -> None:
"""Vérifie que le mot de passe n'apparaît pas dans les logs en cas d'erreur.
:param caplog: Fixture pytest pour capturer les logs.
"""
settings = XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr(PASS_SENTINEL_123),
host="xmpp.example.com",
port=5222,
to="ignored",
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("Authentification échouée")
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=ErrorClient):
channel = XmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
try:
await channel.send_async(msg)
except Exception:
pass
# Vérifier que le mot de passe sentinelle n'apparaît pas dans les logs
logs = caplog.text
assert PASS_SENTINEL_123 not in logs
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_no_recipient_in_logs_on_error(self, caplog: pytest.LogCaptureFixture) -> None:
"""Vérifie que le destinataire n'apparaît pas dans les logs en cas d'erreur.
:param caplog: Fixture pytest pour capturer les logs.
"""
settings = XmppSettings(
enabled=True,
jid="bot@example.com",
password=SecretStr("ignored"),
host="xmpp.example.com",
port=5222,
to=RECIPIENT_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("Envoi impossible")
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=ErrorClient):
channel = XmppChannel(settings, dry_run=False)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
try:
await channel.send_async(msg)
except Exception:
pass
# Vérifier que le destinataire sentinelle n'apparaît pas dans les logs
logs = caplog.text
assert RECIPIENT_SENTINEL not in logs
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
@pytest.mark.asyncio
async def test_dry_run_no_secret_in_log(self, caplog: pytest.LogCaptureFixture) -> None:
"""Vérifie que dry-run n'expose pas de secrets dans les logs.
:param caplog: Fixture pytest pour capturer les logs.
"""
settings = XmppSettings(
enabled=True,
jid=BOT_SENTINEL_JID,
password=SecretStr(PASS_SENTINEL_123),
host="xmpp.example.com",
port=5222,
to=RECIPIENT_SENTINEL,
resource="pronote-sync",
use_tls=True,
timeout=30,
)
channel = XmppChannel(settings, dry_run=True)
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
await channel.send_async(msg)
# 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