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

@@ -85,13 +85,50 @@ class FakeCalendar:
self._server = server
self.url = "https://caldav.example.com/remote.php/dav/calendars/test-user/pronote-sync/"
def date_search(
self, start: datetime, end: datetime, expand: bool = True
def search(
self,
*,
start: datetime | None = None,
end: datetime | None = None,
event: bool = True,
expand: bool = True,
**kwargs: Any,
) -> list[FakeCalendarEvent]:
"""Retourne tous les événements comme de faux objets calendrier."""
"""Retourne les événements dont la période chevauche la fenêtre donnée.
Reproduit le comportement d'un vrai serveur CalDAV : seuls les
événements dont la période chevauche la fenêtre ``[start, end)`` sont
retournés, comme de faux objets calendrier.
:param start: Début de la fenêtre de recherche (``None`` : pas de
borne inférieure).
:param end: Fin de la fenêtre de recherche (``None`` : pas de borne
supérieure).
:param event: Non utilisé (le faux ne traite que des VEVENT).
:param expand: Non utilisé (compatibilité avec la passerelle).
:param kwargs: Paramètres additionnels ignorés (compatibilité avec
l'API ``caldav``).
:return: Liste des événements chevauchant la fenêtre.
:rtype: list[FakeCalendarEvent]
"""
results: list[FakeCalendarEvent] = []
for uid, ical_text in self._server._events.items():
results.append(FakeCalendarEvent(ical_text, uid=uid, server=self._server))
cal = Calendar.from_ical(ical_text)
for component in cal.walk("VEVENT"):
raw_start = component.get("dtstart")
raw_end = component.get("dtend")
if raw_start is None:
continue
event_start = raw_start.dt
event_end = raw_end.dt if raw_end is not None else event_start
overlaps = True
if start is not None:
overlaps = overlaps and event_end > start
if end is not None:
overlaps = overlaps and event_start < end
if overlaps:
results.append(FakeCalendarEvent(ical_text, uid=uid, server=self._server))
break
return results
def get_event_by_uid(self, uid: str) -> FakeCalendarEvent:
@@ -266,6 +303,7 @@ class TestCalDAVSynchronize:
pronote_data=pronote_data,
settings=full_settings,
client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
)
# Vérifications
@@ -308,6 +346,7 @@ class TestCalDAVSynchronize:
pronote_data=pronote_data,
settings=full_settings,
client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
)
assert result1.added == 1
@@ -316,6 +355,7 @@ class TestCalDAVSynchronize:
pronote_data=pronote_data,
settings=full_settings,
client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
)
assert result2.status == CalDAVSyncStatus.SKIPPED
assert result2.added == 0
@@ -366,6 +406,7 @@ class TestCalDAVSynchronize:
pronote_data=pronote_data,
settings=full_settings,
client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
)
# Vérifications : le résultat indique un ajout, mais le serveur reste vide
@@ -415,6 +456,7 @@ class TestCalDAVSynchronize:
pronote_data=pronote_data,
settings=full_settings,
client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
)
# Vérifications
@@ -454,6 +496,7 @@ class TestCalDAVSynchronize:
pronote_data=pronote_data,
settings=full_settings,
client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
)
# Vérifications
@@ -496,6 +539,7 @@ class TestCalDAVSynchronize:
pronote_data=pronote_data,
settings=full_settings,
client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
)
# Vérifications
@@ -538,6 +582,7 @@ class TestCalDAVSynchronize:
pronote_data=pronote_data,
settings=full_settings,
client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
)
# Vérifications : l'événement non géré est toujours présent
@@ -593,6 +638,7 @@ class TestCalDAVSynchronize:
pronote_data=pronote_data,
settings=full_settings,
client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
)
# Vérifications
@@ -632,6 +678,7 @@ class TestCalDAVSynchronize:
pronote_data=pronote_data,
settings=full_settings,
client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
)
# Vérifications
@@ -667,6 +714,7 @@ class TestCalDAVSynchronize:
pronote_data=pronote_data,
settings=full_settings,
client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 12, 20, 12, 0),
)
# Vérifications
@@ -758,6 +806,7 @@ class TestCalDAVSynchronize:
pronote_data=pronote_data,
settings=full_settings,
client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
)
# Vérifications
@@ -770,3 +819,111 @@ class TestCalDAVSynchronize:
events = fake_caldav_server.get_events()
assert "UNMANAGED-001" in events
assert len(events) == 4 # L-001 (mis à jour), L-002, hw-002, UNMANAGED-001
def test_sync_filters_out_of_window_events(
self,
fake_caldav_server: FakeCalDAVServer,
full_settings: Settings,
) -> None:
"""Teste qu'un cours hors fenêtre n'est pas écrit sur le calendrier.
Avec ``now=2026-01-14`` et la configuration par défaut (7 jours dans
le passé, 30 dans le futur), la fenêtre couvre les journées complètes
du 2026-01-07 au 2026-02-14 (fin exclusive) : un cours le 2026-02-15
doit être filtré et ne jamais atteindre le planificateur ni le
calendrier.
"""
# Données Pronote avec un cours hors de la fenêtre
pronote_data = PronoteData(
lessons=[
Lesson(
id="L-001",
start=datetime(2026, 2, 15, 8, 0),
end=datetime(2026, 2, 15, 9, 0),
subject="Mathématiques",
teachers=("Prof Dupont",),
rooms=("Salle 101",),
status=LessonStatus.NORMAL,
group=None,
content=None,
)
],
homeworks=[],
school_events=[],
messages=[],
target_date=datetime(2026, 2, 15).date(),
generated_at=datetime(2026, 2, 15, 0, 0),
)
# Synchronisation
result = synchronize(
pronote_data=pronote_data,
settings=full_settings,
client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
)
# Vérifications : le cours hors fenêtre n'est jamais écrit
assert result.status == CalDAVSyncStatus.SKIPPED
assert result.added == 0
assert result.updated == 0
assert result.removed == 0
assert len(fake_caldav_server.get_events()) == 0
def test_sync_idempotent_at_window_edge(
self,
fake_caldav_server: FakeCalDAVServer,
full_settings: Settings,
) -> None:
"""Teste l'idempotence pour un cours tardif en fin de fenêtre.
Un cours à 23:00 le dernier jour complet de la fenêtre (2026-02-13 ;
la fenêtre se termine de façon exclusive le 2026-02-14) doit être
ajouté une seule fois : une seconde exécution identique doit le
détecter comme déjà présent et ne rien réécrire.
"""
# Données Pronote avec un cours tardif le dernier jour de la fenêtre
pronote_data = PronoteData(
lessons=[
Lesson(
id="L-001",
start=datetime(2026, 2, 13, 23, 0),
end=datetime(2026, 2, 13, 23, 30),
subject="Mathématiques",
teachers=("Prof Dupont",),
rooms=("Salle 101",),
status=LessonStatus.NORMAL,
group=None,
content=None,
)
],
homeworks=[],
school_events=[],
messages=[],
target_date=datetime(2026, 2, 13).date(),
generated_at=datetime(2026, 2, 13, 0, 0),
)
# Première synchronisation : le cours est ajouté
result1 = synchronize(
pronote_data=pronote_data,
settings=full_settings,
client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
)
assert result1.status == CalDAVSyncStatus.SUCCESS
assert result1.added == 1
assert len(fake_caldav_server.get_events()) == 1
# Seconde synchronisation identique : rien n'est réécrit
result2 = synchronize(
pronote_data=pronote_data,
settings=full_settings,
client_factory=fake_caldav_server.client_factory,
now=datetime(2026, 1, 14, 12, 0),
)
assert result2.status == CalDAVSyncStatus.SKIPPED
assert result2.added == 0
assert result2.updated == 0
assert result2.removed == 0
assert len(fake_caldav_server.get_events()) == 1