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:
@@ -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