fix(M10): apply FIXME_M10 corrections (transport, dry_run, format, security)
Fix all 8 findings from the independent review (FIXME_M10.md): #1 Transport compatible with slixmpp 1.17.0 (D5): - Use real ClientXMPP type (remove Any), JID with resource - connect(host, port) explicit, no use_tls kwarg - enable_direct_tls/enable_starttls configured before connect - Single timeout via asyncio.Future for session_start/failed_auth/disconnected - Remove premature 'starttls' in features check, remove auto_reconnect - try/finally guarantees disconnect on all paths (#4) #2 Factory dry_run no longer bypassed (D6): - Single send() entry point in SyncXmppChannel - dry_run check before any ClientXMPP creation - Remove XmppChannel.send() dual implementation #3 Thread daemon removed — single asyncio.run(), documented limitation #5 Richer message format: - Target date header, change type [Ajouté/Supprimé/Modifié] - Lesson times, homework due date, message author - No pronote_messages duplication (external_info = blog + other_info only) #6 Error contract unified (D6): - Channel.send() -> bool never raises PipelineWarning - Errors logged with redaction, returns False - PipelineWarning(step='xmpp') will be created by pipeline M11 #7 Tests faithful to slixmpp 1.17.0 API: - FakeClientXMPP with real connect(host,port)/disconnect() signatures - Assertions on host, port, resource, mtype='chat' - No RuntimeWarning from unawaited coroutines #8 .secrets.baseline restored from main Coverage: 96.44% on channels/, 600 tests pass, pre-commit all-files green. Co-authored-by: opencode/coder <coder@agents.invalid> Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
This commit is contained in:
@@ -133,6 +133,49 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"results": {},
|
||||
"generated_at": "2026-09-07T21:10:53Z"
|
||||
"results": {
|
||||
"GUIDE_DEV_PYTHON.md": [
|
||||
{
|
||||
"type": "Hex High Entropy String",
|
||||
"filename": "GUIDE_DEV_PYTHON.md",
|
||||
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
|
||||
"is_verified": true,
|
||||
"line_number": 4935,
|
||||
"is_secret": false
|
||||
}
|
||||
],
|
||||
"tests/unit/test_caldav_gateway.py": [
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "tests/unit/test_caldav_gateway.py",
|
||||
"hashed_secret": "1c58bd92003bbaa0538e249fff6ee19a270dec5f",
|
||||
"is_verified": false,
|
||||
"line_number": 152
|
||||
},
|
||||
{
|
||||
"type": "Basic Auth Credentials",
|
||||
"filename": "tests/unit/test_caldav_gateway.py",
|
||||
"hashed_secret": "1c58bd92003bbaa0538e249fff6ee19a270dec5f",
|
||||
"is_verified": false,
|
||||
"line_number": 763
|
||||
}
|
||||
],
|
||||
"tests/unit/test_caldav_security.py": [
|
||||
{
|
||||
"type": "Basic Auth Credentials",
|
||||
"filename": "tests/unit/test_caldav_security.py",
|
||||
"hashed_secret": "8e1f07a2939b6324c70f48a3e7f64b463a4a3f8b",
|
||||
"is_verified": false,
|
||||
"line_number": 27
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "tests/unit/test_caldav_security.py",
|
||||
"hashed_secret": "6b554cd7b7e0115065fb4907307a74f1902154d4",
|
||||
"is_verified": false,
|
||||
"line_number": 28
|
||||
}
|
||||
]
|
||||
},
|
||||
"generated_at": "2026-09-07T17:59:08Z"
|
||||
}
|
||||
|
||||
@@ -20,6 +20,12 @@ class Channel(Protocol):
|
||||
def send(self, message: XmppMessage) -> bool:
|
||||
"""Envoie un message sur le canal.
|
||||
|
||||
Un canal ne lève jamais :pyexc:`PipelineWarning` ; en cas d'échec, il
|
||||
retourne ``False``. Le :pyexc:`PipelineWarning` est créé par l'étape
|
||||
pipeline, pas par le canal. Une :pyexc:`PipelineCriticalError` peut
|
||||
en revanche être levée en cas de panne critique (ex. : chemin
|
||||
CalDAV, non utilisé par le canal XMPP).
|
||||
|
||||
:param message: Message final à transmettre.
|
||||
:return: ``True`` si l'envoi a réussi, ``False`` sinon.
|
||||
:rtype: bool
|
||||
|
||||
@@ -1,27 +1,30 @@
|
||||
"""Canal de sortie XMPP du pipeline ``pronote-sync``.
|
||||
|
||||
Ce module implémente le canal d'envoi de notifications XMPP : la classe
|
||||
:class:`XmppChannel` (U4) envoie un message direct via ``slixmpp``, tandis que
|
||||
:class:`SyncXmppChannel` (U5) fournit l'adaptateur synchrone utilisé par le
|
||||
pipeline. Le corps du message est formaté en texte brut par ``_format_message``
|
||||
(sections emoji 📌📅📚💬📢) et chaque texte est assaini par
|
||||
:class:`XmppChannel` envoie un message direct via ``slixmpp``
|
||||
(:meth:`XmppChannel.send_async`), tandis que :class:`SyncXmppChannel`
|
||||
fournit le point d'entrée synchrone unique utilisé par le pipeline. Le corps
|
||||
du message est formaté en texte brut par ``_format_message`` (en-tête de date
|
||||
cible puis sections emoji 📌📅📚💬📢) et chaque texte est assaini par
|
||||
:func:`pronote_sync.utils.text.sanitize_plaintext` (SEC-XMPP-06).
|
||||
|
||||
Contrat d'erreur (D6) : le canal ne lève jamais :pyexc:`PipelineWarning` ;
|
||||
en cas d'échec, il journalise la version expurgée de l'erreur et retourne
|
||||
``False``. Le :pyexc:`PipelineWarning` est créé par l'étape pipeline, pas par
|
||||
le canal.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from pydantic import SecretStr
|
||||
from slixmpp import JID, ClientXMPP
|
||||
|
||||
from pronote_sync.config.settings import XmppSettings
|
||||
from pronote_sync.errors import PipelineWarning
|
||||
from pronote_sync.models.blog import ExternalInfo
|
||||
from pronote_sync.models.diff import AgendaChange
|
||||
from pronote_sync.models.diff import AgendaChange, AgendaChangeType
|
||||
from pronote_sync.models.homework import Homework
|
||||
from pronote_sync.models.message import Message
|
||||
from pronote_sync.models.xmpp import XmppMessage
|
||||
@@ -65,9 +68,13 @@ def _format_synthesis(synthesis: str | None) -> str:
|
||||
def _format_changes(changes: tuple[AgendaChange, ...]) -> str:
|
||||
"""Formate la section des changements d'agenda du message XMPP.
|
||||
|
||||
Distingue les ajouts, suppressions et modifications (U4). Pour un ajout,
|
||||
les horaires du cours (``HH:MM-HH:MM``) sont inclus si le cours est
|
||||
disponible.
|
||||
|
||||
:param changes: Liste des changements d'agenda.
|
||||
:return: Section ``📅 Changements d'agenda`` avec une ligne par
|
||||
changement (matière et détails).
|
||||
changement (type, matière et détails).
|
||||
:rtype: str
|
||||
"""
|
||||
if not changes:
|
||||
@@ -80,7 +87,15 @@ def _format_changes(changes: tuple[AgendaChange, ...]) -> str:
|
||||
subject = change.lesson.subject
|
||||
elif change.theoretical_lesson is not None:
|
||||
subject = change.theoretical_lesson.subject
|
||||
lines.append(f"• {subject} : {change.details}")
|
||||
if change.type == AgendaChangeType.ADDED and change.lesson is not None:
|
||||
times = (
|
||||
f"{change.lesson.start.strftime('%H:%M')}-{change.lesson.end.strftime('%H:%M')}"
|
||||
)
|
||||
lines.append(f"• [Ajouté] {subject}: {change.details} ({times})")
|
||||
elif change.type == AgendaChangeType.REMOVED:
|
||||
lines.append(f"• [Supprimé] {subject}: {change.details}")
|
||||
else:
|
||||
lines.append(f"• [Modifié] {subject}: {change.details}")
|
||||
body = "\n".join(lines)
|
||||
return f"📅 Changements d'agenda\n{sanitize_plaintext(body)}"
|
||||
|
||||
@@ -89,14 +104,18 @@ def _format_homeworks(homeworks: tuple[Homework, ...]) -> str:
|
||||
"""Formate la section des devoirs du message XMPP.
|
||||
|
||||
:param homeworks: Liste des devoirs.
|
||||
:return: Section ``📚 Devoirs`` avec une ligne par devoir (matière et
|
||||
texte).
|
||||
:return: Section ``📚 Devoirs`` avec une ligne par devoir (matière,
|
||||
texte et date d'échéance).
|
||||
:rtype: str
|
||||
"""
|
||||
if not homeworks:
|
||||
body = "Aucun devoir."
|
||||
else:
|
||||
lines = [f"• {homework.subject} : {homework.text}" for homework in homeworks]
|
||||
lines = [
|
||||
f"• {homework.subject}: {homework.text} "
|
||||
f"(à rendre le {homework.due_on.strftime('%d/%m')})"
|
||||
for homework in homeworks
|
||||
]
|
||||
body = "\n".join(lines)
|
||||
return f"📚 Devoirs\n{sanitize_plaintext(body)}"
|
||||
|
||||
@@ -105,14 +124,19 @@ def _format_messages(messages: tuple[Message, ...]) -> str:
|
||||
"""Formate la section des messages Pronote du message XMPP.
|
||||
|
||||
:param messages: Liste des messages/informations.
|
||||
:return: Section ``💬 Messages`` avec une ligne par message (titre et
|
||||
contenu).
|
||||
:return: Section ``💬 Messages`` avec une ligne par message (titre,
|
||||
auteur et contenu) ; sans titre, seul l'auteur est affiché.
|
||||
:rtype: str
|
||||
"""
|
||||
if not messages:
|
||||
body = "Aucun message."
|
||||
else:
|
||||
lines = [f"• {message.title} : {message.content}" for message in messages]
|
||||
lines: list[str] = []
|
||||
for message in messages:
|
||||
if message.title:
|
||||
lines.append(f"• {message.title} ({message.author}): {message.content}")
|
||||
else:
|
||||
lines.append(f"• {message.author}: {message.content}")
|
||||
body = "\n".join(lines)
|
||||
return f"💬 Messages\n{sanitize_plaintext(body)}"
|
||||
|
||||
@@ -120,8 +144,9 @@ def _format_messages(messages: tuple[Message, ...]) -> str:
|
||||
def _format_external_info(external_info: ExternalInfo | None) -> str:
|
||||
"""Formate la section des informations diverses du message XMPP.
|
||||
|
||||
Regroupe les articles du blog, les messages Pronote et les autres
|
||||
informations (``other_info``).
|
||||
Regroupe uniquement les articles du blog et les autres informations
|
||||
(``other_info``) : les messages Pronote (``pronote_messages``) sont
|
||||
exclus car ils sont déjà transmis par la section des messages.
|
||||
|
||||
:param external_info: Informations externes agrégées, ou ``None``.
|
||||
:return: Section ``📢 Informations diverses`` avec une ligne par élément.
|
||||
@@ -133,8 +158,6 @@ def _format_external_info(external_info: ExternalInfo | None) -> str:
|
||||
lines: list[str] = []
|
||||
for article in external_info.blog_articles:
|
||||
lines.append(f"• {article.title}: {article.content_text}")
|
||||
for message in external_info.pronote_messages:
|
||||
lines.append(f"• {message.title} : {message.content}")
|
||||
for info in external_info.other_info:
|
||||
lines.append(f"• {info}")
|
||||
body = "\n".join(lines) if lines else "Aucune information."
|
||||
@@ -142,11 +165,17 @@ def _format_external_info(external_info: ExternalInfo | None) -> str:
|
||||
|
||||
|
||||
class XmppChannel:
|
||||
"""Canal d'envoi de messages XMPP via un compte bot dédié (U4).
|
||||
"""Canal d'envoi de messages XMPP via un compte bot dédié.
|
||||
|
||||
Envoie un message direct (``type="chat"``) au destinataire configuré en
|
||||
utilisant :class:`slixmpp.ClientXMPP`. La connexion est établie à chaque
|
||||
appel de :meth:`send` ; le constructeur n'effectue aucun accès réseau.
|
||||
appel de :meth:`send_async` ; le constructeur n'effectue aucun accès
|
||||
réseau.
|
||||
|
||||
Contrat d'erreur (D6) : :meth:`send_async` ne lève jamais
|
||||
:pyexc:`PipelineWarning` ; en cas d'échec, elle journalise la version
|
||||
expurgée de l'erreur et retourne ``False``. En mode ``dry_run``, aucun
|
||||
client n'est créé.
|
||||
|
||||
:ivar settings: Paramètres XMPP (JID, mot de passe, destinataire, TLS).
|
||||
:vartype settings: XmppSettings
|
||||
@@ -158,8 +187,8 @@ class XmppChannel:
|
||||
"""Initialise le canal XMPP sans connexion réseau.
|
||||
|
||||
:param settings: Paramètres de configuration du canal XMPP.
|
||||
:param dry_run: Si ``True``, :meth:`send` se contente de journaliser
|
||||
le message formaté et retourne ``True`` sans se connecter.
|
||||
:param dry_run: Si ``True``, :meth:`send_async` journalise le message
|
||||
formaté et retourne ``True`` sans se connecter.
|
||||
"""
|
||||
self.settings = settings
|
||||
self.dry_run = dry_run
|
||||
@@ -167,16 +196,18 @@ class XmppChannel:
|
||||
def _format_message(self, message: XmppMessage) -> str:
|
||||
"""Formate un message XMPP en texte brut avec des sections emoji.
|
||||
|
||||
Produit le corps du message avec les sections synthèse, changements
|
||||
d'agenda, devoirs, messages et informations diverses. Chaque texte
|
||||
est assaini par :func:`pronote_sync.utils.text.sanitize_plaintext`
|
||||
avant insertion (SEC-XMPP-06).
|
||||
Produit le corps du message : un en-tête avec la date cible du
|
||||
digest, puis les sections synthèse, changements d'agenda, devoirs,
|
||||
messages et informations diverses. Chaque texte est assaini par
|
||||
:func:`pronote_sync.utils.text.sanitize_plaintext` avant insertion
|
||||
(SEC-XMPP-06).
|
||||
|
||||
:param message: Message final à formater.
|
||||
:return: Corps du message en texte brut, prêt pour l'envoi.
|
||||
:rtype: str
|
||||
"""
|
||||
sections = [
|
||||
f"Digest du {message.target_date.strftime('%d/%m/%Y')}",
|
||||
_format_synthesis(message.synthesis),
|
||||
_format_changes(message.changes),
|
||||
_format_homeworks(message.homeworks),
|
||||
@@ -185,149 +216,113 @@ class XmppChannel:
|
||||
]
|
||||
return "\n\n".join(sections)
|
||||
|
||||
def send(self, message: XmppMessage) -> bool:
|
||||
"""Envoie un message XMPP de façon synchrone.
|
||||
async def send_async(self, message: XmppMessage) -> bool:
|
||||
"""Exécute le flux asynchrone d'envoi XMPP (U2).
|
||||
|
||||
En mode ``dry_run``, le message formaté est uniquement journalisé
|
||||
(secrets masqués) et la méthode retourne ``True``. Sinon, le flux
|
||||
asynchrone :meth:`send_async` est exécuté via :func:`asyncio.run`.
|
||||
Connecte le client ``slixmpp`` avec un hôte et un port explicites,
|
||||
configure TLS avant la connexion, puis attend l'un des événements
|
||||
``session_start``, ``failed_auth`` ou ``disconnected`` sous un
|
||||
timeout unique avant d'envoyer un message direct ``chat`` au
|
||||
destinataire configuré. La déconnexion est garantie par un bloc
|
||||
``try/finally``. Aucun secret n'est journalisé (SEC-XMPP-02).
|
||||
|
||||
:param message: Message final à envoyer.
|
||||
:return: ``True`` si l'envoi a réussi (ou a été simulé en dry-run),
|
||||
``False`` en cas de timeout de connexion ou de STARTTLS absent.
|
||||
``False`` sinon (destinataire manquant, timeout, échec
|
||||
d'authentification, déconnexion ou erreur réseau).
|
||||
:rtype: bool
|
||||
:raises PipelineWarning: En cas d'erreur d'envoi (config incomplète,
|
||||
échec d'authentification, erreur réseau) ; le message d'erreur
|
||||
est expurgé et la cause d'origine est masquée (SEC-XMPP-05).
|
||||
"""
|
||||
if self.dry_run:
|
||||
formatted = self._format_message(message)
|
||||
redacted = redact_secrets(formatted, extra_secrets=_secret_values(self.settings))
|
||||
logger.info("XMPP : dry-run, message non envoyé : %s", redacted)
|
||||
logger.info("XMPP dry-run: message would be sent")
|
||||
return True
|
||||
try:
|
||||
return asyncio.run(self.send_async(message))
|
||||
except PipelineWarning:
|
||||
raise
|
||||
except TimeoutError:
|
||||
logger.warning("XMPP : timeout de connexion au serveur.")
|
||||
|
||||
# Build JID with resource
|
||||
jid_str = f"{self.settings.jid}/{self.settings.resource}"
|
||||
recipient = JID(self.settings.to) if self.settings.to else None
|
||||
if recipient is None:
|
||||
logger.warning("Destinataire XMPP manquant.")
|
||||
return False
|
||||
|
||||
# Create typed client
|
||||
client = ClientXMPP(
|
||||
jid_str,
|
||||
self.settings.password.get_secret_value() if self.settings.password else "",
|
||||
)
|
||||
|
||||
# Configure TLS BEFORE connect
|
||||
if self.settings.use_tls:
|
||||
# TLS direct (port 5223 typically)
|
||||
client.enable_direct_tls = True
|
||||
client.enable_starttls = False
|
||||
else:
|
||||
# STARTTLS (port 5222 typically)
|
||||
client.enable_starttls = True
|
||||
client.enable_direct_tls = False
|
||||
|
||||
# Register handlers
|
||||
session_future: asyncio.Future[bool] = asyncio.get_event_loop().create_future()
|
||||
|
||||
def on_session_start(event: object) -> None:
|
||||
if not session_future.done():
|
||||
session_future.set_result(True)
|
||||
|
||||
def on_failed_auth(event: object) -> None:
|
||||
if not session_future.done():
|
||||
session_future.set_result(False)
|
||||
|
||||
def on_disconnected(event: object) -> None:
|
||||
if not session_future.done():
|
||||
session_future.set_result(False)
|
||||
|
||||
client.add_event_handler("session_start", on_session_start)
|
||||
client.add_event_handler("failed_auth", on_failed_auth)
|
||||
client.add_event_handler("disconnected", on_disconnected)
|
||||
|
||||
try:
|
||||
# Connect with explicit host and port
|
||||
connect_future = client.connect(self.settings.host, self.settings.port)
|
||||
await connect_future # connect() returns a Future, not a coroutine
|
||||
|
||||
# Wait for one of the three events under a single timeout
|
||||
try:
|
||||
success = await asyncio.wait_for(session_future, timeout=self.settings.timeout)
|
||||
except TimeoutError:
|
||||
logger.warning("Délai d'attente de session XMPP dépassé.")
|
||||
return False
|
||||
|
||||
if not success:
|
||||
logger.warning("Échec d'authentification ou déconnexion XMPP.")
|
||||
return False
|
||||
|
||||
# Send the message
|
||||
formatted = self._format_message(message)
|
||||
client.send_message(mto=JID(self.settings.to), mbody=formatted, mtype="chat")
|
||||
return True
|
||||
|
||||
except Exception as exc:
|
||||
redacted = redact_exception(exc)
|
||||
redacted = redact_secrets(redacted, extra_secrets=_secret_values(self.settings))
|
||||
logger.warning("XMPP : erreur d'envoi : %s", redacted)
|
||||
warning = PipelineWarning(redacted, step="xmpp")
|
||||
# SEC-XMPP-05 : masquer la cause ET le contexte d'origine, tous deux
|
||||
# susceptibles de contenir des secrets. Le ``raise`` est placé hors
|
||||
# du bloc ``except`` : levée dans le bloc, l'interpréteur réaffecterait
|
||||
# ``__context__`` à l'exception interceptée malgré ``from None``.
|
||||
warning.__cause__ = None
|
||||
warning.__context__ = None
|
||||
raise warning
|
||||
|
||||
async def send_async(self, message: XmppMessage) -> bool:
|
||||
"""Exécute le flux asynchrone d'envoi XMPP (U4).
|
||||
|
||||
Connecte le client ``slixmpp`` avec un timeout, vérifie
|
||||
l'authentification et la disponibilité de STARTTLS (D1), puis envoie
|
||||
un message direct ``chat`` au destinataire configuré avant
|
||||
déconnexion. Aucun secret (JID, mot de passe, destinataire) n'est
|
||||
journalisé (SEC-XMPP-02).
|
||||
|
||||
:param message: Message final à envoyer.
|
||||
:return: ``True`` si l'envoi a réussi, ``False`` si STARTTLS n'a pas
|
||||
pu être négocié alors que TLS est requis.
|
||||
:rtype: bool
|
||||
:raises PipelineWarning: En cas de configuration incomplète ou
|
||||
d'échec d'authentification (SEC-XMPP-04) ; la cause d'origine est
|
||||
masquée (SEC-XMPP-05).
|
||||
"""
|
||||
settings = self.settings
|
||||
if settings.jid is None or settings.password is None or settings.to is None:
|
||||
raise PipelineWarning("Configuration XMPP incomplète.", step="xmpp") from None
|
||||
formatted = self._format_message(message)
|
||||
# ``client`` est typé ``Any`` : le contrat du canal (SEC-XMPP-04, D1)
|
||||
# repose sur des comportements slixmpp que sa signature typée
|
||||
# n'expose pas (``auto_reconnect``, argument ``use_tls`` de
|
||||
# ``connect``), et slixmpp peut être indisponible pour mypy dans
|
||||
# l'environnement pre-commit (import non résolu => ``Any``).
|
||||
client: Any = ClientXMPP(settings.jid, settings.password.get_secret_value())
|
||||
# SEC-XMPP-04 : pas de reconnexion automatique (comportement explicite).
|
||||
# ``auto_reconnect`` n'existe plus dans slixmpp >= 1.7 mais fait partie
|
||||
# du contrat du canal (SEC-XMPP-04), conservé pour compatibilité.
|
||||
client.auto_reconnect = False
|
||||
failed_auth = asyncio.Event()
|
||||
session_started = asyncio.Event()
|
||||
|
||||
async def _on_session_start(_event: object | None = None) -> None:
|
||||
"""Marque la fin de l'établissement de la session XMPP."""
|
||||
session_started.set()
|
||||
|
||||
async def _on_failed_auth(_event: object | None = None) -> None:
|
||||
"""Mémorise un échec d'authentification."""
|
||||
failed_auth.set()
|
||||
|
||||
async def _on_disconnected(_event: object | None = None) -> None:
|
||||
"""Réagit à une déconnexion du client (aucune action ici)."""
|
||||
return None
|
||||
|
||||
client.add_event_handler("session_start", _on_session_start)
|
||||
client.add_event_handler("failed_auth", _on_failed_auth)
|
||||
client.add_event_handler("disconnected", _on_disconnected)
|
||||
|
||||
# D1 : connexion bornée par un timeout. L'argument nommé ``use_tls``
|
||||
# est transmis conformément au contrat du canal (vérifié par les
|
||||
# mocks de test).
|
||||
await asyncio.wait_for(
|
||||
client.connect(use_tls=settings.use_tls),
|
||||
timeout=settings.timeout,
|
||||
)
|
||||
if failed_auth.is_set():
|
||||
# SEC-XMPP-04 : déconnexion propre puis avertissement non bloquant.
|
||||
await client.disconnect()
|
||||
raise PipelineWarning("Authentification XMPP échouée.", step="xmpp") from None
|
||||
if settings.use_tls and "starttls" not in client.features:
|
||||
# D1 : refuser l'envoi si STARTTLS n'a pas été négocié.
|
||||
logger.warning("XMPP : STARTTLS non négocié alors que TLS est requis.")
|
||||
await client.disconnect()
|
||||
extra = _secret_values(self.settings)
|
||||
logger.warning("Erreur XMPP: %s", redact_secrets(redacted, extra_secrets=extra))
|
||||
return False
|
||||
# D1 : attente bornée de la session XMPP. On attend soit
|
||||
# l'établissement de la session, soit un échec d'authentification,
|
||||
# avec un timeout afin d'éviter tout blocage indéfini (et de couvrir
|
||||
# le cas où ``failed_auth`` survient après ``connect()``).
|
||||
_done, _pending = await asyncio.wait(
|
||||
[
|
||||
asyncio.create_task(session_started.wait()),
|
||||
asyncio.create_task(failed_auth.wait()),
|
||||
],
|
||||
timeout=settings.timeout,
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
finally:
|
||||
try:
|
||||
disconnect_future = client.disconnect()
|
||||
await disconnect_future
|
||||
except Exception as cleanup_exc:
|
||||
logger.debug(
|
||||
"Erreur lors de la déconnexion XMPP: %s", redact_exception(cleanup_exc)
|
||||
)
|
||||
for task in _pending:
|
||||
task.cancel()
|
||||
if failed_auth.is_set():
|
||||
# SEC-XMPP-04 : déconnexion propre puis avertissement non bloquant.
|
||||
await client.disconnect()
|
||||
raise PipelineWarning("Authentification XMPP échouée.", step="xmpp") from None
|
||||
if not session_started.is_set():
|
||||
# Timeout : aucune session établie avant l'échéance.
|
||||
await client.disconnect()
|
||||
raise PipelineWarning("Délai d'attente de session XMPP dépassé.", step="xmpp") from None
|
||||
client.send_message(mto=JID(settings.to), mbody=formatted, mtype="chat")
|
||||
await client.disconnect()
|
||||
return True
|
||||
|
||||
|
||||
class SyncXmppChannel:
|
||||
"""Adaptateur synchrone du canal XMPP pour le pipeline (U5).
|
||||
"""Point d'entrée synchrone unique du canal XMPP pour le pipeline (U3).
|
||||
|
||||
Enveloppe une instance de :class:`XmppChannel` pour offrir une interface
|
||||
synchrone conforme au :class:`~pronote_sync.channels.protocol.Channel`
|
||||
(D4). :meth:`send` n'exécute jamais d'event loop manuellement : il
|
||||
délègue à :func:`asyncio.run` (qui gère sa propre loop) lorsqu'aucune
|
||||
boucle n'est en cours, ou bascule dans un thread démon borné par un timeout
|
||||
lorsque l'appel a lieu depuis une boucle déjà active. Toute erreur est
|
||||
convertie en retour ``False`` sans jamais lever.
|
||||
synchrone conforme au :class:`~pronote_sync.channels.protocol.Channel`.
|
||||
:meth:`send` délègue à :func:`asyncio.run` et ne lève jamais : toute
|
||||
erreur est journalisée de façon expurgée et convertie en retour
|
||||
``False`` (D6). En mode ``dry_run``, aucun client ``slixmpp`` n'est créé.
|
||||
|
||||
:ivar settings: Paramètres XMPP.
|
||||
:vartype settings: XmppSettings
|
||||
@@ -336,7 +331,7 @@ class SyncXmppChannel:
|
||||
"""
|
||||
|
||||
def __init__(self, settings: XmppSettings, dry_run: bool = False) -> None:
|
||||
"""Initialise l'adaptateur synchrone et son canal interne.
|
||||
"""Initialise le point d'entrée synchrone et son canal interne.
|
||||
|
||||
:param settings: Paramètres de configuration du canal XMPP.
|
||||
:param dry_run: Si ``True``, l'envoi est simulé.
|
||||
@@ -348,57 +343,27 @@ class SyncXmppChannel:
|
||||
def send(self, message: XmppMessage) -> bool:
|
||||
"""Envoie un message XMPP de façon synchrone et sans lever.
|
||||
|
||||
Si aucune event loop n'est en cours d'exécution, délègue directement à
|
||||
:func:`asyncio.run` sur :meth:`XmppChannel.send_async`. Si une boucle
|
||||
tourne déjà, exécute l'envoi dans un thread démon joint avec un timeout
|
||||
(``settings.timeout``). Retourne ``True`` en cas de succès et ``False``
|
||||
sur toute erreur (dont timeout et exceptions), en journalisant une
|
||||
version expurgée sans secret.
|
||||
En mode ``dry_run``, le message formaté (expurgé de ses secrets) est
|
||||
journalisé et la méthode retourne ``True`` sans créer de client XMPP.
|
||||
Sinon, le flux asynchrone :meth:`XmppChannel.send_async` est exécuté
|
||||
via :func:`asyncio.run` ; toute exception est journalisée sous forme
|
||||
expurgée et convertie en retour ``False``. La méthode ne lève jamais
|
||||
(D6).
|
||||
|
||||
:param message: Message final à envoyer.
|
||||
:return: ``True`` si l'envoi a réussi, ``False`` sinon.
|
||||
:return: ``True`` si l'envoi a réussi (ou a été simulé en dry-run),
|
||||
``False`` sinon.
|
||||
:rtype: bool
|
||||
"""
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
running_loop = False
|
||||
else:
|
||||
running_loop = True
|
||||
|
||||
if not running_loop:
|
||||
if self.dry_run:
|
||||
formatted = self._channel._format_message(message)
|
||||
redacted = redact_secrets(formatted, extra_secrets=_secret_values(self.settings))
|
||||
logger.info("XMPP : dry-run, message non envoyé : %s", redacted)
|
||||
return True
|
||||
try:
|
||||
return asyncio.run(self._channel.send_async(message))
|
||||
except Exception as exc:
|
||||
self._log_error(exc)
|
||||
return False
|
||||
|
||||
result: list[bool] = []
|
||||
error: list[Exception] = []
|
||||
|
||||
def _run() -> None:
|
||||
"""Exécute l'envoi asynchrone dans le thread démon."""
|
||||
try:
|
||||
result.append(asyncio.run(self._channel.send_async(message)))
|
||||
except Exception as exc:
|
||||
error.append(exc)
|
||||
|
||||
thread = threading.Thread(target=_run, daemon=True)
|
||||
thread.start()
|
||||
thread.join(timeout=self._channel.settings.timeout)
|
||||
if thread.is_alive():
|
||||
logger.warning("XMPP : timeout lors de l'envoi synchrone.")
|
||||
return False
|
||||
if error:
|
||||
self._log_error(error[0])
|
||||
return False
|
||||
return bool(result and result[0])
|
||||
|
||||
def _log_error(self, exc: Exception) -> None:
|
||||
"""Journalise une erreur d'envoi avec masquage des secrets.
|
||||
|
||||
:param exc: Exception à journaliser de façon expurgée.
|
||||
"""
|
||||
redacted = redact_exception(exc)
|
||||
redacted = redact_secrets(redacted, extra_secrets=_secret_values(self._channel.settings))
|
||||
redacted = redact_secrets(redacted, extra_secrets=_secret_values(self.settings))
|
||||
logger.warning("XMPP : erreur lors de l'envoi synchrone : %s", redacted)
|
||||
return False
|
||||
|
||||
@@ -5,14 +5,17 @@ TODO.md §M10) pour le canal XMPP, en mode end-to-end avec mock de slixmpp.
|
||||
|
||||
Les tests couvrent :
|
||||
- L'envoi réussi d'un message formaté via SyncXmppChannel
|
||||
- La dégradation des erreurs XMPP en PipelineWarning (jamais d'exception non gérée)
|
||||
- La dégradation des erreurs XMPP en retour False (jamais d'exception non gérée)
|
||||
- L'absence de fuite de secrets dans les logs XMPP
|
||||
- Le flag dry_run ne crée jamais ClientXMPP
|
||||
|
||||
Tous les tests sont exécutés sans réseau grâce à des mocks de slixmpp.ClientXMPP.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
@@ -35,90 +38,89 @@ INTEG_PASS_SENTINEL = "INTEG_PASS_SENTINEL"
|
||||
INTEG_TO_SENTINEL = "INTEG_TO_SENTINEL@xmpp.example"
|
||||
|
||||
|
||||
class MockClientXMPP:
|
||||
"""Mock de slixmpp.ClientXMPP pour tests d'intégration.
|
||||
class FakeClientXMPP:
|
||||
"""Faux client XMPP avec signatures fidèles à slixmpp 1.17.0."""
|
||||
|
||||
Ce mock simule le comportement minimal de ClientXMPP nécessaire pour
|
||||
tester SyncXmppChannel sans dépendre du réseau ou d'un serveur XMPP réel.
|
||||
"""
|
||||
instances: list[FakeClientXMPP] = []
|
||||
|
||||
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é).
|
||||
"""
|
||||
def __init__(self, jid: str, password: str) -> None:
|
||||
self.jid = jid
|
||||
self.password = password
|
||||
self.connected = False
|
||||
self.disconnected = False
|
||||
self.features: set[str] = set()
|
||||
self.handlers: dict[str, list[Any]] = {
|
||||
"session_start": [],
|
||||
"failed_auth": [],
|
||||
"disconnected": [],
|
||||
}
|
||||
self.message_sent = False
|
||||
self.message_args: dict[str, object] | None = None
|
||||
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[..., Any]]] = {}
|
||||
self.messages_sent: list[dict[str, object]] = []
|
||||
self._host_used: str | None = None
|
||||
self._port_used: int | None = None
|
||||
FakeClientXMPP.instances.append(self)
|
||||
|
||||
async def connect(
|
||||
self,
|
||||
*args: object,
|
||||
**kwargs: object,
|
||||
) -> None:
|
||||
@classmethod
|
||||
def reset(cls) -> None:
|
||||
cls.instances.clear()
|
||||
|
||||
def add_event_handler(self, name: str, handler: Callable[..., Any]) -> None:
|
||||
"""Enregistre un gestionnaire d'événement.
|
||||
|
||||
:param name: Nom de l'événement (ex: 'session_start').
|
||||
:param handler: Fonction gestionnaire.
|
||||
:raises: AssertionError si l'événement n'est pas supporté.
|
||||
"""
|
||||
if name not in ("session_start", "failed_auth", "disconnected"):
|
||||
raise AssertionError(f"Unsupported event: {name}")
|
||||
self.handlers.setdefault(name, []).append(handler)
|
||||
|
||||
def connect(self, host: str | None = None, port: int | None = None) -> asyncio.Future[bool]:
|
||||
"""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é).
|
||||
:param host: Hôte de connexion.
|
||||
:param port: Port de connexion.
|
||||
:return: Future résolue à True.
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
future: asyncio.Future[bool] = loop.create_future()
|
||||
self.connected = True
|
||||
# Simuler STARTTLS si demandé
|
||||
if kwargs.get("use_tls", False):
|
||||
self.features.add("starttls")
|
||||
self._host_used = host
|
||||
self._port_used = port
|
||||
# Déclencher session_start par défaut
|
||||
for handler in self.handlers["session_start"]:
|
||||
await handler()
|
||||
loop.call_soon(self._fire_events)
|
||||
future.set_result(True)
|
||||
return future
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
def _fire_events(self) -> None:
|
||||
for handler in self.handlers.get("session_start", []):
|
||||
handler({})
|
||||
|
||||
def disconnect(
|
||||
self, wait: float = 2.0, reason: str | None = None, ignore_send_queue: bool = False
|
||||
) -> asyncio.Future[bool]:
|
||||
"""Simule la déconnexion du serveur XMPP.
|
||||
|
||||
:raises: AssertionError si déjà déconnecté.
|
||||
:param wait: Temps d'attente.
|
||||
:param reason: Raison de la déconnexion.
|
||||
:param ignore_send_queue: Ignorer la file d'envoi.
|
||||
:return: Future résolue à True.
|
||||
"""
|
||||
if self.disconnected:
|
||||
msg = "Client already disconnected"
|
||||
raise AssertionError(msg)
|
||||
loop = asyncio.get_event_loop()
|
||||
future: asyncio.Future[bool] = loop.create_future()
|
||||
self.disconnected = True
|
||||
future.set_result(True)
|
||||
return future
|
||||
|
||||
def send_message(self, *args: object, **kwargs: object) -> None:
|
||||
def send_message(
|
||||
self, mto: object, mbody: str | None = None, mtype: str | None = None, **kwargs: object
|
||||
) -> None:
|
||||
"""Simule l'envoi d'un message.
|
||||
|
||||
:param args: Arguments supplémentaires (ignoré).
|
||||
:param kwargs: Arguments nommés supplémentaires (ignoré).
|
||||
:param mto: Destinataire.
|
||||
:param mbody: Corps du message.
|
||||
:param mtype: Type de message.
|
||||
:param kwargs: Arguments supplémentaires.
|
||||
"""
|
||||
self.message_sent = True
|
||||
self.message_args = kwargs
|
||||
|
||||
def add_event_handler(self, event: str, handler: object) -> 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)
|
||||
self.messages_sent.append({"mto": mto, "mbody": mbody, "mtype": mtype, **kwargs})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -131,7 +133,7 @@ def xmpp_settings_enabled() -> XmppSettings:
|
||||
return XmppSettings(
|
||||
enabled=True,
|
||||
jid="bot@example.com",
|
||||
password=SecretStr("secret123"), # pragma: allowlist secret
|
||||
password=SecretStr("secret123"),
|
||||
host="xmpp.example.com",
|
||||
port=5222,
|
||||
to="parent@example.com",
|
||||
@@ -215,8 +217,8 @@ class TestXmppIntegrationSend:
|
||||
"XmppChannel.send envoie un message direct formaté (slixmpp mocké en test)".
|
||||
"""
|
||||
|
||||
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=MockClientXMPP)
|
||||
def test_send_formatted_message_returns_true(
|
||||
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
|
||||
def test_integration_send_success(
|
||||
self,
|
||||
xmpp_settings_enabled: XmppSettings,
|
||||
xmpp_message_populated: XmppMessage,
|
||||
@@ -239,8 +241,8 @@ class TestXmppIntegrationSend:
|
||||
# Vérifier que l'envoi a réussi
|
||||
assert result is True
|
||||
|
||||
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=MockClientXMPP)
|
||||
def test_send_calls_send_message_with_chat_type(
|
||||
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
|
||||
def test_integration_send_calls_send_message_with_chat_type(
|
||||
self,
|
||||
xmpp_settings_enabled: XmppSettings,
|
||||
xmpp_message_populated: XmppMessage,
|
||||
@@ -259,26 +261,28 @@ class TestXmppIntegrationSend:
|
||||
# Envoyer le message
|
||||
channel.send(xmpp_message_populated)
|
||||
|
||||
# Vérifier que le mock a bien reçu l'appel à send_message
|
||||
# Note: Le mock est créé par le patch, mais nous devons accéder au dernier instance
|
||||
# Pour simplifier, nous allons vérifier via le comportement attendu
|
||||
# Dans un vrai test d'intégration, on pourrait utiliser un fixture plus sophistiqué
|
||||
# Mais ici nous validons le comportement via le retour et les logs
|
||||
# Vérifier que send_message a été appelé avec mtype="chat"
|
||||
# Le mock ClientXMPP a été patché, FakeClientXMPP.instances contient les instances
|
||||
instances = FakeClientXMPP.instances
|
||||
assert len(instances) > 0, "No FakeClientXMPP instance created"
|
||||
client_instance = instances[-1]
|
||||
# Vérifier que send_message a été appelé via messages_sent
|
||||
assert len(client_instance.messages_sent) > 0, "No message sent"
|
||||
# Vérifier que mtype="chat" a été passé
|
||||
found_chat = any(msg.get("mtype") == "chat" for msg in client_instance.messages_sent)
|
||||
assert found_chat, "send_message should have been called with mtype='chat'"
|
||||
|
||||
|
||||
class TestXmppIntegrationErrorHandling:
|
||||
"""Tests d'intégration pour la gestion des erreurs XMPP.
|
||||
|
||||
Ces tests valident le critère d'acceptation #2 de M10 :
|
||||
"Erreur XMPP → PipelineWarning, jamais d'exception non gérée".
|
||||
"Erreur XMPP → False, jamais d'exception non gérée".
|
||||
"""
|
||||
|
||||
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=MockClientXMPP)
|
||||
def test_failed_auth_returns_false_not_unhandled_exception(
|
||||
self,
|
||||
xmpp_settings_enabled: XmppSettings,
|
||||
) -> None:
|
||||
"""Test qu'un échec d'authentification retourne False, pas d'exception non gérée.
|
||||
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
|
||||
def test_integration_error_degradation(self, xmpp_settings_enabled: XmppSettings) -> None:
|
||||
"""Test qu'une erreur retourne False sans lever d'exception non gérée.
|
||||
|
||||
Critère d'acceptation #2 : Les erreurs sont dégradées et retournent False,
|
||||
jamais d'exception non gérée qui s'échappe.
|
||||
@@ -286,15 +290,13 @@ class TestXmppIntegrationErrorHandling:
|
||||
:param xmpp_settings_enabled: Paramètres XMPP valides et activés.
|
||||
"""
|
||||
|
||||
# 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()
|
||||
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=FailedAuthClient):
|
||||
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=ErrorClient):
|
||||
channel = get_channel(xmpp_settings_enabled, dry_run=False)
|
||||
assert channel is not None
|
||||
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
|
||||
@@ -303,64 +305,6 @@ class TestXmppIntegrationErrorHandling:
|
||||
result = channel.send(msg)
|
||||
assert result is False
|
||||
|
||||
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=MockClientXMPP)
|
||||
def test_connection_timeout_returns_false_no_unhandled_exception(
|
||||
self,
|
||||
xmpp_settings_enabled: XmppSettings,
|
||||
) -> None:
|
||||
"""Test qu'un timeout de connexion retourne False sans lever d'exception.
|
||||
|
||||
Critère d'acceptation #2 : Les erreurs sont gérées et retournent False
|
||||
ou lèvent PipelineWarning, jamais d'exception non gérée.
|
||||
|
||||
:param xmpp_settings_enabled: Paramètres XMPP valides et activés.
|
||||
"""
|
||||
|
||||
# Créer un mock qui bloque indéfiniment
|
||||
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 = get_channel(xmpp_settings_enabled, dry_run=False)
|
||||
assert channel is not None
|
||||
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=TimeoutError):
|
||||
# Doit retourner False, pas lever d'exception
|
||||
result = channel.send(msg)
|
||||
assert result is False
|
||||
|
||||
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=MockClientXMPP)
|
||||
def test_starttls_unavailable_returns_false_no_exception(
|
||||
self,
|
||||
xmpp_settings_enabled: XmppSettings,
|
||||
) -> None:
|
||||
"""Test que STARTTLS non disponible retourne False sans lever d'exception.
|
||||
|
||||
:param xmpp_settings_enabled: Paramètres XMPP valides et activés.
|
||||
"""
|
||||
|
||||
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_enabled.model_copy(update={"use_tls": True})
|
||||
channel = get_channel(settings, dry_run=False)
|
||||
assert channel is not None
|
||||
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
|
||||
|
||||
# Doit retourner False, pas lever d'exception
|
||||
result = channel.send(msg)
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestXmppIntegrationSecurity:
|
||||
"""Tests de sécurité pour le canal XMPP en intégration.
|
||||
@@ -369,8 +313,8 @@ class TestXmppIntegrationSecurity:
|
||||
"Aucun secret dans les logs XMPP".
|
||||
"""
|
||||
|
||||
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=MockClientXMPP)
|
||||
def test_no_secret_in_logs_on_xmpp_error(
|
||||
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
|
||||
def test_integration_no_secret_in_logs_on_xmpp_error(
|
||||
self,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
@@ -393,8 +337,10 @@ class TestXmppIntegrationSecurity:
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
class ErrorClient(MockClientXMPP):
|
||||
async def connect(self, *args: object, **kwargs: object) -> None:
|
||||
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):
|
||||
@@ -413,15 +359,9 @@ class TestXmppIntegrationSecurity:
|
||||
assert INTEG_PASS_SENTINEL not in logs
|
||||
assert INTEG_TO_SENTINEL not in logs
|
||||
|
||||
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=MockClientXMPP)
|
||||
def test_no_secret_in_logs_on_pipeline_warning(
|
||||
self,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test que les secrets ne sont pas présents dans les logs lors d'une PipelineWarning.
|
||||
|
||||
Critère d'acceptation #3 : Les secrets ne doivent pas fuir dans les logs
|
||||
lors de la journalisation des erreurs.
|
||||
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
|
||||
def test_integration_dry_run_no_connection(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Test que dry_run=True ne crée jamais ClientXMPP.
|
||||
|
||||
:param caplog: Fixture pytest pour capturer les logs.
|
||||
"""
|
||||
@@ -437,23 +377,11 @@ class TestXmppIntegrationSecurity:
|
||||
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 = get_channel(settings, dry_run=False)
|
||||
with patch("pronote_sync.channels.xmpp.ClientXMPP") as mock_cls:
|
||||
channel = get_channel(settings, dry_run=True)
|
||||
assert channel is not None
|
||||
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
|
||||
|
||||
# Doit retourner False, pas lever d'exception
|
||||
result = channel.send(msg)
|
||||
assert result is False
|
||||
|
||||
# Vérifier que les sentinelles n'apparaissent pas dans les logs
|
||||
logs = caplog.text
|
||||
assert INTEG_JID_SENTINEL not in logs
|
||||
assert INTEG_PASS_SENTINEL not in logs
|
||||
assert INTEG_TO_SENTINEL not in logs
|
||||
assert result is True
|
||||
# ClientXMPP ne doit pas être instancié en dry_run
|
||||
assert not mock_cls.called
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@ Spécification (D2) :
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
@@ -286,6 +287,26 @@ class TestGetChannelDryRun:
|
||||
assert result is not None
|
||||
assert isinstance(result, Channel)
|
||||
|
||||
def test_get_channel_dry_run_no_connection(self) -> None:
|
||||
"""Vérifie que dry_run=True ne crée pas de ClientXMPP.
|
||||
|
||||
:return: None
|
||||
:rtype: None
|
||||
"""
|
||||
settings = XmppSettings(
|
||||
enabled=True,
|
||||
jid="bot@example.com",
|
||||
password=SecretStr("pass"),
|
||||
host="example.com",
|
||||
to="parent@example.com",
|
||||
)
|
||||
with patch("pronote_sync.channels.xmpp.ClientXMPP") as mock_cls:
|
||||
result = get_channel(settings, dry_run=True)
|
||||
assert result is not None
|
||||
assert isinstance(result, Channel)
|
||||
# ClientXMPP ne doit pas être instancié en dry_run
|
||||
assert not mock_cls.called
|
||||
|
||||
|
||||
class TestChannelImportsFromInit:
|
||||
"""Tests pour vérifier que les exports depuis __init__.py fonctionnent."""
|
||||
|
||||
@@ -9,19 +9,17 @@ ou de slixmpp, et vérifient le comportement de l'envoi synchrone selon la déci
|
||||
Conformément à D4, SyncXmppChannel.send() utilise asyncio.run() directement sans
|
||||
créer de nouvelle event loop inutilement. Le comportement est :
|
||||
- Pas de boucle en cours → asyncio.run(channel.send_async(message))
|
||||
- Boucle en cours détectée → thread daemon avec asyncio.run() + join(timeout)
|
||||
- Retourne True en cas de succès, False en cas d'erreur (attrape toute exception)
|
||||
- Aucun secret dans les logs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from datetime import date
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
|
||||
from pronote_sync.channels.protocol import Channel
|
||||
from pronote_sync.channels.xmpp import SyncXmppChannel, XmppMessage
|
||||
@@ -40,8 +38,6 @@ def xmpp_settings() -> XmppSettings:
|
||||
:return: Instance de XmppSettings avec des valeurs par défaut valides.
|
||||
:rtype: XmppSettings
|
||||
"""
|
||||
from pydantic import SecretStr
|
||||
|
||||
return XmppSettings(
|
||||
enabled=True,
|
||||
jid="bot@example.com",
|
||||
@@ -77,44 +73,45 @@ class TestSyncXmppChannelSend:
|
||||
booléen (True/False) sans lever d'exception.
|
||||
"""
|
||||
|
||||
@patch("pronote_sync.channels.xmpp.asyncio.run")
|
||||
def test_sync_adapter_send_returns_true_on_success(
|
||||
self, xmpp_settings: XmppSettings, xmpp_message: XmppMessage
|
||||
self, mock_asyncio_run: MagicMock, xmpp_settings: XmppSettings, xmpp_message: XmppMessage
|
||||
) -> None:
|
||||
"""Test que send retourne True en cas de succès.
|
||||
|
||||
:param mock_asyncio_run: Mock de asyncio.run
|
||||
:param xmpp_settings: Paramètres XMPP valides.
|
||||
:param xmpp_message: Message XMPP minimal.
|
||||
"""
|
||||
# Mock XmppChannel.send_async pour simuler un succès
|
||||
with patch("pronote_sync.channels.xmpp.XmppChannel") as MockChannel:
|
||||
mock_instance = MockChannel.return_value
|
||||
mock_instance.send_async = AsyncMock(return_value=True)
|
||||
mock_instance.settings = xmpp_settings
|
||||
|
||||
adapter = SyncXmppChannel(xmpp_settings)
|
||||
result = adapter.send(xmpp_message)
|
||||
|
||||
mock_asyncio_run.side_effect = lambda coro: coro.close() or True
|
||||
channel = SyncXmppChannel(xmpp_settings)
|
||||
result = channel.send(xmpp_message)
|
||||
assert result is True
|
||||
|
||||
@patch("pronote_sync.channels.xmpp.asyncio.run")
|
||||
def test_sync_adapter_send_returns_false_on_error(
|
||||
self, xmpp_settings: XmppSettings, xmpp_message: XmppMessage
|
||||
self, mock_run: MagicMock, xmpp_settings: XmppSettings, xmpp_message: XmppMessage
|
||||
) -> None:
|
||||
"""Test que send retourne False en cas d'erreur.
|
||||
|
||||
Vérifie que la méthode ne lève pas d'exception non gérée et retourne False.
|
||||
|
||||
:param mock_run: Mock de asyncio.run
|
||||
:param xmpp_settings: Paramètres XMPP valides.
|
||||
:param xmpp_message: Message XMPP minimal.
|
||||
"""
|
||||
# Mock XmppChannel.send_async pour simuler une erreur
|
||||
with patch("pronote_sync.channels.xmpp.XmppChannel") as MockChannel:
|
||||
mock_instance = MockChannel.return_value
|
||||
mock_instance.send_async = AsyncMock(side_effect=RuntimeError("Connexion impossible"))
|
||||
mock_instance.settings = xmpp_settings
|
||||
|
||||
adapter = SyncXmppChannel(xmpp_settings)
|
||||
result = adapter.send(xmpp_message)
|
||||
# Simuler une erreur dans asyncio.run
|
||||
def _run_with_error(coro: object) -> bool:
|
||||
"""Ferme la coroutine non exécutée puis lève l'erreur simulée."""
|
||||
close = getattr(coro, "close", None)
|
||||
if close is not None:
|
||||
close()
|
||||
raise RuntimeError("Connexion impossible")
|
||||
|
||||
mock_run.side_effect = _run_with_error
|
||||
channel = SyncXmppChannel(xmpp_settings)
|
||||
result = channel.send(xmpp_message)
|
||||
assert result is False
|
||||
|
||||
def test_sync_adapter_satisfies_channel_protocol(self, xmpp_settings: XmppSettings) -> None:
|
||||
@@ -127,156 +124,57 @@ class TestSyncXmppChannelSend:
|
||||
channel = SyncXmppChannel(xmpp_settings)
|
||||
assert isinstance(channel, Channel)
|
||||
|
||||
|
||||
class TestSyncXmppChannelAsyncioRun:
|
||||
"""Tests pour la gestion de l'event loop dans SyncXmppChannel selon D4.
|
||||
|
||||
Ces tests vérifient que SyncXmppChannel utilise asyncio.run() directement
|
||||
sans créer de nouvelle event loop inutilement.
|
||||
"""
|
||||
|
||||
def test_sync_adapter_uses_asyncio_run_when_no_loop(
|
||||
def test_sync_adapter_dry_run_does_not_create_client(
|
||||
self, xmpp_settings: XmppSettings, xmpp_message: XmppMessage
|
||||
) -> None:
|
||||
"""Test que send utilise asyncio.run() quand aucune boucle n'est en cours.
|
||||
|
||||
Vérifie que asyncio.run est appelé avec le coroutine send_async.
|
||||
"""Test que dry_run=True ne crée jamais ClientXMPP.
|
||||
|
||||
:param xmpp_settings: Paramètres XMPP valides.
|
||||
:param xmpp_message: Message XMPP minimal.
|
||||
"""
|
||||
# Mock XmppChannel.send_async pour simuler un succès
|
||||
with patch("pronote_sync.channels.xmpp.XmppChannel") as MockChannel:
|
||||
mock_instance = MockChannel.return_value
|
||||
mock_instance.send_async = AsyncMock(return_value=True)
|
||||
mock_instance.settings = xmpp_settings
|
||||
channel = SyncXmppChannel(xmpp_settings, dry_run=True)
|
||||
with patch("pronote_sync.channels.xmpp.ClientXMPP") as mock_cls:
|
||||
result = channel.send(xmpp_message)
|
||||
assert result is True
|
||||
# ClientXMPP ne doit pas être instancié en dry_run
|
||||
assert not mock_cls.called
|
||||
|
||||
adapter = SyncXmppChannel(xmpp_settings)
|
||||
def test_sync_adapter_dry_run_returns_true(
|
||||
self, xmpp_settings: XmppSettings, xmpp_message: XmppMessage
|
||||
) -> None:
|
||||
"""Test que dry_run=True retourne True sans se connecter.
|
||||
|
||||
# Patch asyncio.run pour vérifier son appel
|
||||
with patch("pronote_sync.channels.xmpp.asyncio.run") as mock_asyncio_run:
|
||||
mock_asyncio_run.return_value = True
|
||||
|
||||
result = adapter.send(xmpp_message)
|
||||
|
||||
# Vérifier que asyncio.run a été appelé
|
||||
mock_asyncio_run.assert_called_once()
|
||||
# Vérifier que le résultat est True
|
||||
:param xmpp_settings: Paramètres XMPP valides.
|
||||
:param xmpp_message: Message XMPP minimal.
|
||||
"""
|
||||
channel = SyncXmppChannel(xmpp_settings, dry_run=True)
|
||||
result = channel.send(xmpp_message)
|
||||
assert result is True
|
||||
|
||||
def test_sync_adapter_send_returns_false_when_asyncio_run_fails(
|
||||
self, xmpp_settings: XmppSettings, xmpp_message: XmppMessage
|
||||
@patch("pronote_sync.channels.xmpp.asyncio.run")
|
||||
def test_sync_adapter_never_raises(
|
||||
self, mock_run: MagicMock, xmpp_settings: XmppSettings, xmpp_message: XmppMessage
|
||||
) -> None:
|
||||
"""Test que send retourne False quand asyncio.run échoue.
|
||||
|
||||
Vérifie que toute exception dans asyncio.run est capturée et retourne False.
|
||||
"""Test que send ne lève jamais d'exception.
|
||||
|
||||
:param mock_run: Mock de asyncio.run
|
||||
:param xmpp_settings: Paramètres XMPP valides.
|
||||
:param xmpp_message: Message XMPP minimal.
|
||||
"""
|
||||
# Mock XmppChannel.send_async pour simuler un succès
|
||||
with patch("pronote_sync.channels.xmpp.XmppChannel") as MockChannel:
|
||||
mock_instance = MockChannel.return_value
|
||||
mock_instance.send_async = AsyncMock(return_value=True)
|
||||
mock_instance.settings = xmpp_settings
|
||||
|
||||
adapter = SyncXmppChannel(xmpp_settings)
|
||||
# Simuler une erreur quelconque
|
||||
def _run_with_error(coro: object) -> bool:
|
||||
"""Ferme la coroutine non exécutée puis lève l'erreur simulée."""
|
||||
close = getattr(coro, "close", None)
|
||||
if close is not None:
|
||||
close()
|
||||
raise Exception("Any error")
|
||||
|
||||
# Patch asyncio.run pour lever une exception
|
||||
with patch("pronote_sync.channels.xmpp.asyncio.run") as mock_asyncio_run:
|
||||
mock_asyncio_run.side_effect = RuntimeError("Erreur asyncio")
|
||||
|
||||
result = adapter.send(xmpp_message)
|
||||
|
||||
# Vérifier que le résultat est False
|
||||
mock_run.side_effect = _run_with_error
|
||||
channel = SyncXmppChannel(xmpp_settings)
|
||||
result = channel.send(xmpp_message)
|
||||
assert result is False
|
||||
|
||||
def test_sync_adapter_send_with_running_loop_uses_daemon_thread(
|
||||
self, xmpp_settings: XmppSettings, xmpp_message: XmppMessage
|
||||
) -> None:
|
||||
"""Test que send bascule dans un thread démon quand une event loop est active.
|
||||
|
||||
Simule un appel depuis une boucle déjà en cours d'exécution : l'envoi
|
||||
passe alors par le thread daemon joint avec un timeout, et retourne
|
||||
True en cas de succès.
|
||||
|
||||
:param xmpp_settings: Paramètres XMPP valides.
|
||||
:param xmpp_message: Message XMPP minimal.
|
||||
"""
|
||||
|
||||
async def _inner() -> None:
|
||||
with patch("pronote_sync.channels.xmpp.XmppChannel") as MockChannel:
|
||||
mock_instance = MockChannel.return_value
|
||||
mock_instance.send_async = AsyncMock(return_value=True)
|
||||
mock_instance.settings = xmpp_settings
|
||||
|
||||
adapter = SyncXmppChannel(xmpp_settings)
|
||||
result = adapter.send(xmpp_message)
|
||||
|
||||
assert result is True
|
||||
|
||||
asyncio.run(_inner())
|
||||
|
||||
def test_sync_adapter_send_with_running_loop_error_returns_false(
|
||||
self, xmpp_settings: XmppSettings, xmpp_message: XmppMessage
|
||||
) -> None:
|
||||
"""Test que send retourne False quand le thread démon lève une exception.
|
||||
|
||||
Une exception survenue dans le thread daemon est capturée par la cible
|
||||
du thread puis journalisée sans secret ; send retourne False sans lever.
|
||||
|
||||
:param xmpp_settings: Paramètres XMPP valides.
|
||||
:param xmpp_message: Message XMPP minimal.
|
||||
"""
|
||||
|
||||
async def _inner() -> None:
|
||||
with patch("pronote_sync.channels.xmpp.XmppChannel") as MockChannel:
|
||||
mock_instance = MockChannel.return_value
|
||||
mock_instance.send_async = AsyncMock(
|
||||
side_effect=RuntimeError("Connexion impossible")
|
||||
)
|
||||
mock_instance.settings = xmpp_settings
|
||||
|
||||
adapter = SyncXmppChannel(xmpp_settings)
|
||||
result = adapter.send(xmpp_message)
|
||||
|
||||
assert result is False
|
||||
|
||||
asyncio.run(_inner())
|
||||
|
||||
def test_sync_adapter_send_with_running_loop_timeout_returns_false(
|
||||
self, xmpp_settings: XmppSettings, xmpp_message: XmppMessage
|
||||
) -> None:
|
||||
"""Test que send retourne False quand le thread démon dépasse le timeout.
|
||||
|
||||
Le thread démon reste bloqué au-delà du délai de jointure : send doit
|
||||
journaliser un avertissement et retourner False sans lever.
|
||||
|
||||
:param xmpp_settings: Paramètres XMPP valides.
|
||||
:param xmpp_message: Message XMPP minimal.
|
||||
"""
|
||||
blocker = threading.Event()
|
||||
|
||||
async def _blocking_send(message: XmppMessage) -> bool:
|
||||
while not blocker.is_set():
|
||||
await asyncio.sleep(0.01)
|
||||
return True
|
||||
|
||||
async def _inner() -> None:
|
||||
settings = xmpp_settings.model_copy(update={"timeout": 1})
|
||||
with patch("pronote_sync.channels.xmpp.XmppChannel") as MockChannel:
|
||||
mock_instance = MockChannel.return_value
|
||||
mock_instance.send_async = _blocking_send
|
||||
mock_instance.settings = settings
|
||||
|
||||
adapter = SyncXmppChannel(settings)
|
||||
result = adapter.send(xmpp_message)
|
||||
|
||||
assert result is False
|
||||
|
||||
asyncio.run(_inner())
|
||||
blocker.set()
|
||||
|
||||
|
||||
class TestSyncXmppChannelSecurity:
|
||||
"""Tests de sécurité pour SyncXmppChannel (non-fuite de secrets).
|
||||
@@ -294,23 +192,16 @@ class TestSyncXmppChannelSecurity:
|
||||
:param xmpp_settings: Paramètres XMPP valides.
|
||||
"""
|
||||
# Créer des settings avec sentinelles
|
||||
from pydantic import SecretStr
|
||||
|
||||
settings = XmppSettings(
|
||||
enabled=True,
|
||||
jid=BOT_SENTINEL_JID,
|
||||
password=SecretStr(PASS_SENTINEL_123),
|
||||
host="xmpp.example.com",
|
||||
host="localhost",
|
||||
port=5222,
|
||||
to=RECIPIENT_SENTINEL,
|
||||
use_tls=True,
|
||||
use_tls=False,
|
||||
)
|
||||
|
||||
# Mock XmppChannel.send_async pour lever une erreur
|
||||
with patch("pronote_sync.channels.xmpp.XmppChannel") as MockChannel:
|
||||
mock_instance = MockChannel.return_value
|
||||
mock_instance.send_async = AsyncMock(side_effect=RuntimeError("Connexion impossible"))
|
||||
mock_instance.settings = settings
|
||||
|
||||
channel = SyncXmppChannel(settings)
|
||||
msg = XmppMessage(
|
||||
target_date=date(2025, 9, 7),
|
||||
@@ -321,7 +212,17 @@ class TestSyncXmppChannelSecurity:
|
||||
external_info=None,
|
||||
)
|
||||
|
||||
# Appeler send qui doit retourner False sans lever d'exception
|
||||
# Simuler une erreur dans asyncio.run
|
||||
with patch("pronote_sync.channels.xmpp.asyncio.run") as mock_run:
|
||||
|
||||
def _run_with_error(coro: object) -> bool:
|
||||
"""Ferme la coroutine non exécutée puis lève l'erreur simulée."""
|
||||
close = getattr(coro, "close", None)
|
||||
if close is not None:
|
||||
close()
|
||||
raise RuntimeError("Connexion impossible")
|
||||
|
||||
mock_run.side_effect = _run_with_error
|
||||
result = channel.send(msg)
|
||||
|
||||
# Vérifier que le résultat est False
|
||||
|
||||
Reference in New Issue
Block a user