feat: add TLS policy and field constraints to XmppSettings (M10-U1)
Enforce TLS on non-loopback hosts via @field_validator on use_tls,
and add unconditional Field constraints on port (1-65535) and timeout (>0).
Security:
- use_tls=False rejected outside {localhost, 127.0.0.1, ::1} regardless of enabled
- field_validator on use_tls (not model_validator) prevents raw config leakage
- hide_input_in_errors=True as defense-in-depth
- Validation error messages contain no secrets (jid, password, recipient)
16 unit tests covering port/timeout bounds, TLS policy, loopback, secret safety.
Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
Co-authored-by: opencode/coder <coder@agents.invalid>
This commit is contained in:
199
tests/unit/test_xmpp_settings.py
Normal file
199
tests/unit/test_xmpp_settings.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""Tests unitaires pour les contraintes de sécurité et validateurs de XmppSettings.
|
||||
|
||||
Ce module valide les contraintes de sécurité et les validateurs qui seront ajoutés
|
||||
à la classe XmppSettings dans pronote_sync/config/settings.py.
|
||||
|
||||
Les tests doivent être initialement en échec (RED) car les contraintes et validateurs
|
||||
n'existent pas encore dans le code de production.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pydantic import SecretStr, ValidationError
|
||||
|
||||
from pronote_sync.config.settings import XmppSettings
|
||||
|
||||
|
||||
class TestPortConstraints:
|
||||
"""Tests des contraintes sur le champ port."""
|
||||
|
||||
def test_port_below_1_rejected(self) -> None:
|
||||
"""Vérifie que port < 1 est rejeté.
|
||||
|
||||
:raises ValidationError: Si le port est inférieur à 1.
|
||||
"""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
XmppSettings(port=0)
|
||||
assert "port" in str(exc_info.value).lower()
|
||||
|
||||
def test_port_above_65535_rejected(self) -> None:
|
||||
"""Vérifie que port > 65535 est rejeté.
|
||||
|
||||
:raises ValidationError: Si le port est supérieur à 65535.
|
||||
"""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
XmppSettings(port=70000)
|
||||
assert "port" in str(exc_info.value).lower()
|
||||
|
||||
def test_port_default_5222(self) -> None:
|
||||
"""Vérifie que la valeur par défaut de port est 5222.
|
||||
|
||||
:return: Vérifie que XmppSettings().port == 5222.
|
||||
:rtype: None
|
||||
"""
|
||||
settings = XmppSettings()
|
||||
assert settings.port == 5222
|
||||
|
||||
def test_port_valid(self) -> None:
|
||||
"""Vérifie que les ports valides sont acceptés.
|
||||
|
||||
:return: Vérifie que XmppSettings(port=5222) et XmppSettings(port=5223) sont valides.
|
||||
:rtype: None
|
||||
"""
|
||||
settings1 = XmppSettings(port=5222)
|
||||
assert settings1.port == 5222
|
||||
|
||||
settings2 = XmppSettings(port=5223)
|
||||
assert settings2.port == 5223
|
||||
|
||||
|
||||
class TestTimeoutConstraints:
|
||||
"""Tests des contraintes sur le champ timeout."""
|
||||
|
||||
def test_timeout_zero_rejected(self) -> None:
|
||||
"""Vérifie que timeout = 0 est rejeté.
|
||||
|
||||
:raises ValidationError: Si le timeout est égal à 0.
|
||||
"""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
XmppSettings(timeout=0)
|
||||
assert "timeout" in str(exc_info.value).lower()
|
||||
|
||||
def test_timeout_negative_rejected(self) -> None:
|
||||
"""Vérifie que timeout < 0 est rejeté.
|
||||
|
||||
:raises ValidationError: Si le timeout est négatif.
|
||||
"""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
XmppSettings(timeout=-1)
|
||||
assert "timeout" in str(exc_info.value).lower()
|
||||
|
||||
def test_timeout_default_30(self) -> None:
|
||||
"""Vérifie que la valeur par défaut de timeout est 30.
|
||||
|
||||
:return: Vérifie que XmppSettings().timeout == 30.
|
||||
:rtype: None
|
||||
"""
|
||||
settings = XmppSettings()
|
||||
assert settings.timeout == 30
|
||||
|
||||
def test_timeout_positive_valid(self) -> None:
|
||||
"""Vérifie que les valeurs positives de timeout sont acceptées.
|
||||
|
||||
:return: Vérifie que XmppSettings(timeout=10) est valide.
|
||||
:rtype: None
|
||||
"""
|
||||
settings = XmppSettings(timeout=10)
|
||||
assert settings.timeout == 10
|
||||
|
||||
|
||||
class TestTlsPolicy:
|
||||
"""Tests de la politique TLS pour le champ use_tls."""
|
||||
|
||||
def test_use_tls_false_with_remote_host_rejected(self) -> None:
|
||||
"""Vérifie que use_tls=False avec un hôte distant est rejeté.
|
||||
|
||||
:raises ValidationError: Si use_tls=False et host n'est pas une boucle locale.
|
||||
"""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
XmppSettings(use_tls=False, host="talk.example.com")
|
||||
assert "use_tls" in str(exc_info.value).lower() or "tls" in str(exc_info.value).lower()
|
||||
|
||||
def test_use_tls_false_with_remote_host_rejected_when_enabled(self) -> None:
|
||||
"""Vérifie que la politique TLS s'applique même quand le canal est activé."""
|
||||
with pytest.raises(ValidationError):
|
||||
XmppSettings(enabled=True, use_tls=False, host="talk.example.com")
|
||||
|
||||
def test_use_tls_false_with_localhost_allowed(self) -> None:
|
||||
"""Vérifie que use_tls=False avec localhost est autorisé.
|
||||
|
||||
:return: Vérifie que XmppSettings(use_tls=False, host="localhost") est valide.
|
||||
:rtype: None
|
||||
"""
|
||||
settings = XmppSettings(use_tls=False, host="localhost")
|
||||
assert settings.use_tls is False
|
||||
assert settings.host == "localhost"
|
||||
|
||||
def test_use_tls_false_with_127_allowed(self) -> None:
|
||||
"""Vérifie que use_tls=False avec 127.0.0.1 est autorisé.
|
||||
|
||||
:return: Vérifie que XmppSettings(use_tls=False, host="127.0.0.1") est valide.
|
||||
:rtype: None
|
||||
"""
|
||||
settings = XmppSettings(use_tls=False, host="127.0.0.1")
|
||||
assert settings.use_tls is False
|
||||
assert settings.host == "127.0.0.1"
|
||||
|
||||
def test_use_tls_false_with_ipv6_loopback_allowed(self) -> None:
|
||||
"""Vérifie que use_tls=False avec ::1 est autorisé.
|
||||
|
||||
:return: Vérifie que XmppSettings(use_tls=False, host="::1") est valide.
|
||||
:rtype: None
|
||||
"""
|
||||
settings = XmppSettings(use_tls=False, host="::1")
|
||||
assert settings.use_tls is False
|
||||
assert settings.host == "::1"
|
||||
|
||||
def test_use_tls_true_with_remote_host_allowed(self) -> None:
|
||||
"""Vérifie que use_tls=True avec un hôte distant est autorisé.
|
||||
|
||||
:return: Vérifie que XmppSettings(use_tls=True, host="talk.example.com") est valide.
|
||||
:rtype: None
|
||||
"""
|
||||
settings = XmppSettings(use_tls=True, host="talk.example.com")
|
||||
assert settings.use_tls is True
|
||||
assert settings.host == "talk.example.com"
|
||||
|
||||
def test_use_tls_true_with_empty_host_allowed(self) -> None:
|
||||
"""Vérifie que use_tls=True avec host vide est autorisé.
|
||||
|
||||
:return: Vérifie que XmppSettings(use_tls=True, host="") est valide.
|
||||
:rtype: None
|
||||
"""
|
||||
settings = XmppSettings(use_tls=True, host="")
|
||||
assert settings.use_tls is True
|
||||
assert settings.host == ""
|
||||
|
||||
|
||||
class TestNoSecretInErrorMessages:
|
||||
"""Tests de sécurité : vérifie que les messages d'erreur ne contiennent pas de secrets."""
|
||||
|
||||
def test_no_secret_in_validation_error(self) -> None:
|
||||
"""Vérifie que les messages de ValidationError ne contiennent pas de secrets.
|
||||
|
||||
Crée une instance avec des valeurs sensibles et vérifie que l'erreur de validation
|
||||
ne contient pas ces valeurs dans son message.
|
||||
|
||||
:raises ValidationError: Si use_tls=False avec un hôte non-local.
|
||||
:return: Vérifie que le message d'erreur ne contient pas les secrets.
|
||||
:rtype: None
|
||||
"""
|
||||
# Utilisation de valeurs sentinelles pour éviter toute fuite
|
||||
sentinel_jid = "test_jid@example.com"
|
||||
sentinel_password = SecretStr("test_password_123")
|
||||
sentinel_to = "test_to@example.com"
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
XmppSettings(
|
||||
use_tls=False,
|
||||
host="talk.example.com",
|
||||
jid=sentinel_jid,
|
||||
password=sentinel_password,
|
||||
to=sentinel_to,
|
||||
)
|
||||
|
||||
error_message = str(exc_info.value).lower()
|
||||
# Vérifie que les valeurs sensibles ne sont pas dans le message d'erreur
|
||||
assert "test_jid@example.com" not in error_message
|
||||
assert "test_password_123" not in error_message
|
||||
assert "test_to@example.com" not in error_message
|
||||
assert "secret" not in error_message
|
||||
Reference in New Issue
Block a user