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>
380 lines
16 KiB
Python
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)
|