fix(config): valider l'alias CalDAV legacy et retirer file:// de fetch_ical

Supprime le contournement model_construct : l'alias CALDAV_URL est désormais validé comme le champ canonique (userinfo, host et port rejetés). Retire le support file:// de fetch_ical et corrige l'exemple résiduel du guide. Ajoute les tests négatifs de l'alias legacy et adapte le test de sécurité CalDAV au contrat durci.

Refs #63
This commit is contained in:
2026-09-13 15:46:07 +02:00
parent c89113ba36
commit 5a3e251ad6
6 changed files with 89 additions and 76 deletions
+3 -3
View File
@@ -140,7 +140,7 @@
"filename": "GUIDE_DEV_PYTHON.md",
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
"is_verified": false,
"line_number": 5198
"line_number": 5205
}
],
"tests/unit/test_caldav_gateway.py": [
@@ -172,7 +172,7 @@
"filename": "tests/unit/test_caldav_security.py",
"hashed_secret": "6b554cd7b7e0115065fb4907307a74f1902154d4",
"is_verified": false,
"line_number": 28
"line_number": 29
}
],
"tests/unit/test_xmpp_channel.py": [
@@ -185,5 +185,5 @@
}
]
},
"generated_at": "2026-09-13T13:32:54Z"
"generated_at": "2026-09-13T13:46:01Z"
}
+19 -12
View File
@@ -1632,11 +1632,14 @@ from ..models.agenda import RawCalendarData
def fetch_ical(url: str, timeout: int = 20) -> str:
"""
Récupère le flux iCal depuis une URL Pronote.
Récupère le flux iCal depuis une URL Pronote (HTTPS uniquement).
Inspiré de src/sources/pronote/fetch.ts.
Le schéma ``file://`` n'est **pas** supporté : les fixtures locales sont
injectées via un mock HTTP (``responses``) dans les tests.
Args:
url: URL du flux iCal (peut être file:// pour les tests).
url: URL HTTPS du flux iCal (avec token ``icalsecurise``).
timeout: Timeout en secondes (défaut: 20s).
Returns:
@@ -1651,16 +1654,7 @@ def fetch_ical(url: str, timeout: int = 20) -> str:
"user-agent": "pronote-sync",
}
# Gestion des URLs file:// pour les tests
if url.startswith("file://"):
import pathlib
file_path = pathlib.Path(url.replace("file://", ""))
content = file_path.read_text(encoding="utf-8")
if "BEGIN:VCALENDAR" not in content:
raise ValueError(f"Fichier iCal invalide: {redact_url(url)}")
return content
# Récupération HTTP
# Récupération HTTP (aucun accès fichier local)
try:
response = requests.get(
url,
@@ -1686,6 +1680,19 @@ def fetch_ical(url: str, timeout: int = 20) -> str:
return content
# Test unitaire réseau mocké (aucun accès fichier)
@responses.activate
def test_fetch_ical_https() -> None:
responses.add(
responses.GET,
"https://pronote.example.test/ical.ics",
body="BEGIN:VCALENDAR\nVERSION:2.0\nEND:VCALENDAR",
status=200,
)
content = fetch_ical("https://pronote.example.test/ical.ics")
assert content.startswith("BEGIN:VCALENDAR")
def get_calendar_name(raw_ical: str) -> str | None:
"""
Extrait le nom du calendrier depuis X-WR-CALNAME.
+7 -21
View File
@@ -244,13 +244,12 @@ class CalDAVSettings(BaseSettings):
def _migrate_legacy_url(cls, data: object) -> object:
"""Migre ``url`` vers l'endpoint commun avec un avertissement.
L'alias historique ``CALDAV_URL`` conserve ses sémantiques passées
pendant la transition : lorsqu'il embarque des identifiants
(``user:pass@hôte``), l'endpoint est construit sans revalidation pour
ne pas casser une configuration existante, alors que le contrat
canonique ``CALDAV_ENDPOINT__URL`` refuse désormais les credentials
embarqués. La politique de transport (HTTPS, ou HTTP loopback
uniquement avec ``allow_insecure_http``) reste appliquée ensuite.
L'alias historique ``CALDAV_URL`` est migré via le chemin de validation
canonique : il est donc soumis exactement aux mêmes règles que
``CALDAV_ENDPOINT__URL`` (schémas réseau uniquement, hôte obligatoire,
port valide, credentials embarqués refusés). La politique de transport
(HTTPS, ou HTTP loopback uniquement avec ``allow_insecure_http``) reste
appliquée ensuite.
:param data: Données brutes du modèle.
:return: Données complétées avec ``endpoint`` si nécessaire.
@@ -265,20 +264,7 @@ class CalDAVSettings(BaseSettings):
stacklevel=2,
)
if migrated_data.get("endpoint") is None:
legacy_url = migrated_data["url"]
raw_url = (
legacy_url.get_secret_value()
if isinstance(legacy_url, SecretStr)
else str(legacy_url)
)
parsed = urlparse(raw_url)
if parsed.username is not None or parsed.password is not None:
# Alias obsolète : grandfathered, on préserve l'URL telle quelle.
migrated_data["endpoint"] = ExternalEndpoint.model_construct(
url=legacy_url if isinstance(legacy_url, SecretStr) else SecretStr(legacy_url)
)
else:
migrated_data["endpoint"] = {"url": legacy_url}
migrated_data["endpoint"] = {"url": migrated_data["url"]}
return migrated_data
@model_validator(mode="after")
+9 -25
View File
@@ -1,7 +1,7 @@
"""Récupération et parsing du flux iCal Pronote.
Ce module fournit le téléchargement du flux iCal Pronote (via HTTP ou
``file://`` pour les tests) ainsi que son parsing en modèles : cours
Ce module fournit le téléchargement réseau du flux iCal Pronote ainsi que
son parsing en modèles : cours
(:class:`~pronote_sync.models.agenda.Lesson`), événements scolaires
(:class:`~pronote_sync.models.agenda.SchoolEvent`) et devoirs
(:class:`~pronote_sync.models.homework.Homework`).
@@ -15,10 +15,8 @@ from __future__ import annotations
import hashlib
import re
import unicodedata
import urllib.parse
from datetime import date, datetime
from html import unescape
from pathlib import Path
from typing import NamedTuple, TypedDict
import requests
@@ -70,35 +68,21 @@ class ParsedHomeworkBlock(NamedTuple):
def fetch_ical(url: str, timeout: int = 20) -> str:
"""Récupère le contenu brut d'un flux iCal Pronote.
"""Récupère le contenu brut d'un flux iCal Pronote par HTTPS.
Gère les URLs ``file://`` pour les tests locaux (le chemin est
décodé de l'échappement URI, ex. ``%20`` → espace) et valide que
le flux commence bien par ``BEGIN:VCALENDAR``. Toutes les erreurs
sont relancées avec un message dont les secrets (token
``icalsecurise``) sont masqués.
Le schéma ``file://`` n'est pas supporté : les fixtures locales doivent
être injectées via un mock HTTP (``responses``) dans les tests. Valide que
le flux commence bien par ``BEGIN:VCALENDAR``. Toutes les erreurs sont
relancées avec un message dont les secrets (token ``icalsecurise``) sont
masqués.
:param url: URL du flux iCal (avec token ``icalsecurise``) ou chemin ``file://``.
:param url: URL HTTPS du flux iCal (avec token ``icalsecurise``).
:param timeout: Timeout HTTP en secondes (défaut : 20).
:return: Contenu brut du flux iCal.
:rtype: str
:raises OSError: Si le fichier local ``file://`` est illisible.
:raises requests.RequestException: Si la récupération HTTP échoue.
:raises ValueError: Si le flux ne commence pas par ``BEGIN:VCALENDAR``.
"""
if url.startswith("file://"):
parsed_url = urllib.parse.urlparse(url)
path = Path(urllib.parse.unquote(parsed_url.path))
try:
content = path.read_text(encoding="utf-8")
except OSError as exc:
raise OSError(
f"Impossible de lire le fichier iCal {redact_url(url)} : {redact_exception(exc)}"
) from exc
if not content.lstrip().startswith("BEGIN:VCALENDAR"):
raise ValueError(f"Fichier iCal invalide (pas de BEGIN:VCALENDAR) : {redact_url(url)}")
return content
try:
response = requests.get(url, headers=_HEADERS, timeout=timeout)
response.raise_for_status()
+25 -15
View File
@@ -15,7 +15,7 @@ from unittest.mock import MagicMock
import pytest
from pydantic import SecretStr, ValidationError
from pronote_sync.config.settings import CalDAVSettings
from pronote_sync.config.settings import CalDAVSettings, ExternalEndpoint
from pronote_sync.errors import PronoteSyncError
from pronote_sync.sync.caldav import CalDAVGateway
@@ -25,6 +25,7 @@ if TYPE_CHECKING:
# Sentinelles pour détecter les fuites de secrets dans les tests
SENTINEL_URL = "https://user:pass-super-secret-12345@caldav.example.com/secret-path/"
SENTINEL_ENDPOINT_URL = "https://caldav.example.com/secret-path/?token=pass-super-secret-12345"
SENTINEL_PASSWORD = "super-secret-password-67890"
SENTINEL_HTTP_URL = "http://caldav.example.com/"
SENTINEL_HTTP_LOCALHOST = "http://localhost:5232/caldav/"
@@ -37,13 +38,13 @@ class TestCalDAVSettingsSecurity:
def test_url_redaction_in_repr(self) -> None:
"""Vérifie que l'URL brute n'apparaît pas dans repr(settings)."""
settings = CalDAVSettings(
url=SecretStr(SENTINEL_URL),
endpoint=ExternalEndpoint(url=SecretStr(SENTINEL_ENDPOINT_URL)),
username="test-user",
password=SecretStr(SENTINEL_PASSWORD),
calendar_path="/cal/",
)
repr_str = repr(settings)
assert SENTINEL_URL not in repr_str
assert SENTINEL_ENDPOINT_URL not in repr_str
assert "pass-super-secret-12345" not in repr_str
# L'URL est masquée par redact_url qui retourne REDACTED_URL ou une URL avec REDACTED
assert "REDACTED" in repr_str or "**********" in repr_str
@@ -51,13 +52,13 @@ class TestCalDAVSettingsSecurity:
def test_url_redaction_in_str(self) -> None:
"""Vérifie que l'URL brute n'apparaît pas dans str(settings)."""
settings = CalDAVSettings(
url=SecretStr(SENTINEL_URL),
endpoint=ExternalEndpoint(url=SecretStr(SENTINEL_ENDPOINT_URL)),
username="test-user",
password=SecretStr(SENTINEL_PASSWORD),
calendar_path="/cal/",
)
str_str = str(settings)
assert SENTINEL_URL not in str_str
assert SENTINEL_ENDPOINT_URL not in str_str
assert "pass-super-secret-12345" not in str_str
# L'URL est masquée par redact_url
assert "REDACTED" in str_str or "**********" in str_str
@@ -65,7 +66,7 @@ class TestCalDAVSettingsSecurity:
def test_url_redaction_in_model_dump(self) -> None:
"""Vérifie que l'URL brute n'apparaît pas dans model_dump()."""
settings = CalDAVSettings(
url=SecretStr(SENTINEL_URL),
endpoint=ExternalEndpoint(url=SecretStr(SENTINEL_ENDPOINT_URL)),
username="test-user",
password=SecretStr(SENTINEL_PASSWORD),
calendar_path="/cal/",
@@ -74,7 +75,7 @@ class TestCalDAVSettingsSecurity:
# Vérifie que l'URL n'est pas dans les valeurs du dict
for value in dumped.values():
if isinstance(value, str):
assert SENTINEL_URL not in value
assert SENTINEL_ENDPOINT_URL not in value
assert "pass-super-secret-12345" not in value
# Vérifie que la version rédigée est présente
assert "REDACTED" in str(dumped)
@@ -201,7 +202,7 @@ class TestCalDAVGatewaySecurity:
raise Exception(f"Connection failed to {SENTINEL_URL}?token={SENTINEL_PASSWORD}")
settings = CalDAVSettings(
url=SecretStr(SENTINEL_URL),
endpoint=ExternalEndpoint(url=SecretStr(SENTINEL_ENDPOINT_URL)),
username="test-user",
password=SecretStr(SENTINEL_PASSWORD),
calendar_path="/cal/",
@@ -215,12 +216,14 @@ class TestCalDAVGatewaySecurity:
# Vérifie que le message d'erreur ne contient pas les sentinelles
error_msg = str(exc_info.value)
assert SENTINEL_URL not in error_msg
assert SENTINEL_ENDPOINT_URL not in error_msg
assert SENTINEL_PASSWORD not in error_msg
# Vérifie que les logs ne contiennent pas les sentinelles
for record in caplog.records:
log_msg = record.getMessage()
assert SENTINEL_URL not in log_msg
assert SENTINEL_ENDPOINT_URL not in log_msg
assert SENTINEL_PASSWORD not in log_msg
def test_exception_cause_and_context_is_none(self) -> None:
@@ -230,7 +233,7 @@ class TestCalDAVGatewaySecurity:
raise Exception(f"Connection failed to {SENTINEL_URL}?token={SENTINEL_PASSWORD}")
settings = CalDAVSettings(
url=SecretStr(SENTINEL_URL),
endpoint=ExternalEndpoint(url=SecretStr(SENTINEL_ENDPOINT_URL)),
username="test-user",
password=SecretStr(SENTINEL_PASSWORD),
calendar_path="/cal/",
@@ -246,7 +249,7 @@ class TestCalDAVGatewaySecurity:
def test_logs_redacted_on_list_managed_events_error(self, caplog: LogCaptureFixture) -> None:
"""Vérifie que les logs sont expurgés lors d'une erreur dans list_managed_events."""
settings = CalDAVSettings(
url=SecretStr(SENTINEL_URL),
endpoint=ExternalEndpoint(url=SecretStr(SENTINEL_ENDPOINT_URL)),
username="test-user",
password=SecretStr(SENTINEL_PASSWORD),
calendar_path="/cal/",
@@ -274,12 +277,14 @@ class TestCalDAVGatewaySecurity:
# Vérifie que le message d'erreur ne contient pas les sentinelles
error_msg = str(exc_info.value)
assert SENTINEL_URL not in error_msg
assert SENTINEL_ENDPOINT_URL not in error_msg
assert SENTINEL_PASSWORD not in error_msg
# Vérifie que les logs ne contiennent pas les sentinelles
for record in caplog.records:
log_msg = record.getMessage()
assert SENTINEL_URL not in log_msg
assert SENTINEL_ENDPOINT_URL not in log_msg
assert SENTINEL_PASSWORD not in log_msg
# Vérifie que l'exception n'est chaînée à aucune exception brute
@@ -289,7 +294,7 @@ class TestCalDAVGatewaySecurity:
def test_logs_redacted_on_upsert_event_error(self, caplog: LogCaptureFixture) -> None:
"""Vérifie que les logs sont expurgés lors d'une erreur dans upsert_event."""
settings = CalDAVSettings(
url=SecretStr(SENTINEL_URL),
endpoint=ExternalEndpoint(url=SecretStr(SENTINEL_ENDPOINT_URL)),
username="test-user",
password=SecretStr(SENTINEL_PASSWORD),
calendar_path="/cal/",
@@ -312,12 +317,14 @@ class TestCalDAVGatewaySecurity:
# Vérifie que le message d'erreur ne contient pas les sentinelles
error_msg = str(exc_info.value)
assert SENTINEL_URL not in error_msg
assert SENTINEL_ENDPOINT_URL not in error_msg
assert SENTINEL_PASSWORD not in error_msg
# Vérifie que les logs ne contiennent pas les sentinelles
for record in caplog.records:
log_msg = record.getMessage()
assert SENTINEL_URL not in log_msg
assert SENTINEL_ENDPOINT_URL not in log_msg
assert SENTINEL_PASSWORD not in log_msg
# Vérifie que l'exception n'est chaînée à aucune exception brute
@@ -327,7 +334,7 @@ class TestCalDAVGatewaySecurity:
def test_logs_redacted_on_delete_event_error(self, caplog: LogCaptureFixture) -> None:
"""Vérifie que les logs sont expurgés lors d'une erreur dans delete_event."""
settings = CalDAVSettings(
url=SecretStr(SENTINEL_URL),
endpoint=ExternalEndpoint(url=SecretStr(SENTINEL_ENDPOINT_URL)),
username="test-user",
password=SecretStr(SENTINEL_PASSWORD),
calendar_path="/cal/",
@@ -349,12 +356,14 @@ class TestCalDAVGatewaySecurity:
# Vérifie que le message d'erreur ne contient pas les sentinelles
error_msg = str(exc_info.value)
assert SENTINEL_URL not in error_msg
assert SENTINEL_ENDPOINT_URL not in error_msg
assert SENTINEL_PASSWORD not in error_msg
# Vérifie que les logs ne contiennent pas les sentinelles
for record in caplog.records:
log_msg = record.getMessage()
assert SENTINEL_URL not in log_msg
assert SENTINEL_ENDPOINT_URL not in log_msg
assert SENTINEL_PASSWORD not in log_msg
# Vérifie que l'exception n'est chaînée à aucune exception brute
@@ -364,7 +373,7 @@ class TestCalDAVGatewaySecurity:
def test_redacted_url_stored_in_gateway(self) -> None:
"""Vérifie que l'URL rédigée est stockée sur l'instance de la passerelle."""
settings = CalDAVSettings(
url=SecretStr(SENTINEL_URL),
endpoint=ExternalEndpoint(url=SecretStr(SENTINEL_ENDPOINT_URL)),
username="test-user",
password=SecretStr(SENTINEL_PASSWORD),
calendar_path="/cal/",
@@ -372,8 +381,9 @@ class TestCalDAVGatewaySecurity:
gateway = CalDAVGateway(settings)
# Vérifie que l'URL rédigée est stockée
assert gateway._redacted_url is not None
assert SENTINEL_URL not in gateway._redacted_url
assert SENTINEL_ENDPOINT_URL not in gateway._redacted_url
assert "pass-super-secret-12345" not in gateway._redacted_url
assert "REDACTED" in gateway._redacted_url
# Vérifie que l'URL brute n'est pas stockée en clair
assert gateway._url_secret is not None
assert SENTINEL_URL not in str(gateway._url_secret)
assert SENTINEL_ENDPOINT_URL not in str(gateway._url_secret)
+26
View File
@@ -212,6 +212,32 @@ def test_caldav_legacy_url_migrates_with_warning() -> None:
assert settings.endpoint.url.get_secret_value() == "https://caldav.example.test/dav"
@pytest.mark.parametrize(
"url",
[
"https://user:pass@caldav.example.test/dav", # pragma: allowlist secret
"https:///missing-host",
"https://host:bad",
"file:///tmp/fixture.ics",
],
)
def test_caldav_legacy_url_alias_rejects_invalid_urls(url: str) -> None:
"""L'alias obsolète ``CALDAV_URL`` ne contourne pas la validation canonique.
Contrat corrigé : ``ExternalEndpoint.model_construct`` n'est plus utilisé
pour grandfathered les identifiants embarqués. L'alias est migré via le
chemin de validation normal, donc les credentials embarqués, l'hôte
manquant, le port invalide et le schéma ``file://`` sont refusés,
exactement comme pour ``CALDAV_ENDPOINT__URL``.
:param url: URL invalide fournie via l'alias historique.
:return: None
"""
with pytest.raises(ValidationError):
with pytest.warns(DeprecationWarning, match="CALDAV_URL"):
CalDAVSettings(url=SecretStr(url))
def test_pronote_endpoints_load_from_nested_environment(monkeypatch: MonkeyPatch) -> None:
"""Vérifie le chargement des deux endpoints Pronote depuis l'environnement.