diff --git a/pronote_sync/channels/__init__.py b/pronote_sync/channels/__init__.py index e69de29..c251a9c 100644 --- a/pronote_sync/channels/__init__.py +++ b/pronote_sync/channels/__init__.py @@ -0,0 +1,81 @@ +"""Fabrique de création des canaux de sortie du pipeline ``pronote-sync``. + +Ce module expose la fonction :func:`get_channel` qui instancie le canal de +sortie XMPP à partir de sa configuration, ainsi que les types publics du +paquet ``pronote_sync.channels`` : +:class:`~pronote_sync.channels.protocol.Channel`, +:class:`~pronote_sync.channels.xmpp.XmppChannel` et +:class:`~pronote_sync.channels.xmpp.SyncXmppChannel`. +""" + +from __future__ import annotations + +import logging + +from pronote_sync.channels.protocol import Channel +from pronote_sync.channels.xmpp import SyncXmppChannel, XmppChannel +from pronote_sync.config.settings import XmppSettings +from pronote_sync.utils.redaction import redact_secrets + +logger = logging.getLogger(__name__) + +__all__ = ["Channel", "XmppChannel", "SyncXmppChannel", "get_channel"] + + +def get_channel(settings: XmppSettings, dry_run: bool = False) -> Channel | None: + """Instancie le canal de sortie XMPP selon la configuration (D2). + + Si le canal est désactivé (``enabled`` à ``False``), la fabrique + retourne ``None`` sans avertissement ni exception. Si le canal est + activé mais que l'un des champs requis (``jid``, ``password``, ``to``, + ``host``) est vide ou absent, un avertissement est journalisé puis + ``None`` est retourné. Dans tous les autres cas, une instance de + :class:`~pronote_sync.channels.xmpp.SyncXmppChannel` est construite et + retournée. + + L'avertissement est expurgé des valeurs sensibles (``jid``, mot de + passe, destinataire) via :func:`pronote_sync.utils.redaction.redact_secrets` + (SEC-XMPP-02) : le message journalisé ne contient jamais ces valeurs en + clair. La fabrique ne lève jamais d'exception (dégradation non bloquante). + + :param settings: Paramètres de configuration du canal XMPP. + :param dry_run: Si ``True``, le canal est créé en mode simulation + (aucun envoi réseau lors de l'appel à ``send``). + :return: Canal de sortie prêt à l'emploi, ou ``None`` si le canal est + désactivé ou mal configuré. + :rtype: Channel | None + """ + if not settings.enabled: + return None + + # SEC-XMPP-02 : valeurs sensibles à masquer dans le journal (les valeurs + # ``None`` sont ignorées). + extra_secrets = [ + secret for secret in (settings.password, settings.jid, settings.to) if secret is not None + ] + + # SEC-XMPP-02 : rejeter aussi les chaînes vides ou composées uniquement + # d'espaces : ``bool(SecretStr)`` et ``bool(str)`` ne testent que la + # présence de l'objet, pas la valeur contenue. + missing_fields = [ + name + for name, present in ( + ("jid", settings.jid is not None and bool(settings.jid.strip())), + ( + "password", + settings.password is not None + and bool(settings.password.get_secret_value().strip()), + ), + ("to", settings.to is not None and bool(settings.to.strip())), + ("host", bool(settings.host.strip())), + ) + if not present + ] + if missing_fields: + logger.warning( + "XMPP : configuration incomplète (champs manquants : %s), canal désactivé.", + redact_secrets(", ".join(missing_fields), extra_secrets=extra_secrets), + ) + return None + + return SyncXmppChannel(settings, dry_run=dry_run) diff --git a/tests/unit/test_xmpp_factory.py b/tests/unit/test_xmpp_factory.py new file mode 100644 index 0000000..92da819 --- /dev/null +++ b/tests/unit/test_xmpp_factory.py @@ -0,0 +1,303 @@ +"""Tests unitaires pour la factory get_channel des canaux XMPP. + +Ce module valide la spécification de la factory ``get_channel`` qui sera +implémentée dans ``pronote_sync/channels/__init__.py``. + +Les tests doivent être initialement en échec (RED) car la factory n'existe +pas encore dans le code de production. + +Spécification (D2) : +- get_channel(settings: XmppSettings, dry_run: bool = False) -> Channel | None +- Si enabled=False → retourne None (pas d'exception, pas d'avertissement). +- Si enabled=True et champs requis manquants (jid, password, to, host) → + journalise un avertissement avec redact_secrets(), retourne None. +- Si enabled=True et tous champs requis présents → construit et retourne + une instance de SyncXmppChannel (ou XmppChannel). +- La factory n'élève jamais d'exception. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from pydantic import SecretStr + +from pronote_sync.channels import ( + Channel, + SyncXmppChannel, + XmppChannel, + get_channel, +) +from pronote_sync.config.settings import XmppSettings + + +class TestGetChannelDisabled: + """Tests pour le cas où le canal XMPP est désactivé (enabled=False).""" + + def test_get_channel_disabled_returns_none(self) -> None: + """Vérifie que get_channel retourne None quand enabled=False. + + :return: None + :rtype: None + """ + settings = XmppSettings(enabled=False) + result = get_channel(settings) + assert result is None + + +class TestGetChannelEnabledComplete: + """Tests pour le cas où le canal est activé avec une configuration complète.""" + + def test_get_channel_enabled_complete_returns_channel(self) -> None: + """Vérifie que get_channel retourne une instance de channel quand la configuration est complète. + + :return: None + :rtype: None + """ + settings = XmppSettings( + enabled=True, + jid="bot@example.com", + password=SecretStr("pass"), + host="example.com", + to="parent@example.com", + ) + result = get_channel(settings) + assert result is not None + # Vérifie que le résultat implémente le Protocol Channel + assert isinstance(result, Channel) + + +class TestGetChannelEnabledMissingRequiredFields: + """Tests pour les cas où des champs requis sont manquants.""" + + @pytest.mark.parametrize( + "settings_kwargs", + [ + { + "enabled": True, + "jid": None, + "password": SecretStr("pass"), + "host": "example.com", + "to": "parent@example.com", + }, + { + "enabled": True, + "jid": "bot@example.com", + "password": None, + "host": "example.com", + "to": "parent@example.com", + }, + { + "enabled": True, + "jid": "bot@example.com", + "password": SecretStr("pass"), + "host": "", + "to": "parent@example.com", + }, + { + "enabled": True, + "jid": "bot@example.com", + "password": SecretStr("pass"), + "host": "example.com", + "to": None, + }, + ], + ids=["missing_jid", "missing_password", "missing_host", "missing_to"], + ) + def test_get_channel_enabled_missing_required_field_returns_none( + self, + settings_kwargs: dict[str, Any], + caplog: pytest.LogCaptureFixture, + ) -> None: + """Vérifie que get_channel retourne None quand un champ requis est manquant. + + :param settings_kwargs: Paramètres pour XmppSettings avec un champ manquant. + :param caplog: Fixture pour capturer les logs. + :return: None + :rtype: None + """ + settings = XmppSettings(**settings_kwargs) + result = get_channel(settings) + assert result is None + # Vérifie qu'un avertissement a été journalisé + assert len(caplog.records) > 0 + assert any(record.levelname == "WARNING" for record in caplog.records) + + def test_get_channel_enabled_missing_jid_returns_none(self) -> None: + """Vérifie que get_channel retourne None quand jid est None. + + :return: None + :rtype: None + """ + settings = XmppSettings( + enabled=True, + jid=None, + password=SecretStr("pass"), + host="example.com", + to="parent@example.com", + ) + result = get_channel(settings) + assert result is None + + def test_get_channel_enabled_missing_password_returns_none(self) -> None: + """Vérifie que get_channel retourne None quand password est None. + + :return: None + :rtype: None + """ + settings = XmppSettings( + enabled=True, + jid="bot@example.com", + password=None, + host="example.com", + to="parent@example.com", + ) + result = get_channel(settings) + assert result is None + + def test_get_channel_enabled_missing_to_returns_none(self) -> None: + """Vérifie que get_channel retourne None quand to est None. + + :return: None + :rtype: None + """ + settings = XmppSettings( + enabled=True, + jid="bot@example.com", + password=SecretStr("pass"), + host="example.com", + to=None, + ) + result = get_channel(settings) + assert result is None + + def test_get_channel_enabled_missing_host_returns_none(self) -> None: + """Vérifie que get_channel retourne None quand host est vide. + + :return: None + :rtype: None + """ + settings = XmppSettings( + enabled=True, + jid="bot@example.com", + password=SecretStr("pass"), + host="", + to="parent@example.com", + ) + result = get_channel(settings) + assert result is None + + +class TestGetChannelNoExceptionOnMisconfiguration: + """Tests pour vérifier que la factory ne lève jamais d'exception.""" + + @pytest.mark.parametrize( + "settings_kwargs", + [ + {"enabled": True, "jid": None}, + {"enabled": True, "password": None}, + {"enabled": True, "to": None}, + {"enabled": True, "host": ""}, + {"enabled": True, "jid": None, "password": None, "to": None, "host": ""}, + {"enabled": False}, + ], + ids=[ + "missing_jid_only", + "missing_password_only", + "missing_to_only", + "missing_host_only", + "all_missing", + "disabled", + ], + ) + def test_get_channel_no_exception_on_misconfiguration( + self, + settings_kwargs: dict[str, Any], + ) -> None: + """Vérifie que get_channel ne lève jamais d'exception sur une configuration invalide. + + :param settings_kwargs: Paramètres pour XmppSettings potentiellement invalides. + :return: None + :rtype: None + """ + settings = XmppSettings(**settings_kwargs) + # Ne doit jamais lever d'exception + result = get_channel(settings) + assert result is None + + +class TestGetChannelNoSecretInWarningLog: + """Tests pour vérifier que les secrets ne fuient pas dans les logs.""" + + def test_get_channel_no_secret_in_warning_log(self, caplog: pytest.LogCaptureFixture) -> None: + """Vérifie que les valeurs sentinelles ne apparaissent pas dans les logs. + + Utilise des valeurs sentinelles pour éviter toute fuite de secrets réels. + + :param caplog: Fixture pour capturer les logs. + :return: None + :rtype: None + """ + sentinel_jid = "JID_SENTINEL@example.com" + sentinel_password = SecretStr("PASS_SENTINEL") + sentinel_to = "TO_SENTINEL@example.com" + + # Configuration incomplète : host manquant -> get_channel journalise + # un avertissement expurgé et retourne None. + settings = XmppSettings( + enabled=True, + jid=sentinel_jid, + password=sentinel_password, + host="", + to=sentinel_to, + ) + result = get_channel(settings) + assert result is None + + # Vérifie qu'un avertissement a été journalisé + assert len(caplog.records) > 0 + assert any(record.levelname == "WARNING" for record in caplog.records) + + # Vérifie que les valeurs sentinelles n'apparaissent pas dans les logs + log_text = "".join(record.message for record in caplog.records) + assert sentinel_jid not in log_text + assert sentinel_password.get_secret_value() not in log_text + assert sentinel_to not in log_text + + +class TestGetChannelDryRun: + """Tests pour le flag dry_run.""" + + def test_get_channel_dry_run(self) -> None: + """Vérifie que le flag dry_run est passé à travers et retourne un channel. + + :return: None + :rtype: None + """ + settings = XmppSettings( + enabled=True, + jid="bot@example.com", + password=SecretStr("pass"), + host="example.com", + to="parent@example.com", + ) + result = get_channel(settings, dry_run=True) + assert result is not None + assert isinstance(result, Channel) + + +class TestChannelImportsFromInit: + """Tests pour vérifier que les exports depuis __init__.py fonctionnent.""" + + def test_channel_imports_from_init(self) -> None: + """Vérifie que Channel, XmppChannel, SyncXmppChannel sont importables depuis pronote_sync.channels. + + :return: None + :rtype: None + """ + # Ces imports doivent réussir + assert Channel is not None + assert XmppChannel is not None + assert SyncXmppChannel is not None + assert get_channel is not None