Integration tests validating the 3 M10 acceptance criteria end-to-end with mocked slixmpp: 1. XmppChannel.send sends formatted direct message 2. XMPP error → PipelineWarning, no unhandled exception 3. No secret in XMPP logs (sentinel-based verification) Additional unit tests covering daemon-thread branch of SyncXmppChannel, deferred failed_auth, session timeout, disconnected handler, and _format_message edge cases. Channels coverage: 99.18%. All M10 checklist items marked complete in TODO.md. Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid> Co-authored-by: opencode/coder <coder@agents.invalid>
813 lines
32 KiB
Python
813 lines
32 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 Awaitable, Callable
|
|
from datetime import date, datetime, time
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from pydantic import SecretStr
|
|
|
|
from pronote_sync.channels.xmpp import XmppChannel, XmppMessage
|
|
from pronote_sync.config.settings import XmppSettings
|
|
from pronote_sync.errors import PipelineWarning
|
|
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"
|
|
|
|
|
|
@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 MockClientXMPP:
|
|
"""Mock de slixmpp.ClientXMPP pour tests unitaires.
|
|
|
|
Ce mock simule le comportement minimal de ClientXMPP nécessaire pour
|
|
tester XmppChannel sans dépendre du réseau ou d'un serveur XMPP réel.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
jid: str,
|
|
password: str,
|
|
*args: object,
|
|
**kwargs: object,
|
|
) -> None:
|
|
"""Initialise le mock ClientXMPP.
|
|
|
|
:param jid: JID du compte bot.
|
|
:param password: Mot de passe du compte bot.
|
|
:param args: Arguments positionnels supplémentaires (ignoré).
|
|
:param kwargs: Arguments nommés supplémentaires (ignoré).
|
|
"""
|
|
self.jid = jid
|
|
self.password = password
|
|
self.connected = False
|
|
self.disconnected = False
|
|
self.features: set[str] = set()
|
|
self.handlers: dict[str, list[Callable[..., Awaitable[None]]]] = {
|
|
"session_start": [],
|
|
"failed_auth": [],
|
|
"disconnected": [],
|
|
}
|
|
|
|
async def connect(
|
|
self,
|
|
*args: object,
|
|
**kwargs: object,
|
|
) -> None:
|
|
"""Simule la connexion au serveur XMPP.
|
|
|
|
Déclenche les handlers appropriés selon le scénario de test.
|
|
|
|
:param args: Arguments supplémentaires (ignoré).
|
|
:param kwargs: Arguments nommés supplémentaires (ignoré).
|
|
"""
|
|
self.connected = True
|
|
# Simuler STARTTLS si demandé
|
|
if kwargs.get("use_tls", False):
|
|
self.features.add("starttls")
|
|
# Déclencher session_start par défaut
|
|
for handler in self.handlers["session_start"]:
|
|
await handler()
|
|
|
|
async def disconnect(self) -> None:
|
|
"""Simule la déconnexion du serveur XMPP.
|
|
|
|
:raises: AssertionError si déjà déconnecté.
|
|
"""
|
|
if self.disconnected:
|
|
msg = "Client already disconnected"
|
|
raise AssertionError(msg)
|
|
self.disconnected = True
|
|
|
|
def send_message(self, *args: object, **kwargs: object) -> None:
|
|
"""Simule l'envoi d'un message.
|
|
|
|
:param args: Arguments supplémentaires (ignoré).
|
|
:param kwargs: Arguments nommés supplémentaires (ignoré).
|
|
"""
|
|
# Pas d'implémentation réelle nécessaire pour les tests
|
|
|
|
def add_event_handler(self, event: str, handler: Callable[..., Awaitable[None]]) -> None:
|
|
"""Enregistre un gestionnaire d'événement.
|
|
|
|
:param event: Nom de l'événement (ex: 'session_start').
|
|
:param handler: Fonction gestionnaire.
|
|
:raises: AssertionError si l'événement n'est pas supporté.
|
|
"""
|
|
if event not in self.handlers:
|
|
msg = f"Unsupported event: {event}"
|
|
raise AssertionError(msg)
|
|
self.handlers[event].append(handler)
|
|
|
|
|
|
@pytest.mark.usefixtures("xmpp_settings")
|
|
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_with_synthesis(self, xmpp_message_full: XmppMessage) -> None:
|
|
"""Test que la synthèse est bien formatée avec l'emoji dédié.
|
|
|
|
: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
|
|
|
|
def test_format_message_without_synthesis(self, xmpp_message_minimal: XmppMessage) -> None:
|
|
"""Test que l'absence de synthèse affiche le texte par défaut.
|
|
|
|
:param xmpp_message_minimal: Message XMPP minimal.
|
|
"""
|
|
channel = XmppChannel(XmppSettings(), dry_run=True)
|
|
formatted = channel._format_message(xmpp_message_minimal)
|
|
assert "📌 Synthèse" in formatted
|
|
assert "Aucune synthèse disponible." in formatted
|
|
|
|
def test_format_message_with_homeworks(self, xmpp_message_full: XmppMessage) -> None:
|
|
"""Test que les devoirs sont bien formatés avec l'emoji dédié.
|
|
|
|
:param xmpp_message_full: Message XMPP complet.
|
|
"""
|
|
channel = XmppChannel(XmppSettings(), dry_run=True)
|
|
formatted = channel._format_message(xmpp_message_full)
|
|
assert "📚 Devoirs" in formatted
|
|
assert "Mathématiques" in formatted
|
|
assert "Faire l'exercice 5 page 42" in formatted
|
|
|
|
def test_format_message_without_homeworks(self, xmpp_message_minimal: XmppMessage) -> None:
|
|
"""Test que l'absence de devoirs affiche le texte par défaut.
|
|
|
|
:param xmpp_message_minimal: Message XMPP minimal.
|
|
"""
|
|
channel = XmppChannel(XmppSettings(), dry_run=True)
|
|
formatted = channel._format_message(xmpp_message_minimal)
|
|
assert "📚 Devoirs" in formatted
|
|
assert "Aucun devoir." in formatted
|
|
|
|
def test_format_message_with_changes(self, xmpp_message_full: XmppMessage) -> None:
|
|
"""Test que les changements d'agenda sont bien formatés.
|
|
|
|
:param xmpp_message_full: Message XMPP complet.
|
|
"""
|
|
channel = XmppChannel(XmppSettings(), dry_run=True)
|
|
formatted = channel._format_message(xmpp_message_full)
|
|
assert "📅 Changements d'agenda" in formatted
|
|
assert "Cours déplacé" in formatted
|
|
|
|
def test_format_message_with_messages(self, xmpp_message_full: XmppMessage) -> None:
|
|
"""Test que les messages sont bien formatés.
|
|
|
|
:param xmpp_message_full: Message XMPP complet.
|
|
"""
|
|
channel = XmppChannel(XmppSettings(), dry_run=True)
|
|
formatted = channel._format_message(xmpp_message_full)
|
|
assert "💬 Messages" in formatted
|
|
|
|
def test_format_message_with_external_info(self, xmpp_message_full: XmppMessage) -> None:
|
|
"""Test que les informations externes sont bien formatées.
|
|
|
|
:param xmpp_message_full: Message XMPP complet.
|
|
"""
|
|
channel = XmppChannel(XmppSettings(), dry_run=True)
|
|
formatted = channel._format_message(xmpp_message_full)
|
|
assert "📢 Informations diverses" in formatted
|
|
assert "Sortie prévue le 20/09." in formatted
|
|
|
|
def test_format_message_empty(self) -> None:
|
|
"""Test que tous les sections vides affichent le texte par défaut."""
|
|
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
|
|
channel = XmppChannel(XmppSettings(), dry_run=True)
|
|
formatted = channel._format_message(msg)
|
|
assert "📌 Synthèse\nAucune synthèse disponible." in formatted
|
|
assert "📚 Devoirs\nAucun devoir." in formatted
|
|
assert "📅 Changements d'agenda\nAucun changement." in formatted
|
|
assert "💬 Messages\nAucun message." in formatted
|
|
assert "📢 Informations diverses\nAucune information." in formatted
|
|
|
|
def test_format_message_with_theoretical_lesson(self) -> None:
|
|
"""Test que la matière d'un changement REMOVED provient du cours théorique.
|
|
|
|
Couvre le cas où ``lesson`` est absent et où le sujet est extrait de
|
|
``theoretical_lesson`` dans ``_format_changes``.
|
|
"""
|
|
theoretical = TheoreticalLesson(
|
|
id="th1",
|
|
day_of_week=0,
|
|
start_time=time(8, 0),
|
|
end_time=time(9, 0),
|
|
subject="Anglais",
|
|
)
|
|
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 "Anglais" in formatted
|
|
assert "Cours annulé" in formatted
|
|
|
|
def test_format_message_with_message_content(self) -> None:
|
|
"""Test que les messages Pronote sont formatés avec titre et contenu.
|
|
|
|
Couvre la branche non vide de ``_format_messages``.
|
|
"""
|
|
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,
|
|
)
|
|
msg = XmppMessage(
|
|
target_date=date(2025, 9, 7),
|
|
synthesis=None,
|
|
messages=(message,),
|
|
external_info=None,
|
|
)
|
|
channel = XmppChannel(XmppSettings(), dry_run=True)
|
|
formatted = channel._format_message(msg)
|
|
assert "Réunion parents-professeurs" in formatted
|
|
assert "Une réunion est organisée le 15/09 à 18h." in formatted
|
|
|
|
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
|
|
|
|
|
|
@pytest.mark.usefixtures("xmpp_settings")
|
|
class TestXmppChannelSend:
|
|
"""Tests unitaires pour la méthode send de XmppChannel.
|
|
|
|
Ces tests vérifient le comportement de l'envoi de messages XMPP,
|
|
avec mock de slixmpp.ClientXMPP.
|
|
"""
|
|
|
|
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=MockClientXMPP)
|
|
def test_send_dry_run_returns_true(self, xmpp_settings: XmppSettings) -> None:
|
|
"""Test que dry_run=True retourne True sans appeler slixmpp.
|
|
|
|
:param xmpp_settings: Paramètres XMPP valides.
|
|
"""
|
|
channel = XmppChannel(xmpp_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=MockClientXMPP)
|
|
def test_send_success_returns_true(self, xmpp_settings: XmppSettings) -> None:
|
|
"""Test que send retourne True en cas de succès de connexion.
|
|
|
|
:param xmpp_settings: Paramètres XMPP valides.
|
|
"""
|
|
channel = XmppChannel(xmpp_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=MockClientXMPP)
|
|
def test_send_failed_auth_raises_pipeline_warning(self, xmpp_settings: XmppSettings) -> None:
|
|
"""Vérifie qu'un échec d'authentification lève un PipelineWarning.
|
|
|
|
:param xmpp_settings: Paramètres XMPP valides.
|
|
"""
|
|
|
|
# Créer un mock qui déclenche failed_auth
|
|
class FailedAuthClient(MockClientXMPP):
|
|
async def connect(self, *args: object, **kwargs: object) -> None:
|
|
self.connected = True
|
|
# Déclencher failed_auth au lieu de session_start
|
|
for handler in self.handlers["failed_auth"]:
|
|
await handler()
|
|
|
|
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=FailedAuthClient):
|
|
channel = XmppChannel(xmpp_settings, dry_run=False)
|
|
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
|
|
with pytest.raises(PipelineWarning):
|
|
channel.send(msg)
|
|
|
|
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=MockClientXMPP)
|
|
def test_send_connection_timeout_returns_false(self, xmpp_settings: XmppSettings) -> None:
|
|
"""Test que send retourne False en cas de timeout de connexion.
|
|
|
|
:param xmpp_settings: Paramètres XMPP valides.
|
|
"""
|
|
|
|
# Créer un mock qui bloque indéfiniment : méthode non-asynchrone pour
|
|
# éviter de créer une coroutine jamais attendue (RuntimeWarning),
|
|
# ``asyncio.wait_for`` étant patché pour lever immédiatement.
|
|
class TimeoutClient(MockClientXMPP):
|
|
def connect(self, *args: object, **kwargs: object) -> None: # type: ignore[override]
|
|
# Ne jamais terminer la connexion -> timeout
|
|
return None
|
|
|
|
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=TimeoutClient):
|
|
channel = XmppChannel(xmpp_settings, dry_run=False)
|
|
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
|
|
# Utiliser un timeout court pour le test
|
|
with patch("asyncio.wait_for", side_effect=asyncio.TimeoutError):
|
|
result = channel.send(msg)
|
|
assert result is False
|
|
|
|
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=MockClientXMPP)
|
|
def test_send_starttls_not_available(self, xmpp_settings: XmppSettings) -> None:
|
|
"""Test que send retourne False si STARTTLS non disponible avec use_tls=True.
|
|
|
|
:param xmpp_settings: Paramètres XMPP valides.
|
|
"""
|
|
|
|
class NoTLSClient(MockClientXMPP):
|
|
async def connect(self, *args: object, **kwargs: object) -> None:
|
|
self.connected = True
|
|
# Ne pas ajouter 'starttls' aux features
|
|
for handler in self.handlers["session_start"]:
|
|
await handler()
|
|
|
|
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=NoTLSClient):
|
|
# Force use_tls=True
|
|
settings = xmpp_settings.model_copy(update={"use_tls": True})
|
|
channel = XmppChannel(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
|
|
|
|
def test_send_incomplete_config_raises_pipeline_warning(self) -> None:
|
|
"""Test qu'une configuration XMPP incomplète lève un PipelineWarning.
|
|
|
|
Vérifie que le canal refuse l'envoi (sans tentative réseau) lorsque
|
|
le JID, le mot de passe ou le destinataire sont absents.
|
|
"""
|
|
settings = XmppSettings(
|
|
enabled=True,
|
|
jid=None,
|
|
password=None,
|
|
to=None,
|
|
host="xmpp.example.com",
|
|
)
|
|
channel = XmppChannel(settings, dry_run=False)
|
|
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
|
|
with pytest.raises(PipelineWarning):
|
|
channel.send(msg)
|
|
|
|
def test_dry_run_without_secrets_logs_safely(self) -> None:
|
|
"""Test que le dry-run fonctionne sans champs sensibles renseignés.
|
|
|
|
Vérifie que ``_secret_values`` gère des champs ``jid``, ``password``
|
|
et ``to`` absents (branches ``None`` de la collecte des secrets).
|
|
"""
|
|
settings = XmppSettings(enabled=True, host="xmpp.example.com")
|
|
channel = XmppChannel(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
|
|
|
|
def test_send_disconnected_event_runs_handler(self, xmpp_settings: XmppSettings) -> None:
|
|
"""Test que le handler ``disconnected`` est bien exécuté lors de l'envoi.
|
|
|
|
:param xmpp_settings: Paramètres XMPP valides.
|
|
"""
|
|
|
|
class DisconnectClient(MockClientXMPP):
|
|
async def connect(self, *args: object, **kwargs: object) -> None:
|
|
self.connected = True
|
|
if kwargs.get("use_tls", False):
|
|
self.features.add("starttls")
|
|
for handler in self.handlers["disconnected"]:
|
|
await handler()
|
|
for handler in self.handlers["session_start"]:
|
|
await handler()
|
|
|
|
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=DisconnectClient):
|
|
channel = XmppChannel(xmpp_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
|
|
|
|
def test_send_failed_auth_after_connect_raises_pipeline_warning(
|
|
self, xmpp_settings: XmppSettings
|
|
) -> None:
|
|
"""Test qu'un échec d'authentification après connect() lève un PipelineWarning.
|
|
|
|
Le client établit la session puis l'événement ``failed_auth`` survient
|
|
avant la fin de l'attente de session : le canal doit se déconnecter
|
|
proprement et lever un PipelineWarning.
|
|
|
|
:param xmpp_settings: Paramètres XMPP valides.
|
|
"""
|
|
client_ref: list[MockClientXMPP] = []
|
|
|
|
def _factory(jid: str, password: str, *args: object, **kwargs: object) -> MockClientXMPP:
|
|
instance = MockClientXMPP(jid, password, *args, **kwargs)
|
|
client_ref.append(instance)
|
|
return instance
|
|
|
|
async def _wait_with_failed_auth(
|
|
fs: list[asyncio.Task[bool]],
|
|
*,
|
|
timeout: object = None,
|
|
return_when: object = asyncio.FIRST_COMPLETED,
|
|
) -> tuple[set[asyncio.Task[bool]], set[asyncio.Task[bool]]]:
|
|
# Déclenche failed_auth, puis nettoie les tâches de send_async.
|
|
for handler in client_ref[0].handlers["failed_auth"]:
|
|
await handler()
|
|
for task in fs:
|
|
task.cancel()
|
|
await asyncio.gather(*fs, return_exceptions=True)
|
|
return (set(), set())
|
|
|
|
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=_factory):
|
|
channel = XmppChannel(xmpp_settings, dry_run=False)
|
|
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
|
|
with patch("asyncio.wait", new=_wait_with_failed_auth):
|
|
with pytest.raises(PipelineWarning):
|
|
channel.send(msg)
|
|
|
|
def test_send_session_timeout_raises_pipeline_warning(
|
|
self, xmpp_settings: XmppSettings
|
|
) -> None:
|
|
"""Test qu'un délai d'attente de session dépassé lève un PipelineWarning.
|
|
|
|
Aucun événement n'établit la session : l'attente bornée expire et le
|
|
canal se déconnecte proprement avant de lever un PipelineWarning.
|
|
|
|
:param xmpp_settings: Paramètres XMPP valides.
|
|
"""
|
|
|
|
class NoEventClient(MockClientXMPP):
|
|
async def connect(self, *args: object, **kwargs: object) -> None:
|
|
self.connected = True
|
|
if kwargs.get("use_tls", False):
|
|
self.features.add("starttls")
|
|
|
|
async def _empty_wait(
|
|
fs: list[asyncio.Task[bool]],
|
|
*,
|
|
timeout: object = None,
|
|
return_when: object = asyncio.FIRST_COMPLETED,
|
|
) -> tuple[set[asyncio.Task[bool]], set[asyncio.Task[bool]]]:
|
|
# Aucun événement ne survient : on retourne une attente vide.
|
|
for task in fs:
|
|
task.cancel()
|
|
await asyncio.gather(*fs, return_exceptions=True)
|
|
return (set(), set())
|
|
|
|
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=NoEventClient):
|
|
channel = XmppChannel(xmpp_settings, dry_run=False)
|
|
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
|
|
with patch("asyncio.wait", new=_empty_wait):
|
|
with pytest.raises(PipelineWarning):
|
|
channel.send(msg)
|
|
|
|
|
|
@pytest.mark.usefixtures("xmpp_settings")
|
|
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=MockClientXMPP)
|
|
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(MockClientXMPP):
|
|
async def connect(self, *args: object, **kwargs: object) -> None:
|
|
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:
|
|
channel.send(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=MockClientXMPP)
|
|
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(MockClientXMPP):
|
|
async def connect(self, *args: object, **kwargs: object) -> None:
|
|
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:
|
|
channel.send(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=MockClientXMPP)
|
|
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(MockClientXMPP):
|
|
async def connect(self, *args: object, **kwargs: object) -> None:
|
|
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:
|
|
channel.send(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=MockClientXMPP)
|
|
def test_no_secret_in_exception_cause(self) -> None:
|
|
"""Vérifie que les secrets ne sont pas présents dans __cause__ d'une PipelineWarning."""
|
|
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,
|
|
)
|
|
|
|
class AuthErrorClient(MockClientXMPP):
|
|
async def connect(self, *args: object, **kwargs: object) -> None:
|
|
self.connected = True
|
|
for handler in self.handlers["failed_auth"]:
|
|
await handler()
|
|
|
|
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=AuthErrorClient):
|
|
channel = XmppChannel(settings, dry_run=False)
|
|
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
|
|
with pytest.raises(PipelineWarning) as exc_info:
|
|
channel.send(msg)
|
|
|
|
# Vérifier que l'exception levée est bien une PipelineWarning
|
|
assert isinstance(exc_info.value, PipelineWarning)
|
|
# Vérifier que les secrets ne sont pas dans __cause__
|
|
cause_str = str(exc_info.value.__cause__) if exc_info.value.__cause__ else ""
|
|
assert BOT_SENTINEL_JID not in cause_str
|
|
assert PASS_SENTINEL_123 not in cause_str
|
|
assert RECIPIENT_SENTINEL not in cause_str
|
|
|
|
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=MockClientXMPP)
|
|
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)
|
|
channel.send(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
|