Files
college-infos/tests/unit/test_caldav_security.py
Antoine Van Elstraete b4b0247919 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>
2026-09-07 09:24:18 +02:00

380 lines
16 KiB
Python

"""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.date_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)