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:
2026-09-07 12:24:22 +02:00
parent b4b0247919
commit a1bae41be8
14 changed files with 735 additions and 298 deletions

View File

@@ -7,6 +7,7 @@ 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
@@ -22,7 +23,7 @@ from pronote_sync.sync.caldav import CalDAVGateway
from pronote_sync.sync.serialization import MANAGED_PROPERTY, MANAGED_VALUE
if TYPE_CHECKING:
pass
from _pytest.logging import LogCaptureFixture
# --- Fixtures ---
@@ -312,7 +313,7 @@ def test_list_managed_events_returns_only_managed(
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]
mock_calendar.search.return_value = [mock_managed_event, mock_unmanaged_event]
start = datetime(2026, 1, 1)
end = datetime(2026, 12, 31)
@@ -320,8 +321,9 @@ def test_list_managed_events_returns_only_managed(
# Seuls les événements gérés doivent être retournés
assert len(result) == 1
uid, vevent = result[0]
assert uid == "test-uid-123"
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"
@@ -359,7 +361,7 @@ def test_list_managed_events_caldav_error_raises(
mock_calendar = mock_client_factory.return_value.principal.return_value.calendars.return_value[
0
]
mock_calendar.date_search.side_effect = Exception("CalDAV error")
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))
@@ -402,11 +404,13 @@ def test_upsert_event_creates_new_event_when_uid_missing(
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 existant.
"""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)
@@ -415,16 +419,15 @@ def test_upsert_event_updates_existing_event_by_uid(
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
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_event.data == vcalendar_text
mock_event.save.assert_called_once()
assert mock_managed_event.data == vcalendar_text
mock_managed_event.save.assert_called_once()
mock_calendar.add_event.assert_not_called()
@@ -474,30 +477,66 @@ def test_upsert_event_caldav_error_raises(
# --- delete_event tests ---
def test_delete_event_calls_calendar(
def test_upsert_event_refuses_unmanaged_event(
caldav_settings: CalDAVSettings,
mock_client_factory: MagicMock,
mock_unmanaged_event: MagicMock,
) -> None:
"""Vérifie que delete_event appelle event.delete() sur l'événement trouvé.
"""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()
# 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
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_event.delete.assert_called_once()
mock_managed_event.delete.assert_called_once()
def test_delete_event_not_connected_raises(
@@ -543,6 +582,66 @@ def test_delete_event_caldav_error_raises(
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 ---