Files
college-infos/tests/unit/test_caldav_gateway.py
Antoine Van Elstraete a1bae41be8 fix(M7): corrections d'audit FIXME_M7 — sécurité, fenêtre, UID, timezone
Corrige les 5 constats de l'audit FIXME_M7 :

#1 (Bloquant) — Protection des événements non marqués :
- upsert_event() vérifie le marqueur X-PRONOTE-SYNC-MANAGED avant
  modification ; lève PronoteSyncError en cas de collision avec un
  événement non géré (aucune écriture)
- delete_event() vérifie le marqueur ; no-op avec warning si non géré
- Méthode privée _is_managed_event() factorisant le contrôle

#2 (Bloquant) — Fenêtre de synchronisation :
- Calcul en journées entières (minuit à minuit exclusif)
- Filtrage des données locales (lessons, homeworks, school_events) avant
  passage au planner
- Paramètre now injectable pour les tests

#3 (Bloquant) — UID canonique vs brut :
- list_managed_events() retourne (raw_uid, canonical_uid, vevent)
- compute_plan() matche par UID canonique, route les raw UID vers
  *_to_remove, retourne le mapping remote_raw_by_canonical
- executor.execute() utilise le raw UID pour les mises à jour (pas de
  doublon)
- Pas de migration destructive des UID distants existants

#4 (Correction) — Normalisation temporelle UTC :
- normalize_datetime_to_utc() dans utils/uid.py : naïve → Europe/Paris →
  UTC ; consciente → UTC
- Utilisée par generate_deterministic_uid() et component_to_signature()
- Deux représentations du même instant → même UID et même signature

#5 (Compatibilité) — date_search déprécié :
- Remplacement par calendar.search(start, end, event=True, expand=True)

Documentation :
- GUIDE_DEV_PYTHON.md : suppression des références obsolètes à
  sync/state.py et état SQLite/JSON ; mise à jour de l'API CalDAV
  (search au lieu de date_search, upsert par UID)
- TODO.md : M7 décoché (corrections en cours de validation)

Tests : 390 passés, couverture 95.61%

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>
2026-09-07 12:24:22 +02:00

816 lines
27 KiB
Python

"""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
import logging
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:
from _pytest.logging import LogCaptureFixture
# --- 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.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
raw_uid, canonical_uid, vevent = result[0]
assert raw_uid == "test-uid-123"
assert canonical_uid == "test-uid-123" # Pas de suffixe Pronote : canonique == brut
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.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,
mock_managed_event: MagicMock,
) -> None:
"""Vérifie que upsert_event remplace le contenu d'un événement géré existant.
: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é.
: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.return_value = mock_managed_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_managed_event.data == vcalendar_text
mock_managed_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_upsert_event_refuses_unmanaged_event(
caldav_settings: CalDAVSettings,
mock_client_factory: MagicMock,
mock_unmanaged_event: MagicMock,
) -> None:
"""Vérifie que upsert_event refuse de modifier un événement non géré.
Un événement distant existant sans le marqueur de gestion ne doit jamais
être écrasé : la méthode lève PronoteSyncError avec un message « Conflit »
et n'appelle pas ``add_event``.
:param caldav_settings: Paramètres CalDAV valides.
:param mock_client_factory: Usine de clients mockée.
:param mock_unmanaged_event: Événement non géré mocké.
: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.return_value = mock_unmanaged_event
vcalendar_text = "BEGIN:VCALENDAR\nBEGIN:VEVENT\nUID:test-123\nEND:VEVENT\nEND:VCALENDAR"
with pytest.raises(PronoteSyncError) as exc_info:
gateway.upsert_event(vcalendar_text, "test-123")
assert "Conflit" in str(exc_info.value)
mock_calendar.get_event_by_uid.assert_called_once_with("test-123")
mock_calendar.add_event.assert_not_called()
mock_unmanaged_event.save.assert_not_called()
def test_delete_event_calls_calendar(
caldav_settings: CalDAVSettings,
mock_client_factory: MagicMock,
mock_managed_event: MagicMock,
) -> None:
"""Vérifie que delete_event appelle event.delete() sur un événement géré.
: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é.
:return: None
"""
gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory)
gateway.connect()
# Configurer le calendrier pour retourner un événement géré mocké
mock_calendar = mock_client_factory.return_value.principal.return_value.calendars.return_value[
0
]
mock_calendar.get_event_by_uid.return_value = mock_managed_event
gateway.delete_event("test-uid-123")
mock_calendar.get_event_by_uid.assert_called_once_with("test-uid-123")
mock_managed_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
def test_delete_event_refuses_unmanaged_event(
caldav_settings: CalDAVSettings,
mock_client_factory: MagicMock,
mock_unmanaged_event: MagicMock,
caplog: LogCaptureFixture,
) -> None:
"""Vérifie que delete_event refuse de supprimer un événement non géré.
L'événement distant sans le marqueur de gestion ne doit jamais être
supprimé : la méthode retourne sans erreur, journalise un avertissement
et n'appelle pas ``event.delete()``.
:param caldav_settings: Paramètres CalDAV valides.
:param mock_client_factory: Usine de clients mockée.
:param mock_unmanaged_event: Événement non géré mocké.
:param caplog: Capture des journaux pytest.
: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.return_value = mock_unmanaged_event
with caplog.at_level(logging.WARNING):
gateway.delete_event("test-uid-123")
mock_calendar.get_event_by_uid.assert_called_once_with("test-uid-123")
mock_unmanaged_event.delete.assert_not_called()
assert any("Suppression refusée" in record.getMessage() for record in caplog.records)
def test_delete_event_idempotent_when_not_found(
caldav_settings: CalDAVSettings,
mock_client_factory: MagicMock,
) -> None:
"""Vérifie que delete_event est idempotent quand l'UID est introuvable.
Une suppression d'un événement déjà absent est un succès silencieux :
aucune exception n'est levée.
: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")
gateway.delete_event("test-uid-123")
mock_calendar.get_event_by_uid.assert_called_once_with("test-uid-123")
# --- 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