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:
+3
-3
@@ -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
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user