"""Tests unitaires pour la passerelle CalDAV. Ce module vérifie que :class:`CalDAVGateway` gère correctement la connexion, la liste des événements gérés, l'écriture et la suppression d'événements, avec une attention particulière à la sécurité (masquage des secrets). """ from __future__ import annotations from datetime import datetime from typing import TYPE_CHECKING from unittest.mock import MagicMock import pytest from caldav.lib.error import NotFoundError from icalendar import Calendar, Event from pydantic import SecretStr from pronote_sync.config.settings import CalDAVSettings from pronote_sync.errors import PronoteSyncError from pronote_sync.sync.caldav import CalDAVGateway from pronote_sync.sync.serialization import MANAGED_PROPERTY, MANAGED_VALUE if TYPE_CHECKING: pass # --- Fixtures --- @pytest.fixture def caldav_settings() -> CalDAVSettings: """Fournit des paramètres CalDAV valides pour les tests. :return: Instance de CalDAVSettings. :rtype: CalDAVSettings """ return CalDAVSettings( url=SecretStr("https://caldav.example.com"), username="testuser", password=SecretStr("testpass123"), calendar_path="/pronote-sync/", ) @pytest.fixture def caldav_settings_http_localhost() -> CalDAVSettings: """Fournit des paramètres CalDAV avec HTTP pour localhost. :return: Instance de CalDAVSettings. :rtype: CalDAVSettings """ return CalDAVSettings( url=SecretStr("http://localhost:5232"), username="testuser", password=SecretStr("testpass123"), calendar_path="/pronote-sync/", allow_insecure_http=True, ) @pytest.fixture def mock_client_factory() -> MagicMock: """Fournit une usine de clients CalDAV mockée. Le client retourné expose un principal dont la liste de calendriers contient un calendrier dont le chemin se termine par ``pronote-sync``, ce qui permet à :meth:`~pronote_sync.sync.caldav.CalDAVGateway.connect` de le résoudre par découverte. :return: MagicMock configuré pour retourner un client, un principal et un calendrier mockés. :rtype: MagicMock """ mock_factory = MagicMock() # Configurer le mock pour retourner un client mock_client = MagicMock() mock_factory.return_value = mock_client # Configurer le client pour exposer un principal avec un calendrier mock_principal = MagicMock() mock_client.principal.return_value = mock_principal mock_calendar = MagicMock() mock_calendar.url = "https://caldav.example.com/calendars/testuser/pronote-sync/" mock_principal.calendars.return_value = [mock_calendar] return mock_factory @pytest.fixture def mock_managed_event() -> MagicMock: """Fournit un événement CalDAV mocké avec le marqueur de gestion. :return: MagicMock configuré comme un événement géré. :rtype: MagicMock """ mock_event = MagicMock() # Créer un VEVENT avec le marqueur de gestion vevent = Event() vevent.add("UID", "test-uid-123") vevent.add("SUMMARY", "Test Event") vevent.add(MANAGED_PROPERTY, MANAGED_VALUE) mock_event.icalendar_component = Calendar() mock_event.icalendar_component.add_component(vevent) return mock_event @pytest.fixture def mock_unmanaged_event() -> MagicMock: """Fournit un événement CalDAV mocké sans le marqueur de gestion. :return: MagicMock configuré comme un événement non géré. :rtype: MagicMock """ mock_event = MagicMock() # Créer un VEVENT sans le marqueur de gestion vevent = Event() vevent.add("UID", "unmanaged-uid-456") vevent.add("SUMMARY", "Unmanaged Event") mock_event.icalendar_component = Calendar() mock_event.icalendar_component.add_component(vevent) return mock_event # --- Connection tests --- def test_connect_success( caldav_settings: CalDAVSettings, mock_client_factory: MagicMock, ) -> None: """Vérifie que connect() configure le client et résout le calendrier. La résolution passe par la découverte CalDAV (``principal()`` puis ``calendars()``) et non par une concaténation d'URL. :param caldav_settings: Paramètres CalDAV valides. :param mock_client_factory: Usine de clients mockée. :return: None """ gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory) gateway.connect() # Vérifier que l'usine a été appelée avec les bons paramètres mock_client_factory.assert_called_once() call_kwargs = mock_client_factory.call_args[1] assert call_kwargs["url"] == "https://caldav.example.com" assert call_kwargs["username"] == "testuser" assert call_kwargs["password"] == "testpass123" # Vérifier que le calendrier a été résolu par découverte mock_client = mock_client_factory.return_value mock_client.principal.assert_called_once_with() mock_principal = mock_client.principal.return_value mock_principal.calendars.assert_called_once_with() mock_calendar = mock_principal.calendars.return_value[0] assert gateway._calendar is mock_calendar def test_connect_calendar_not_found_raises( caldav_settings: CalDAVSettings, mock_client_factory: MagicMock, ) -> None: """Vérifie que connect() lève PronoteSyncError si aucun calendrier ne correspond. :param caldav_settings: Paramètres CalDAV valides. :param mock_client_factory: Usine de clients mockée. :return: None """ mock_principal = mock_client_factory.return_value.principal.return_value other_calendar = MagicMock() other_calendar.url = "https://caldav.example.com/calendars/testuser/autre-calendrier/" mock_principal.calendars.return_value = [other_calendar] gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory) with pytest.raises(PronoteSyncError) as exc_info: gateway.connect() assert "introuvable" in str(exc_info.value) assert exc_info.value.__cause__ is None assert exc_info.value.__context__ is None def test_connect_calendar_ambiguous_raises( caldav_settings: CalDAVSettings, mock_client_factory: MagicMock, ) -> None: """Vérifie que connect() lève PronoteSyncError si plusieurs calendriers correspondent. :param caldav_settings: Paramètres CalDAV valides. :param mock_client_factory: Usine de clients mockée. :return: None """ mock_principal = mock_client_factory.return_value.principal.return_value cal1 = MagicMock() cal1.url = "https://caldav.example.com/calendars/testuser/pronote-sync/" cal2 = MagicMock() cal2.url = "https://caldav.example.com/calendars/autreuser/pronote-sync/" mock_principal.calendars.return_value = [cal1, cal2] gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory) with pytest.raises(PronoteSyncError) as exc_info: gateway.connect() assert "ambigu" in str(exc_info.value) assert exc_info.value.__cause__ is None assert exc_info.value.__context__ is None def test_connect_calendar_path_boundary_not_matched( caldav_settings: CalDAVSettings, mock_client_factory: MagicMock, ) -> None: """Vérifie que connect() ne résout pas un chemin partageant un préfixe. Un calendrier dont le chemin se termine par ``pronote-sync`` sans frontière de composant (ex : ``.../not-pronote-sync/``) ne doit pas correspondre au ``calendar_path`` configuré ``/pronote-sync/``. :param caldav_settings: Paramètres CalDAV valides. :param mock_client_factory: Usine de clients mockée. :return: None """ mock_principal = mock_client_factory.return_value.principal.return_value boundary_calendar = MagicMock() boundary_calendar.url = "https://caldav.example.com/calendars/testuser/not-pronote-sync/" mock_principal.calendars.return_value = [boundary_calendar] gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory) with pytest.raises(PronoteSyncError) as exc_info: gateway.connect() assert "introuvable" in str(exc_info.value) def test_connect_failure_raises_pronote_sync_error( caldav_settings: CalDAVSettings, ) -> None: """Vérifie que connect() lève PronoteSyncError en cas d'échec de connexion. :param caldav_settings: Paramètres CalDAV valides. :return: None """ # Créer une usine qui lève une exception def failing_factory(*args: object, **kwargs: object) -> None: raise ConnectionError("Connection failed") gateway = CalDAVGateway(caldav_settings, client_factory=failing_factory) with pytest.raises(PronoteSyncError) as exc_info: gateway.connect() # Vérifier que le message ne contient pas l'URL brute error_message = str(exc_info.value) assert "caldav.example.com" in error_message # L'URL rédigée doit être présente assert "testpass123" not in error_message # Le mot de passe ne doit pas être présent assert "from None" in str(exc_info.typename) or exc_info.value.__cause__ is None def test_connect_missing_credentials_raises( caldav_settings: CalDAVSettings, ) -> None: """Vérifie que connect() lève PronoteSyncError si les identifiants sont manquants. :param caldav_settings: Paramètres CalDAV valides. :return: None """ # Créer des paramètres sans URL incomplete_settings = CalDAVSettings( url=None, username=None, password=None, calendar_path="/pronote-sync/", ) gateway = CalDAVGateway(incomplete_settings) with pytest.raises(PronoteSyncError) as exc_info: gateway.connect() assert "incomplète" in str(exc_info.value) # --- list_managed_events tests --- def test_list_managed_events_returns_only_managed( caldav_settings: CalDAVSettings, mock_client_factory: MagicMock, mock_managed_event: MagicMock, mock_unmanaged_event: MagicMock, ) -> None: """Vérifie que list_managed_events ne retourne que les événements gérés. :param caldav_settings: Paramètres CalDAV valides. :param mock_client_factory: Usine de clients mockée. :param mock_managed_event: Événement géré mocké. :param mock_unmanaged_event: Événement non géré mocké. :return: None """ gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory) gateway.connect() # Configurer le calendrier pour retourner les deux événements mock_calendar = mock_client_factory.return_value.principal.return_value.calendars.return_value[ 0 ] mock_calendar.date_search.return_value = [mock_managed_event, mock_unmanaged_event] start = datetime(2026, 1, 1) end = datetime(2026, 12, 31) result = gateway.list_managed_events(start, end) # Seuls les événements gérés doivent être retournés assert len(result) == 1 uid, vevent = result[0] assert uid == "test-uid-123" assert str(vevent.get("UID")) == "test-uid-123" def test_list_managed_events_not_connected_raises( caldav_settings: CalDAVSettings, ) -> None: """Vérifie que list_managed_events lève PronoteSyncError si non connecté. :param caldav_settings: Paramètres CalDAV valides. :return: None """ gateway = CalDAVGateway(caldav_settings) # Ne pas appeler connect() with pytest.raises(PronoteSyncError) as exc_info: gateway.list_managed_events(datetime(2026, 1, 1), datetime(2026, 12, 31)) assert "non connectée" in str(exc_info.value) def test_list_managed_events_caldav_error_raises( caldav_settings: CalDAVSettings, mock_client_factory: MagicMock, ) -> None: """Vérifie que list_managed_events lève PronoteSyncError en cas d'erreur CalDAV. :param caldav_settings: Paramètres CalDAV valides. :param mock_client_factory: Usine de clients mockée. :return: None """ gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory) gateway.connect() # Configurer le calendrier pour lever une exception mock_calendar = mock_client_factory.return_value.principal.return_value.calendars.return_value[ 0 ] mock_calendar.date_search.side_effect = Exception("CalDAV error") with pytest.raises(PronoteSyncError) as exc_info: gateway.list_managed_events(datetime(2026, 1, 1), datetime(2026, 12, 31)) # Vérifier que le message ne contient pas de secret error_message = str(exc_info.value) assert "testpass123" not in error_message assert exc_info.value.__context__ is None # --- upsert_event tests --- def test_upsert_event_creates_new_event_when_uid_missing( caldav_settings: CalDAVSettings, mock_client_factory: MagicMock, ) -> None: """Vérifie que upsert_event crée un événement si l'UID est introuvable. :param caldav_settings: Paramètres CalDAV valides. :param mock_client_factory: Usine de clients mockée. :return: None """ gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory) gateway.connect() mock_calendar = mock_client_factory.return_value.principal.return_value.calendars.return_value[ 0 ] mock_calendar.get_event_by_uid.side_effect = NotFoundError("Event not found") vcalendar_text = "BEGIN:VCALENDAR\nBEGIN:VEVENT\nUID:test-123\nEND:VEVENT\nEND:VCALENDAR" gateway.upsert_event(vcalendar_text, "test-123") mock_calendar.get_event_by_uid.assert_called_once_with("test-123") mock_calendar.add_event.assert_called_once_with(ical=vcalendar_text) def test_upsert_event_updates_existing_event_by_uid( caldav_settings: CalDAVSettings, mock_client_factory: MagicMock, ) -> None: """Vérifie que upsert_event remplace le contenu d'un événement existant. :param caldav_settings: Paramètres CalDAV valides. :param mock_client_factory: Usine de clients mockée. :return: None """ gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory) gateway.connect() mock_calendar = mock_client_factory.return_value.principal.return_value.calendars.return_value[ 0 ] mock_event = MagicMock() mock_calendar.get_event_by_uid.return_value = mock_event vcalendar_text = "BEGIN:VCALENDAR\nBEGIN:VEVENT\nUID:test-123\nEND:VEVENT\nEND:VCALENDAR" gateway.upsert_event(vcalendar_text, "test-123") mock_calendar.get_event_by_uid.assert_called_once_with("test-123") assert mock_event.data == vcalendar_text mock_event.save.assert_called_once() mock_calendar.add_event.assert_not_called() def test_upsert_event_not_connected_raises( caldav_settings: CalDAVSettings, ) -> None: """Vérifie que upsert_event lève PronoteSyncError si non connecté. :param caldav_settings: Paramètres CalDAV valides. :return: None """ gateway = CalDAVGateway(caldav_settings) with pytest.raises(PronoteSyncError) as exc_info: gateway.upsert_event("BEGIN:VCALENDAR\nEND:VCALENDAR", "test-123") assert "non connectée" in str(exc_info.value) def test_upsert_event_caldav_error_raises( caldav_settings: CalDAVSettings, mock_client_factory: MagicMock, ) -> None: """Vérifie que upsert_event lève PronoteSyncError en cas d'erreur CalDAV. :param caldav_settings: Paramètres CalDAV valides. :param mock_client_factory: Usine de clients mockée. :return: None """ gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory) gateway.connect() mock_calendar = mock_client_factory.return_value.principal.return_value.calendars.return_value[ 0 ] mock_calendar.get_event_by_uid.side_effect = Exception("Save error") with pytest.raises(PronoteSyncError) as exc_info: gateway.upsert_event("BEGIN:VCALENDAR\nEND:VCALENDAR", "test-123") error_message = str(exc_info.value) assert "testpass123" not in error_message assert exc_info.value.__cause__ is None assert exc_info.value.__context__ is None # --- delete_event tests --- def test_delete_event_calls_calendar( caldav_settings: CalDAVSettings, mock_client_factory: MagicMock, ) -> None: """Vérifie que delete_event appelle event.delete() sur l'événement trouvé. :param caldav_settings: Paramètres CalDAV valides. :param mock_client_factory: Usine de clients mockée. :return: None """ gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory) gateway.connect() # Configurer le calendrier pour retourner un événement mocké mock_calendar = mock_client_factory.return_value.principal.return_value.calendars.return_value[ 0 ] mock_event = MagicMock() mock_calendar.get_event_by_uid.return_value = mock_event gateway.delete_event("test-uid-123") mock_calendar.get_event_by_uid.assert_called_once_with("test-uid-123") mock_event.delete.assert_called_once() def test_delete_event_not_connected_raises( caldav_settings: CalDAVSettings, ) -> None: """Vérifie que delete_event lève PronoteSyncError si non connecté. :param caldav_settings: Paramètres CalDAV valides. :return: None """ gateway = CalDAVGateway(caldav_settings) with pytest.raises(PronoteSyncError) as exc_info: gateway.delete_event("test-uid-123") assert "non connectée" in str(exc_info.value) def test_delete_event_caldav_error_raises( caldav_settings: CalDAVSettings, mock_client_factory: MagicMock, ) -> None: """Vérifie que delete_event lève PronoteSyncError en cas d'erreur CalDAV. :param caldav_settings: Paramètres CalDAV valides. :param mock_client_factory: Usine de clients mockée. :return: None """ gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory) gateway.connect() mock_calendar = mock_client_factory.return_value.principal.return_value.calendars.return_value[ 0 ] mock_calendar.get_event_by_uid.side_effect = Exception("Delete error") with pytest.raises(PronoteSyncError) as exc_info: gateway.delete_event("test-uid-123") error_message = str(exc_info.value) assert "testpass123" not in error_message assert exc_info.value.__cause__ is None assert exc_info.value.__context__ is None # --- Context manager tests --- def test_context_manager_calls_connect_and_close( caldav_settings: CalDAVSettings, mock_client_factory: MagicMock, ) -> None: """Vérifie que le gestionnaire de contexte appelle connect() et close(). :param caldav_settings: Paramètres CalDAV valides. :param mock_client_factory: Usine de clients mockée. :return: None """ gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory) with gateway: # À l'intérieur du contexte, le client doit être configuré assert gateway._client is not None assert gateway._calendar is not None # Après la sortie du contexte, le client doit être réinitialisé assert gateway._client is None assert gateway._calendar is None def test_context_manager_connect_failure( caldav_settings: CalDAVSettings, ) -> None: """Vérifie que le gestionnaire de contexte propage l'erreur de connexion. :param caldav_settings: Paramètres CalDAV valides. :return: None """ def failing_factory(*args: object, **kwargs: object) -> None: raise ConnectionError("Connection failed") gateway = CalDAVGateway(caldav_settings, client_factory=failing_factory) with pytest.raises(PronoteSyncError): with gateway: pass # Ne doit pas être atteint # --- Security tests --- def test_no_plaintext_password_in_vars( caldav_settings: CalDAVSettings, ) -> None: """Vérifie que vars(gateway) ne contient pas le mot de passe en clair. :param caldav_settings: Paramètres CalDAV valides. :return: None """ gateway = CalDAVGateway(caldav_settings) gateway_vars = vars(gateway) # Vérifier que le mot de passe n'est pas en clair assert "testpass123" not in str(gateway_vars) # Vérifier que l'URL brute avec credentials n'est pas en clair # Note: _redacted_url contient l'URL sans credentials, ce qui est acceptable assert "testuser:testpass123@" not in str(gateway_vars) def test_repr_does_not_leak_password( caldav_settings: CalDAVSettings, ) -> None: """Vérifie que repr(gateway) ne fuit pas le mot de passe. :param caldav_settings: Paramètres CalDAV valides. :return: None """ gateway = CalDAVGateway(caldav_settings) repr_str = repr(gateway) assert "testpass123" not in repr_str def test_str_does_not_leak_password( caldav_settings: CalDAVSettings, ) -> None: """Vérifie que str(gateway) ne fuit pas le mot de passe. :param caldav_settings: Paramètres CalDAV valides. :return: None """ gateway = CalDAVGateway(caldav_settings) str_str = str(gateway) assert "testpass123" not in str_str def test_redacted_url_in_error_message( caldav_settings: CalDAVSettings, ) -> None: """Vérifie que les messages d'erreur contiennent l'URL rédigée, pas l'URL brute. :param caldav_settings: Paramètres CalDAV valides. :return: None """ def failing_factory(*args: object, **kwargs: object) -> None: raise ConnectionError("Connection failed") gateway = CalDAVGateway(caldav_settings, client_factory=failing_factory) try: gateway.connect() except PronoteSyncError as exc: error_message = str(exc) # L'URL doit être rédigée (sans credentials) assert "caldav.example.com" in error_message # Le mot de passe ne doit pas être présent assert "testpass123" not in error_message # L'URL complète avec credentials ne doit pas être présente assert "https://testuser:testpass123@caldav.example.com" not in error_message def test_close_clears_secrets( caldav_settings: CalDAVSettings, mock_client_factory: MagicMock, ) -> None: """Vérifie que close() réinitialise les secrets. :param caldav_settings: Paramètres CalDAV valides. :param mock_client_factory: Usine de clients mockée. :return: None """ gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory) gateway.connect() # Avant close(), les secrets sont présents assert gateway._url_secret is not None assert gateway._password_secret is not None gateway.close() # Après close(), les secrets sont réinitialisés assert gateway._url_secret is None assert gateway._password_secret is None assert gateway._client is None assert gateway._calendar is None # --- HTTP localhost tests --- def test_http_localhost_allowed_with_flag( caldav_settings_http_localhost: CalDAVSettings, mock_client_factory: MagicMock, ) -> None: """Vérifie que HTTP est autorisé pour localhost avec allow_insecure_http=True. :param caldav_settings_http_localhost: Paramètres CalDAV avec HTTP pour localhost. :param mock_client_factory: Usine de clients mockée. :return: None """ gateway = CalDAVGateway(caldav_settings_http_localhost, client_factory=mock_client_factory) # La validation doit réussir (pas d'erreur levée) # Le client_factory est appelé avec l'URL HTTP gateway.connect() call_kwargs = mock_client_factory.call_args[1] assert call_kwargs["url"] == "http://localhost:5232" # Ensure trailing newline