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
+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.