fix(xmpp): corriger le timeout XMPP — TLS mode + timeouts de connexion/cleanup (#25) (#26)

This commit was merged in pull request #26.
This commit is contained in:
2026-09-11 17:25:21 +02:00
parent d124f78b55
commit f194ed985d
9 changed files with 1070 additions and 105 deletions

View File

@@ -17,7 +17,7 @@ from unittest.mock import patch
import pytest
from pydantic import SecretStr
from pronote_sync.channels.xmpp import XmppChannel, XmppMessage
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
@@ -48,11 +48,15 @@ class FakeClientXMPP:
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", "disconnected"):
if name not in ("session_start", "failed_auth", "connection_failed", "disconnected"):
raise AssertionError(f"Unsupported event: {name}")
self.handlers.setdefault(name, []).append(pointer)
@@ -63,10 +67,24 @@ class FakeClientXMPP:
self.connected = True
self._host_used = host
self._port_used = port
# Schedule event handlers to fire after connect returns
loop.call_soon(self._fire_events)
future.set_result(True)
return future
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:
@@ -86,8 +104,13 @@ class FakeClientXMPP:
loop = asyncio.get_event_loop()
future: asyncio.Future[bool] = loop.create_future()
self.disconnected = True
future.set_result(True)
return future
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
@@ -681,6 +704,339 @@ class TestXmppChannelSend:
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:
@@ -816,6 +1172,328 @@ class TestXmppChannelSend:
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).