feat: implement get_channel factory for XMPP channel (M10-U6)

get_channel(settings, dry_run=False) -> Channel | None with:
- enabled=False → None (no warning, no exception)
- enabled=True + missing jid/password/to/host → redacted warning log, None
- enabled=True + complete config → SyncXmppChannel instance
- Factory never raises exceptions (D2 non-blocking degradation)
- redact_secrets with extra_secrets=[password, jid, to] on warning logs

Re-exports Channel, XmppChannel, SyncXmppChannel from channels package.

19 unit tests covering disabled, misconfigured, complete, dry-run, and
secret-safe warning log scenarios.

Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
Co-authored-by: opencode/coder <coder@agents.invalid>
This commit is contained in:
2026-09-07 23:29:25 +02:00
parent 1962e13eba
commit e07a6d709d
2 changed files with 384 additions and 0 deletions

View File

@@ -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)

View File

@@ -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