"""Tests de sécurité pour la passerelle CalDAV et sa configuration. Ce module vérifie que les secrets (URL, mot de passe) ne fuient jamais dans les représentations textuelles, les logs, les messages d'erreur ou les chaînages d'exceptions de la configuration CalDAV et de la passerelle. """ from __future__ import annotations import logging from datetime import datetime from typing import TYPE_CHECKING, Any from unittest.mock import MagicMock import pytest from pydantic import SecretStr, ValidationError from pronote_sync.config.settings import CalDAVSettings from pronote_sync.errors import PronoteSyncError from pronote_sync.sync.caldav import CalDAVGateway if TYPE_CHECKING: from _pytest.logging import LogCaptureFixture # 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_PASSWORD = "super-secret-password-67890" SENTINEL_HTTP_URL = "http://caldav.example.com/" SENTINEL_HTTP_LOCALHOST = "http://localhost:5232/caldav/" SENTINEL_HTTP_NON_LOOPBACK = "http://insecure.example.com/caldav/" class TestCalDAVSettingsSecurity: """Tests de sécurité pour la configuration CalDAV (CalDAVSettings).""" 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), username="test-user", password=SecretStr(SENTINEL_PASSWORD), calendar_path="/cal/", ) repr_str = repr(settings) assert SENTINEL_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 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), username="test-user", password=SecretStr(SENTINEL_PASSWORD), calendar_path="/cal/", ) str_str = str(settings) assert SENTINEL_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 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), username="test-user", password=SecretStr(SENTINEL_PASSWORD), calendar_path="/cal/", ) dumped = settings.model_dump() # 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 "pass-super-secret-12345" not in value # Vérifie que la version rédigée est présente assert "REDACTED" in str(dumped) def test_password_not_in_repr(self) -> None: """Vérifie que le mot de passe n'apparaît pas dans repr(settings).""" settings = CalDAVSettings( url=SecretStr("https://caldav.example.com/"), username="test-user", password=SecretStr(SENTINEL_PASSWORD), calendar_path="/cal/", ) repr_str = repr(settings) assert SENTINEL_PASSWORD not in repr_str assert "**********" in repr_str def test_password_not_in_str(self) -> None: """Vérifie que le mot de passe n'apparaît pas dans str(settings).""" settings = CalDAVSettings( url=SecretStr("https://caldav.example.com/"), username="test-user", password=SecretStr(SENTINEL_PASSWORD), calendar_path="/cal/", ) str_str = str(settings) assert SENTINEL_PASSWORD not in str_str assert "**********" in str_str def test_https_enforcement(self) -> None: """Vérifie que HTTP (non-localhost) est rejeté par défaut.""" with pytest.raises(ValidationError) as exc_info: CalDAVSettings( url=SecretStr(SENTINEL_HTTP_URL), username="test-user", password=SecretStr(SENTINEL_PASSWORD), calendar_path="/cal/", ) assert exc_info.value.error_count() >= 1 def test_https_accepted(self) -> None: """Vérifie que HTTPS est accepté sans erreur.""" settings = CalDAVSettings( url=SecretStr("https://caldav.example.com/"), username="test-user", password=SecretStr(SENTINEL_PASSWORD), calendar_path="/cal/", ) assert settings.url is not None def test_http_localhost_without_flag(self) -> None: """Vérifie que HTTP localhost est rejeté sans allow_insecure_http.""" with pytest.raises(ValidationError) as exc_info: CalDAVSettings( url=SecretStr(SENTINEL_HTTP_LOCALHOST), username="test-user", password=SecretStr(SENTINEL_PASSWORD), calendar_path="/cal/", ) assert exc_info.value.error_count() >= 1 def test_http_localhost_with_flag(self) -> None: """Vérifie que HTTP localhost est accepté avec allow_insecure_http=True.""" settings = CalDAVSettings( url=SecretStr(SENTINEL_HTTP_LOCALHOST), username="test-user", password=SecretStr(SENTINEL_PASSWORD), calendar_path="/cal/", allow_insecure_http=True, ) assert settings.url is not None def test_http_non_loopback_with_flag(self) -> None: """Vérifie que HTTP non-loopback est rejeté même avec allow_insecure_http=True.""" with pytest.raises(ValidationError) as exc_info: CalDAVSettings( url=SecretStr(SENTINEL_HTTP_NON_LOOPBACK), username="test-user", password=SecretStr(SENTINEL_PASSWORD), calendar_path="/cal/", allow_insecure_http=True, ) assert exc_info.value.error_count() >= 1 def test_validation_error_message_safe(self) -> None: """Vérifie que les messages d'erreur de validation ne contiennent pas l'URL brute.""" with pytest.raises(ValidationError) as exc_info: CalDAVSettings( url=SecretStr(SENTINEL_HTTP_URL), username="test-user", password=SecretStr(SENTINEL_PASSWORD), calendar_path="/cal/", ) error_str = str(exc_info.value) assert SENTINEL_HTTP_URL not in error_str assert "caldav.example.com" not in error_str class TestCalDAVGatewaySecurity: """Tests de sécurité pour la passerelle CalDAV (CalDAVGateway).""" def test_password_not_stored_in_plaintext(self) -> None: """Vérifie que le mot de passe n'est pas stocké en clair sur l'instance.""" settings = CalDAVSettings( url=SecretStr("https://caldav.example.com/"), username="test-user", password=SecretStr(SENTINEL_PASSWORD), calendar_path="/cal/", ) gateway = CalDAVGateway(settings) # Vérifie que le mot de passe en clair n'est dans aucun attribut for attr_name in vars(gateway): attr_value = getattr(gateway, attr_name) if isinstance(attr_value, str): assert SENTINEL_PASSWORD not in attr_value elif isinstance(attr_value, SecretStr): # SecretStr peut contenir le secret, mais pas en clair assert SENTINEL_PASSWORD not in str(attr_value) def test_error_messages_redacted(self, caplog: LogCaptureFixture) -> None: """Vérifie que les messages d'erreur ne contiennent pas de secrets.""" def _leaky_client_factory(**kwargs: Any) -> None: # Utiliser un message d'erreur qui contient des secrets dans un format détectable raise Exception(f"Connection failed to {SENTINEL_URL}?token={SENTINEL_PASSWORD}") settings = CalDAVSettings( url=SecretStr(SENTINEL_URL), username="test-user", password=SecretStr(SENTINEL_PASSWORD), calendar_path="/cal/", ) gateway = CalDAVGateway(settings, client_factory=_leaky_client_factory) with caplog.at_level(logging.ERROR): with pytest.raises(PronoteSyncError) as exc_info: gateway.connect() # 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_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_PASSWORD not in log_msg def test_exception_cause_and_context_is_none(self) -> None: """Vérifie que PronoteSyncError.__cause__ et __context__ sont None.""" def _leaky_client_factory(**kwargs: Any) -> None: raise Exception(f"Connection failed to {SENTINEL_URL}?token={SENTINEL_PASSWORD}") settings = CalDAVSettings( url=SecretStr(SENTINEL_URL), username="test-user", password=SecretStr(SENTINEL_PASSWORD), calendar_path="/cal/", ) gateway = CalDAVGateway(settings, client_factory=_leaky_client_factory) with pytest.raises(PronoteSyncError) as exc_info: gateway.connect() assert exc_info.value.__cause__ is None assert exc_info.value.__context__ is None 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), username="test-user", password=SecretStr(SENTINEL_PASSWORD), calendar_path="/cal/", ) # Créer un mock de client qui lève une exception avec des secrets mock_client = MagicMock() mock_principal = MagicMock() mock_calendar = MagicMock() mock_calendar.url = "https://caldav.example.com/cal/" mock_calendar.search.side_effect = Exception( f"Search failed at {SENTINEL_URL}?token={SENTINEL_PASSWORD}" ) mock_principal.calendars.return_value = [mock_calendar] mock_client.principal.return_value = mock_principal gateway = CalDAVGateway(settings, client_factory=lambda **kw: mock_client) gateway._client = mock_client gateway._calendar = mock_calendar with caplog.at_level(logging.ERROR): with pytest.raises(PronoteSyncError) as exc_info: gateway.list_managed_events(start=datetime(2026, 1, 15), end=datetime(2026, 1, 20)) # 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_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_PASSWORD not in log_msg # Vérifie que l'exception n'est chaînée à aucune exception brute assert exc_info.value.__cause__ is None assert exc_info.value.__context__ is None 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), username="test-user", password=SecretStr(SENTINEL_PASSWORD), calendar_path="/cal/", ) # Créer un mock de calendrier dont la recherche par UID lève une # exception avec des secrets (simule une fuite de la bibliothèque caldav) mock_calendar = MagicMock() mock_calendar.get_event_by_uid.side_effect = Exception( f"Save failed at {SENTINEL_URL}?token={SENTINEL_PASSWORD}" ) gateway = CalDAVGateway(settings) gateway._calendar = mock_calendar with caplog.at_level(logging.ERROR): with pytest.raises(PronoteSyncError) as exc_info: gateway.upsert_event("BEGIN:VCALENDAR\nEND:VCALENDAR", "test-uid") # 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_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_PASSWORD not in log_msg # Vérifie que l'exception n'est chaînée à aucune exception brute assert exc_info.value.__cause__ is None assert exc_info.value.__context__ is None 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), username="test-user", password=SecretStr(SENTINEL_PASSWORD), calendar_path="/cal/", ) # Créer un mock de calendrier qui lève une exception avec des secrets mock_calendar = MagicMock() mock_calendar.get_event_by_uid.side_effect = Exception( f"Delete failed at {SENTINEL_URL}?token={SENTINEL_PASSWORD}" ) gateway = CalDAVGateway(settings) gateway._calendar = mock_calendar with caplog.at_level(logging.ERROR): with pytest.raises(PronoteSyncError) as exc_info: gateway.delete_event("test-uid") # 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_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_PASSWORD not in log_msg # Vérifie que l'exception n'est chaînée à aucune exception brute assert exc_info.value.__cause__ is None assert exc_info.value.__context__ is None 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), username="test-user", password=SecretStr(SENTINEL_PASSWORD), calendar_path="/cal/", ) 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 "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)