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>
1643 lines
62 KiB
Python
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
|