feat(M7): synchronisation différentielle CalDAV
Implémente la synchronisation des événements Pronote vers un calendrier CalDAV (Nextcloud) de façon idempotente et sécurisée. Production : - sync/serialization.py : sérialisation Lesson/Homework/SchoolEvent vers VEVENT, signature sémantique (exclut DTSTAMP/CREATED/LAST-MODIFIED), enveloppe VCALENDAR complète avec VERSION:2.0 et PRODID - sync/caldav.py : passerelle CalDAV isolant caldav>=1.3.0, résolution du calendrier via principal().calendars() avec boundary matching, upsert par UID (fetch-then-save), exceptions expurgées et __context__ propre, mot de passe non stocké en clair, context manager - sync/planner.py : calcul explicite du CalDAVSyncPlan (add/update/remove par comparaison de signatures sémantiques, routage par préfixe d'UID) - sync/executor.py : exécution du plan avec dry-run (aucune écriture), isolation des erreurs par événement, statut FAILED/SKIPPED/SUCCESS - sync/synchronizer.py : orchestration en trois phases (scan, plan, exécution), SKIPPED si CalDAV non configuré - sync/__init__.py : export synchronize() - sources/pronote/client.py : normalisation UID via normalize_pronote_uid/ generate_deterministic_uid (parité avec ical.py) - config/settings.py : CalDAVSettings durci (url SecretStr, validation HTTPS, allow_insecure_http pour localhost, serializer redact_url) Tests (381 passés, couverture 95.58%) : - tests/unit/test_sync_serialization.py (21 tests) - tests/unit/test_caldav_planner.py (16 tests) - tests/unit/test_caldav_executor.py (18 tests) - tests/unit/test_caldav_gateway.py (24 tests) - tests/unit/test_caldav_security.py (18 tests) - tests/unit/test_uid_equivalence.py (8 tests) - tests/integration/test_caldav_sync.py (11 tests, faux serveur en mémoire) - tests/conftest.py : fixtures partagées Documentation : - GUIDE_DEV_PYTHON.md §7 : API réelle caldav>=1.3.0, principal().calendars(), VCALENDAR complet, upsert par UID, pas d'état local, événements non gérés protégés, CalDAVSettings durci (SecretStr, HTTPS, allow_insecure_http) - TODO.md : M7 coché - .env.example : CALDAV_ALLOW_INSECURE_HTTP=false Co-authored-by: opencode/coder <coder@agents.invalid> Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid> Co-authored-by: opencode/tech-writer <tech-writer@agents.invalid>
This commit is contained in:
716
tests/unit/test_caldav_gateway.py
Normal file
716
tests/unit/test_caldav_gateway.py
Normal file
@@ -0,0 +1,716 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user