refactor(config): migrer les endpoints Pronote

Co-authored-by: Codex <codex@antoineve.me>
This commit is contained in:
2026-09-13 11:54:19 +02:00
parent 097a27fcf3
commit ee89f13d67
9 changed files with 293 additions and 77 deletions
+153 -3
View File
@@ -13,7 +13,14 @@ import pytest
from pydantic import SecretStr, ValidationError
from pronote_sync.config.env import load_settings
from pronote_sync.config.settings import AppSettings, PronoteSettings, Settings
from pronote_sync.config.settings import (
AppSettings,
BlogSettings,
CalDAVSettings,
ExternalEndpoint,
PronoteSettings,
Settings,
)
if TYPE_CHECKING:
from _pytest.monkeypatch import MonkeyPatch
@@ -73,7 +80,7 @@ def test_secretstr_masking_ical_url() -> None:
# Vérification de la sérialisation JSON
json_str = settings.model_dump_json()
assert "SECRET_TOKEN" not in json_str
assert "**********" in json_str
assert "REDACTED" in json_str
def test_secretstr_masking_password() -> None:
@@ -128,7 +135,150 @@ def test_url_from_pronote_url_env_var(monkeypatch: MonkeyPatch) -> 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
assert settings.pronote.endpoint is not None
assert settings.pronote.endpoint.url.get_secret_value() == test_url
@pytest.mark.parametrize(
"url",
[
"https://endpoint.example.test/api",
"http://localhost:8080/test",
"file:///tmp/fixture.ics",
],
)
def test_external_endpoint_accepts_supported_schemes(url: str) -> None:
"""Vérifie le socle commun des URL d'endpoints autorisés.
:param url: URL représentative du schéma à valider.
:return: None
"""
endpoint = ExternalEndpoint(url=SecretStr(url))
assert endpoint.url.get_secret_value() == url
@pytest.mark.parametrize(
"url",
["ftp://endpoint.example.test", "https:///missing-host", "https://host:bad", "file://"],
)
def test_external_endpoint_rejects_invalid_urls_without_leak(url: str) -> None:
"""Vérifie que le socle rejette les URL invalides sans les afficher.
:param url: URL invalide à refuser.
:return: None
"""
with pytest.raises(ValidationError) as exc_info:
ExternalEndpoint(url=SecretStr(url))
assert url not in str(exc_info.value)
def test_external_endpoint_is_immutable_and_redacted() -> None:
"""Vérifie le contrat immuable et expurgé du value object.
:return: None
"""
endpoint = ExternalEndpoint(url=SecretStr("https://user:secret@example.test/calendar"))
with pytest.raises(ValidationError):
endpoint.url = SecretStr("https://other.example.test")
assert "secret" not in endpoint.model_dump_json()
assert "REDACTED" in endpoint.model_dump_json()
def test_caldav_endpoint_loads_from_nested_environment(monkeypatch: MonkeyPatch) -> None:
"""Vérifie le chargement du nouvel endpoint CalDAV depuis l'environnement.
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
:return: None
"""
monkeypatch.setenv("CALDAV_ENDPOINT__URL", "https://caldav.example.test/dav")
settings = load_settings()
assert settings.caldav.endpoint is not None
assert settings.caldav.endpoint.url.get_secret_value() == "https://caldav.example.test/dav"
def test_caldav_legacy_url_migrates_with_warning() -> None:
"""Vérifie la migration temporaire du réglage CalDAV historique.
:return: None
"""
with pytest.warns(DeprecationWarning, match="CALDAV_URL"):
settings = CalDAVSettings(url=SecretStr("https://caldav.example.test/dav"))
assert settings.endpoint is not None
assert settings.endpoint.url.get_secret_value() == "https://caldav.example.test/dav"
def test_pronote_endpoints_load_from_nested_environment(monkeypatch: MonkeyPatch) -> None:
"""Vérifie le chargement des deux endpoints Pronote depuis l'environnement.
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
:return: None
"""
monkeypatch.setenv("PRONOTE_ENDPOINT__URL", "https://pronote.example.test/parent.html")
monkeypatch.setenv("PRONOTE_ICAL_ENDPOINT__URL", "file:///tmp/pronote.ics")
settings = load_settings()
assert settings.pronote.endpoint is not None
assert settings.pronote.ical_endpoint is not None
assert (
settings.pronote.endpoint.url.get_secret_value()
== "https://pronote.example.test/parent.html"
)
assert settings.pronote.ical_endpoint.url.get_secret_value() == "file:///tmp/pronote.ics"
@pytest.mark.parametrize(
("field", "url", "message"),
[
("endpoint", "http://pronote.example.test", "HTTPS requis"),
("ical_endpoint", "http://pronote.example.test/calendar", "HTTPS ou file requis"),
],
)
def test_pronote_endpoint_policy_rejects_insecure_url(field: str, url: str, message: str) -> None:
"""Vérifie la politique de transport des endpoints Pronote.
:param field: Nom du champ endpoint à alimenter.
:param url: URL non sûre à refuser.
:param message: Fragment attendu du message sûr.
:return: None
"""
endpoint = ExternalEndpoint(url=SecretStr(url))
with pytest.raises(ValidationError, match=message) as exc_info:
if field == "endpoint":
PronoteSettings(endpoint=endpoint)
else:
PronoteSettings(ical_endpoint=endpoint)
assert url not in str(exc_info.value)
def test_blog_endpoint_loads_from_nested_environment(monkeypatch: MonkeyPatch) -> None:
"""Vérifie le chargement du nouvel endpoint RSS depuis l'environnement.
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
:return: None
"""
monkeypatch.setenv("BLOG_ENDPOINT__URL", "https://blog.example.test/feed")
settings = load_settings()
assert settings.blog.endpoint.url.get_secret_value() == "https://blog.example.test/feed"
def test_blog_legacy_rss_url_migrates_with_warning() -> None:
"""Vérifie la migration temporaire du réglage RSS historique.
:return: None
"""
with pytest.warns(DeprecationWarning, match="BLOG_RSS_URL"):
settings = BlogSettings(rss_url="https://blog.example.test/feed")
assert settings.endpoint.url.get_secret_value() == "https://blog.example.test/feed"
def test_blog_endpoint_rejects_insecure_url_without_leak() -> None:
"""Vérifie que l'URL RSS HTTP est refusée sans être exposée.
:return: None
"""
url = "http://user:secret@blog.example.test/feed" # pragma: allowlist secret
with pytest.raises(ValidationError) as exc_info:
BlogSettings(endpoint=ExternalEndpoint(url=SecretStr(url)))
assert url not in str(exc_info.value)
def test_auth_mode_default_password() -> None:
+17 -15
View File
@@ -251,7 +251,7 @@ def test_fetch_agenda_auto_both_fail(mock_fetcher: PronoteFetcher) -> None:
:rtype: None
"""
# Disable pronotepy so fallback is None
mock_fetcher._settings.pronote.url = None
mock_fetcher._settings.pronote.endpoint = None
with (
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
@@ -280,7 +280,7 @@ def test_fetch_agenda_ical_mode_failure(mock_fetcher: PronoteFetcher) -> None:
"""
# Override settings to use ical mode explicitly and disable fallback
mock_fetcher._settings.pronote.agenda_source = "ical"
mock_fetcher._settings.pronote.url = None
mock_fetcher._settings.pronote.endpoint = None
with (
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
@@ -311,7 +311,7 @@ def test_fetch_agenda_pronotepy_mode_failure(mock_fetcher: PronoteFetcher) -> No
"""
# Override settings to use pronotepy mode explicitly and disable fallback
mock_fetcher._settings.pronote.agenda_source = "pronotepy"
mock_fetcher._settings.pronote.ical_url = None
mock_fetcher._settings.pronote.ical_endpoint = None
client = MagicMock()
client.get_lessons.side_effect = OSError("Pronote API error")
@@ -438,7 +438,7 @@ def test_fetch_homework_auto_both_fail(mock_fetcher: PronoteFetcher) -> None:
target_date = date(2025, 9, 10)
# Disable pronotepy so fallback is None
mock_fetcher._settings.pronote.url = None
mock_fetcher._settings.pronote.endpoint = None
with (
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
@@ -531,7 +531,7 @@ def test_no_secrets_in_error_messages(
:rtype: None
"""
# Disable pronotepy so fallback is None to trigger PipelineCriticalError
mock_fetcher._settings.pronote.url = None
mock_fetcher._settings.pronote.endpoint = None
with (
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
@@ -628,8 +628,8 @@ def test_fetch_agenda_no_source_configured_raises(mock_fetcher: PronoteFetcher)
:rtype: None
"""
# Disable both sources
mock_fetcher._settings.pronote.ical_url = None
mock_fetcher._settings.pronote.url = None
mock_fetcher._settings.pronote.ical_endpoint = None
mock_fetcher._settings.pronote.endpoint = None
with pytest.raises(PipelineCriticalError) as exc_info:
mock_fetcher.fetch_agenda()
@@ -912,8 +912,10 @@ def test_fetch_agenda_ical_url_none_raises_value_error(mock_fetcher: PronoteFetc
:return: None
:rtype: None
"""
mock_fetcher._settings.pronote.ical_url = None
with pytest.raises(ValueError, match="PRONOTE_ICAL_URL est requis pour la source iCal"):
mock_fetcher._settings.pronote.ical_endpoint = None
with pytest.raises(
ValueError, match="PRONOTE_ICAL_ENDPOINT__URL est requis pour la source iCal"
):
mock_fetcher._fetch_agenda_ical()
@@ -928,7 +930,7 @@ def test_agenda_sources_auto_only_pronotepy_configured(mock_fetcher: PronoteFetc
:rtype: None
"""
mock_fetcher._settings.pronote.agenda_source = "auto"
mock_fetcher._settings.pronote.ical_url = None
mock_fetcher._settings.pronote.ical_endpoint = None
primary, fallback = mock_fetcher._agenda_sources()
assert primary == "pronotepy"
assert fallback is None
@@ -1020,7 +1022,7 @@ def test_homework_sources_explicit_ical_mode_strict(mock_fetcher: PronoteFetcher
assert fallback is None
# Without pronotepy configured
mock_fetcher._settings.pronote.url = None
mock_fetcher._settings.pronote.endpoint = None
primary, fallback = mock_fetcher._homework_sources()
assert primary == "ical"
assert fallback is None
@@ -1046,7 +1048,7 @@ def test_homework_sources_explicit_pronotepy_mode_strict(
assert fallback is None
# Without ical configured
mock_fetcher._settings.pronote.ical_url = None
mock_fetcher._settings.pronote.ical_endpoint = None
primary, fallback = mock_fetcher._homework_sources()
assert primary == "pronotepy"
assert fallback is None
@@ -1063,7 +1065,7 @@ def test_homework_sources_auto_only_pronotepy_configured(mock_fetcher: PronoteFe
:rtype: None
"""
mock_fetcher._settings.pronote.homework_source = "auto"
mock_fetcher._settings.pronote.ical_url = None
mock_fetcher._settings.pronote.ical_endpoint = None
primary, fallback = mock_fetcher._homework_sources()
assert primary == "pronotepy"
assert fallback is None
@@ -1079,8 +1081,8 @@ def test_homework_sources_auto_no_source_configured_raises(mock_fetcher: Pronote
:rtype: None
"""
mock_fetcher._settings.pronote.homework_source = "auto"
mock_fetcher._settings.pronote.ical_url = None
mock_fetcher._settings.pronote.url = None
mock_fetcher._settings.pronote.ical_endpoint = None
mock_fetcher._settings.pronote.endpoint = None
with pytest.raises(PipelineCriticalError) as exc_info:
mock_fetcher._homework_sources()