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>
This commit is contained in:
@@ -32,6 +32,7 @@ from pronote_sync.config.settings import CalDAVSettings
|
||||
from pronote_sync.errors import PronoteSyncError
|
||||
from pronote_sync.sync.serialization import MANAGED_PROPERTY, MANAGED_VALUE
|
||||
from pronote_sync.utils.redaction import redact_exception, redact_secrets, redact_url
|
||||
from pronote_sync.utils.uid import normalize_pronote_uid
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -158,33 +159,41 @@ class CalDAVGateway:
|
||||
if error_msg is not None:
|
||||
raise PronoteSyncError(f"Échec de la connexion CalDAV : {self._redacted_url}") from None
|
||||
|
||||
def list_managed_events(self, start: datetime, end: datetime) -> list[tuple[str, Any]]:
|
||||
def list_managed_events(self, start: datetime, end: datetime) -> list[tuple[str, str, Any]]:
|
||||
"""Liste les événements gérés par l'outil dans la fenêtre donnée.
|
||||
|
||||
Interroge le serveur CalDAV sur la fenêtre ``[start, end]`` et ne
|
||||
conserve que les VEVENT portant le marqueur de gestion
|
||||
(:data:`MANAGED_PROPERTY` avec la valeur :data:`MANAGED_VALUE`).
|
||||
Pour chaque VEVENT, l'UID brut tel que stocké sur le serveur est
|
||||
conservé ainsi que sa forme canonique obtenue via
|
||||
:func:`~pronote_sync.utils.uid.normalize_pronote_uid` — la même
|
||||
normalisation que celle appliquée aux événements locaux — afin que
|
||||
le planificateur puisse apparier les événements distants suffixés aux
|
||||
événements Pronote normalisés.
|
||||
|
||||
:param start: Début de la fenêtre de recherche.
|
||||
:param end: Fin de la fenêtre de recherche.
|
||||
:return: Couples ``(uid, vevent)`` pour chaque événement géré trouvé.
|
||||
:rtype: list[tuple[str, Any]]
|
||||
:return: Triplets ``(raw_uid, canonical_uid, vevent)`` pour chaque
|
||||
événement géré trouvé.
|
||||
:rtype: list[tuple[str, str, Any]]
|
||||
:raises PronoteSyncError: Si la passerelle n'est pas connectée ou si
|
||||
la récupération échoue.
|
||||
"""
|
||||
if self._calendar is None:
|
||||
raise PronoteSyncError("Passerelle CalDAV non connectée") from None
|
||||
result: list[tuple[str, Any]] = []
|
||||
result: list[tuple[str, str, Any]] = []
|
||||
error_msg: str | None = None
|
||||
try:
|
||||
events = self._calendar.date_search(start=start, end=end, expand=True)
|
||||
events = self._calendar.search(start=start, end=end, event=True, expand=True)
|
||||
for event in events:
|
||||
component: Component = event.icalendar_component
|
||||
for vevent in component.walk("VEVENT"):
|
||||
managed = vevent.get(MANAGED_PROPERTY)
|
||||
if managed is not None and str(managed) == MANAGED_VALUE:
|
||||
uid = str(vevent.get("UID"))
|
||||
result.append((uid, vevent))
|
||||
raw_uid = str(vevent.get("UID"))
|
||||
canonical_uid = normalize_pronote_uid(raw_uid)
|
||||
result.append((raw_uid, canonical_uid, vevent))
|
||||
except Exception as exc:
|
||||
error_msg = redact_exception(exc)
|
||||
logger.error("Échec de la récupération des événements CalDAV : %s", error_msg)
|
||||
@@ -194,22 +203,46 @@ class CalDAVGateway:
|
||||
) from None
|
||||
return result
|
||||
|
||||
def _is_managed_event(self, event: Any) -> bool:
|
||||
"""Détermine si un événement distant est géré par pronote-sync.
|
||||
|
||||
Vérifie la présence du marqueur de gestion (:data:`MANAGED_PROPERTY`
|
||||
avec la valeur :data:`MANAGED_VALUE`) sur au moins un des composants
|
||||
VEVENT de l'événement, selon le même motif que
|
||||
:meth:`list_managed_events`.
|
||||
|
||||
:param event: Objet événement distant exposant la propriété
|
||||
``icalendar_component`` retournant un ``icalendar.Calendar``.
|
||||
:return: ``True`` si l'événement porte le marqueur de gestion,
|
||||
``False`` sinon.
|
||||
:rtype: bool
|
||||
"""
|
||||
component: Component = event.icalendar_component
|
||||
for vevent in component.walk("VEVENT"):
|
||||
managed = vevent.get(MANAGED_PROPERTY)
|
||||
if managed is not None and str(managed) == MANAGED_VALUE:
|
||||
return True
|
||||
return False
|
||||
|
||||
def upsert_event(self, vcalendar_text: str, uid: str) -> None:
|
||||
"""Crée ou met à jour un événement CalDAV identifié par son UID.
|
||||
|
||||
Recherche d'abord l'événement existant par UID via
|
||||
``get_event_by_uid`` : s'il existe, son contenu est remplacé puis
|
||||
sauvegardé ; s'il est introuvable (``NotFoundError``), un nouvel
|
||||
événement est créé via ``add_event``. Toute autre exception est
|
||||
journalisée avec :func:`redact_exception` puis re-levée en
|
||||
:class:`PronoteSyncError` — hors du bloc ``except``, afin que
|
||||
``__context__`` ne retienne aucune exception brute — sans chaînage ni
|
||||
donnée sensible.
|
||||
``get_event_by_uid`` : s'il est introuvable (``NotFoundError``), un
|
||||
nouvel événement est créé via ``add_event``. S'il existe, son contenu
|
||||
est remplacé puis sauvegardé — uniquement si l'événement est géré par
|
||||
l'outil (marqueur :data:`MANAGED_PROPERTY` avec la valeur
|
||||
:data:`MANAGED_VALUE`). Un événement existant non géré provoque une
|
||||
:class:`PronoteSyncError` explicite et n'est jamais modifié. Toute
|
||||
autre exception est journalisée avec :func:`redact_exception` puis
|
||||
re-levée en :class:`PronoteSyncError` — sans chaînage ni donnée
|
||||
sensible.
|
||||
|
||||
:param vcalendar_text: Document iCalendar complet (VCALENDAR + VEVENT).
|
||||
:param uid: UID stable de l'événement à créer ou mettre à jour.
|
||||
:raises PronoteSyncError: Si la passerelle n'est pas connectée ou si
|
||||
l'opération échoue.
|
||||
:raises PronoteSyncError: Si la passerelle n'est pas connectée, si
|
||||
l'événement distant n'est pas géré par l'outil, ou si l'opération
|
||||
échoue.
|
||||
"""
|
||||
if self._calendar is None:
|
||||
raise PronoteSyncError("Passerelle CalDAV non connectée") from None
|
||||
@@ -220,8 +253,14 @@ class CalDAVGateway:
|
||||
except NotFoundError:
|
||||
self._calendar.add_event(ical=vcalendar_text)
|
||||
else:
|
||||
if not self._is_managed_event(event):
|
||||
raise PronoteSyncError(
|
||||
"Conflit d'UID : l'événement distant n'est pas géré par pronote-sync"
|
||||
) from None
|
||||
event.data = vcalendar_text
|
||||
event.save()
|
||||
except PronoteSyncError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
error_msg = redact_exception(exc)
|
||||
logger.error(
|
||||
@@ -237,11 +276,15 @@ class CalDAVGateway:
|
||||
def delete_event(self, uid: str) -> None:
|
||||
"""Supprime un événement du calendrier, identifié par son UID.
|
||||
|
||||
Récupère l'événement distant via ``get_event_by_uid`` puis le
|
||||
supprime. Toute exception est journalisée avec
|
||||
:func:`redact_exception` et re-levée en :class:`PronoteSyncError` —
|
||||
hors du bloc ``except``, afin que ``__context__`` ne retienne aucune
|
||||
exception brute — sans chaînage ni donnée sensible.
|
||||
Récupère l'événement distant via ``get_event_by_uid`` : s'il est
|
||||
introuvable (``NotFoundError``), la suppression est un succès
|
||||
idempotent et la méthode retourne silencieusement. S'il existe, il
|
||||
n'est supprimé que s'il est géré par l'outil (marqueur
|
||||
:data:`MANAGED_PROPERTY` avec la valeur :data:`MANAGED_VALUE`) ; un
|
||||
événement non géré est laissé intact, un avertissement est
|
||||
journalisé et la méthode retourne sans erreur. Toute autre exception
|
||||
est journalisée avec :func:`redact_exception` et re-levée en
|
||||
:class:`PronoteSyncError` — sans chaînage ni donnée sensible.
|
||||
|
||||
:param uid: Identifiant UID de l'événement à supprimer.
|
||||
:raises PronoteSyncError: Si la passerelle n'est pas connectée ou si
|
||||
@@ -252,7 +295,16 @@ class CalDAVGateway:
|
||||
error_msg: str | None = None
|
||||
try:
|
||||
event = self._calendar.get_event_by_uid(uid)
|
||||
if not self._is_managed_event(event):
|
||||
logger.warning(
|
||||
"Suppression refusée : l'événement distant UID=%s n'est pas géré par "
|
||||
"pronote-sync",
|
||||
redact_secrets(uid),
|
||||
)
|
||||
return
|
||||
event.delete()
|
||||
except NotFoundError:
|
||||
return
|
||||
except Exception as exc:
|
||||
error_msg = redact_exception(exc)
|
||||
logger.error(
|
||||
|
||||
Reference in New Issue
Block a user