Ajoute le mode d'authentification PRONOTE_AUTH_MODE=qr_token comme alternative au mode password pour les instances Pronote utilisant HubEduConnect/EduConnect où l'authentification par mot de passe échoue (CAPTCHA, MFA, flux SAML). Nouveaux éléments : - PronoteSettings : auth_mode, qr_code_file, qr_pin (SecretStr) - PronoteAuthState : persistance du token rotatif dans .pronote_auth_state.json (écriture atomique, permissions 0600, symlink-safe via O_EXCL|O_NOFOLLOW) - PronoteClient._connect_qr_token() : token_login avec creds persistés, qrcode_login pour l'enrôlement initial, export_credentials persisté après chaque login réussi - PronoteAuthRotationError : levée en cas d'échec de rotation du token, propagée sans wrapping à travers PronoteFetcher et fetch_step jusqu'à PipelineRunner.run() qui notifie via XMPP (si canal disponible et dry_run inactif) - _is_pronotepy_configured() mode-aware : qr_token ne requiert que PRONOTE_URL - _collect_auth_secrets() : redaction des secrets explicites (token, PIN, jeton QR) dans tous les logs du chemin d'authentification Documentation : - .env.example : PRONOTE_AUTH_MODE, PRONOTE_QR_CODE_FILE, PRONOTE_QR_PIN - AGENTS.md : contrat d'authentification QR code / token - Wiki GuidePronote : section enrôlement, exécutions suivantes, ré-enrôlement Tests (686 passés, couverture 94.87%) : - 5 tests config QR, 9 tests auth_state, 10 tests client QR, 3 tests propagation, 4 tests intégration rotation end-to-end, 4 tests fallback mode-aware - Tests de non-fuite : sentinelles distinctes pour token, PIN, jeton QR Co-authored-by: coder/litellm/coder <coder@agents.invalid>
211 lines
7.2 KiB
Python
211 lines
7.2 KiB
Python
"""Tests unitaires pour la configuration et le chargement des secrets.
|
|
|
|
Ce module vérifie que la configuration est chargée correctement depuis les
|
|
variables d'environnement, que les secrets sont masqués dans les représentations
|
|
textuelles et sérialisées, et que le rechargement fonctionne comme attendu.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
import pytest
|
|
from pydantic import SecretStr
|
|
|
|
from pronote_sync.config.env import load_settings
|
|
from pronote_sync.config.settings import PronoteSettings, Settings
|
|
|
|
if TYPE_CHECKING:
|
|
from _pytest.monkeypatch import MonkeyPatch
|
|
|
|
|
|
def test_env_prefix_loading(monkeypatch: MonkeyPatch) -> None:
|
|
"""Vérifie que les variables d'environnement avec préfixe sont chargées correctement.
|
|
|
|
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
|
:return: None
|
|
"""
|
|
monkeypatch.setenv("PRONOTE_USERNAME", "testuser")
|
|
settings = load_settings()
|
|
assert settings.pronote.username == "testuser"
|
|
|
|
|
|
def test_env_reload_via_load_settings(monkeypatch: MonkeyPatch) -> None:
|
|
"""Vérifie que le rechargement de la configuration lit les nouvelles valeurs.
|
|
|
|
Ce test garantit que les champs configurés avec ``default_factory`` créent
|
|
des instances fraîches à chaque appel de ``load_settings()``, permettant ainsi
|
|
un rechargement dynamique des variables d'environnement.
|
|
|
|
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
|
:return: None
|
|
"""
|
|
# Premier chargement (valeur par défaut None)
|
|
settings1 = load_settings()
|
|
assert settings1.pronote.username is None
|
|
|
|
# Modification de l'environnement
|
|
monkeypatch.setenv("PRONOTE_USERNAME", "newuser")
|
|
|
|
# Rechargement
|
|
settings2 = load_settings()
|
|
assert settings2.pronote.username == "newuser"
|
|
|
|
|
|
def test_secretstr_masking_ical_url() -> None:
|
|
"""Vérifie que l'URL iCal contenant un secret est masquée dans les représentations.
|
|
|
|
:return: None
|
|
"""
|
|
ical_url = "https://example.fr/ical?icalsecurise=SECRET_TOKEN" # pragma: allowlist secret
|
|
settings = Settings(pronote=PronoteSettings(ical_url=SecretStr(ical_url)))
|
|
|
|
# Vérification de la représentation string
|
|
str_repr = str(settings)
|
|
assert "SECRET_TOKEN" not in str_repr
|
|
assert "**********" in str_repr
|
|
|
|
# Vérification de la représentation repr
|
|
repr_repr = repr(settings)
|
|
assert "SECRET_TOKEN" not in repr_repr
|
|
assert "**********" in repr_repr
|
|
|
|
# Vérification de la sérialisation JSON
|
|
json_str = settings.model_dump_json()
|
|
assert "SECRET_TOKEN" not in json_str
|
|
assert "**********" in json_str
|
|
|
|
|
|
def test_secretstr_masking_password() -> None:
|
|
"""Vérifie que le mot de passe est masqué dans les représentations.
|
|
|
|
:return: None
|
|
"""
|
|
password = "mypass123" # pragma: allowlist secret
|
|
settings = Settings(pronote=PronoteSettings(password=SecretStr(password)))
|
|
|
|
# Vérification de la représentation string
|
|
str_repr = str(settings)
|
|
assert "mypass123" not in str_repr
|
|
assert "**********" in str_repr
|
|
|
|
# Vérification de la représentation repr
|
|
repr_repr = repr(settings)
|
|
assert "mypass123" not in repr_repr
|
|
assert "**********" in repr_repr
|
|
|
|
# Vérification de la sérialisation JSON
|
|
json_str = settings.model_dump_json()
|
|
assert "mypass123" not in json_str
|
|
assert "**********" in json_str
|
|
|
|
|
|
def test_no_singleton_import() -> None:
|
|
"""Vérifie que l'import du singleton settings lève une ImportError.
|
|
|
|
Ce test garantit que le pattern singleton a été supprimé et que la
|
|
configuration doit être chargée explicitement via ``load_settings()``.
|
|
|
|
:return: None
|
|
"""
|
|
with pytest.raises(ImportError):
|
|
# Attempt to import a non-existent attribute to trigger ImportError
|
|
# as required by the spec
|
|
from pronote_sync.config.settings import ( # type: ignore[attr-defined] # noqa: F401
|
|
settings,
|
|
)
|
|
|
|
|
|
def test_url_from_pronote_url_env_var(monkeypatch: MonkeyPatch) -> None:
|
|
"""Vérifie que PRONOTE_URL mappe au champ url via le préfixe PRONOTE_.
|
|
|
|
Ce test couvre la régression où PRONOTE_URL n'était pas mappé vers le
|
|
champ du modèle à cause du double préfixe PRONOTE_.
|
|
|
|
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
|
:return: None
|
|
"""
|
|
test_url = "https://example.index-education.net/pronote/parent.html"
|
|
monkeypatch.setenv("PRONOTE_URL", test_url)
|
|
settings = load_settings()
|
|
assert settings.pronote.url == test_url
|
|
|
|
|
|
def test_auth_mode_default_password() -> None:
|
|
"""Vérifie que ``auth_mode`` vaut ``"password"`` par défaut.
|
|
|
|
:return: None
|
|
"""
|
|
settings = PronoteSettings()
|
|
assert settings.auth_mode == "password"
|
|
|
|
|
|
def test_auth_mode_env_qr_token(monkeypatch: MonkeyPatch) -> None:
|
|
"""Vérifie que ``PRONOTE_AUTH_MODE=qr_token`` est chargé correctement.
|
|
|
|
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
|
:return: None
|
|
"""
|
|
monkeypatch.setenv("PRONOTE_AUTH_MODE", "qr_token")
|
|
settings = load_settings()
|
|
assert settings.pronote.auth_mode == "qr_token"
|
|
|
|
|
|
def test_qr_pin_loaded_as_secretstr_and_masked(monkeypatch: MonkeyPatch) -> None:
|
|
"""Vérifie que ``PRONOTE_QR_PIN`` est chargé en ``SecretStr`` et masqué.
|
|
|
|
Le PIN ne doit apparaître nulle part dans les représentations textuelles
|
|
(str, repr, JSON) : seul le masque ``**********`` est visible.
|
|
|
|
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
|
:return: None
|
|
"""
|
|
monkeypatch.setenv("PRONOTE_QR_PIN", "123456")
|
|
settings = load_settings()
|
|
assert isinstance(settings.pronote.qr_pin, SecretStr)
|
|
assert settings.pronote.qr_pin.get_secret_value() == "123456"
|
|
|
|
str_repr = str(settings)
|
|
assert "123456" not in str_repr
|
|
assert "**********" in str_repr
|
|
|
|
repr_repr = repr(settings)
|
|
assert "123456" not in repr_repr
|
|
assert "**********" in repr_repr
|
|
|
|
json_str = settings.model_dump_json()
|
|
assert "123456" not in json_str
|
|
assert "**********" in json_str
|
|
|
|
|
|
def test_qr_code_file_loaded_as_plain_string(monkeypatch: MonkeyPatch) -> None:
|
|
"""Vérifie que ``PRONOTE_QR_CODE_FILE`` est chargé comme chaîne simple.
|
|
|
|
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
|
:return: None
|
|
"""
|
|
monkeypatch.setenv("PRONOTE_QR_CODE_FILE", "/data/qr_code.png")
|
|
settings = load_settings()
|
|
assert isinstance(settings.pronote.qr_code_file, str)
|
|
assert settings.pronote.qr_code_file == "/data/qr_code.png"
|
|
|
|
|
|
def test_qr_pin_in_redaction_secrets(monkeypatch: MonkeyPatch) -> None:
|
|
"""Vérifie que le PIN QR est collecté pour la rédaction des secrets.
|
|
|
|
Le ``SecretStr`` du PIN doit figurer dans ``redaction_secrets()`` et sa
|
|
représentation textuelle doit rester masquée.
|
|
|
|
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
|
:return: None
|
|
"""
|
|
monkeypatch.setenv("PRONOTE_QR_PIN", "654321")
|
|
settings = load_settings()
|
|
secrets = settings.redaction_secrets()
|
|
assert settings.pronote.qr_pin in secrets
|
|
assert "654321" not in repr(settings.pronote.qr_pin)
|
|
assert "**********" in repr(settings.pronote.qr_pin)
|
|
|
|
|
|
# Ensure trailing newline
|