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

@@ -183,9 +183,9 @@ def test_lesson_add_when_not_in_remote() -> None:
"""
lesson = _make_lesson(lesson_id="L-1234")
pronote_data = _make_pronote_data(lessons=[lesson])
remote_managed: list[tuple[str, Event]] = []
remote_managed: list[tuple[str, str, Event]] = []
plan = compute_plan(pronote_data, remote_managed)
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
assert len(plan.lessons_to_add) == 1
assert plan.lessons_to_add[0].id == "L-1234"
@@ -208,9 +208,9 @@ def test_lesson_update_when_signature_differs() -> None:
dtstart=datetime(2026, 1, 15, 8, 0),
dtend=datetime(2026, 1, 15, 9, 0),
)
remote_managed: list[tuple[str, Event]] = [("L-1234", remote_event)]
remote_managed: list[tuple[str, str, Event]] = [("L-1234", "L-1234", remote_event)]
plan = compute_plan(pronote_data, remote_managed)
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
assert len(plan.lessons_to_add) == 0
assert len(plan.lessons_to_update) == 1
@@ -228,9 +228,9 @@ def test_lesson_idempotent_when_signature_same() -> None:
# Créer un VEVENT distant avec les mêmes propriétés
remote_event = lesson_to_vevent(lesson)
remote_managed: list[tuple[str, Event]] = [("L-1234", remote_event)]
remote_managed: list[tuple[str, str, Event]] = [("L-1234", "L-1234", remote_event)]
plan = compute_plan(pronote_data, remote_managed)
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
assert len(plan.lessons_to_add) == 0
assert len(plan.lessons_to_update) == 0
@@ -250,9 +250,9 @@ def test_lesson_remove_when_not_in_local() -> None:
dtstart=datetime(2026, 1, 15, 8, 0),
dtend=datetime(2026, 1, 15, 9, 0),
)
remote_managed: list[tuple[str, Event]] = [("L-9999", remote_event)]
remote_managed: list[tuple[str, str, Event]] = [("L-9999", "L-9999", remote_event)]
plan = compute_plan(pronote_data, remote_managed)
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
assert len(plan.lessons_to_add) == 0
assert len(plan.lessons_to_update) == 0
@@ -260,6 +260,62 @@ def test_lesson_remove_when_not_in_local() -> None:
assert plan.lessons_to_remove[0] == "L-9999"
def test_plan_with_suffixed_remote_uid_matches_canonical() -> None:
"""Vérifie qu'un UID distant suffixé apparié par UID canonique ne produit rien.
L'événement distant porte un UID brut suffixé
(``L-1234-20260905T080000Z-Index-Education``) dont la forme canonique
(``L-1234``) correspond au cours local ; les signatures étant identiques,
le plan doit être vide — sans ajout, mise à jour ou suppression artificiels.
:return: None
"""
lesson = _make_lesson(lesson_id="L-1234", subject="Mathématiques")
pronote_data = _make_pronote_data(lessons=[lesson])
# VEVENT distant construit à partir du même cours : contenu sémantique
# identique (seul l'UID brut stocké diffère, capturé par le triplet).
remote_event = lesson_to_vevent(lesson)
remote_managed: list[tuple[str, str, Event]] = [
("L-1234-20260905T080000Z-Index-Education", "L-1234", remote_event)
]
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
assert len(plan.lessons_to_add) == 0
assert len(plan.lessons_to_update) == 0
assert len(plan.lessons_to_remove) == 0
def test_plan_with_suffixed_remote_uid_and_no_local_adds_to_remove() -> None:
"""Vérifie que la suppression d'un UID distant suffixé utilise l'UID brut.
Un événement distant orphelin (aucun cours local) dont l'UID brut est
suffixé doit être supprimé en ciblant l'UID brut stocké sur le serveur,
et non sa forme canonique.
:return: None
"""
pronote_data = _make_pronote_data(lessons=[])
raw_uid = "L-9999-20260101T080000Z-Index-Education"
remote_event = _make_vevent(
uid=raw_uid,
summary="Ancien cours",
dtstart=datetime(2026, 1, 15, 8, 0),
dtend=datetime(2026, 1, 15, 9, 0),
)
remote_managed: list[tuple[str, str, Event]] = [(raw_uid, "L-9999", remote_event)]
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
assert len(plan.lessons_to_add) == 0
assert len(plan.lessons_to_update) == 0
assert len(plan.lessons_to_remove) == 1
# La liste de suppression contient l'UID brut, pas la forme canonique.
assert plan.lessons_to_remove[0] == raw_uid
def test_lesson_cancelled_preserved() -> None:
"""Vérifie qu'un cours annulé est traité normalement (ajout/mise à jour).
@@ -267,9 +323,9 @@ def test_lesson_cancelled_preserved() -> None:
"""
lesson = _make_lesson(lesson_id="L-1234", status=LessonStatus.CANCELLED)
pronote_data = _make_pronote_data(lessons=[lesson])
remote_managed: list[tuple[str, Event]] = []
remote_managed: list[tuple[str, str, Event]] = []
plan = compute_plan(pronote_data, remote_managed)
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
# Un cours annulé doit aller dans lessons_to_add comme n'importe quel autre cours
assert len(plan.lessons_to_add) == 1
@@ -287,9 +343,9 @@ def test_homework_add_when_not_in_remote() -> None:
"""
homework = _make_homework(homework_id="HW-5678")
pronote_data = _make_pronote_data(homeworks=[homework])
remote_managed: list[tuple[str, Event]] = []
remote_managed: list[tuple[str, str, Event]] = []
plan = compute_plan(pronote_data, remote_managed)
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
assert len(plan.homeworks_to_add) == 1
assert plan.homeworks_to_add[0].id == "HW-5678"
@@ -312,9 +368,11 @@ def test_homework_update_when_signature_differs() -> None:
dtstart=datetime(2026, 1, 20, 8, 0),
dtend=datetime(2026, 1, 20, 18, 0),
)
remote_managed: list[tuple[str, Event]] = [("homework-HW-5678", remote_event)]
remote_managed: list[tuple[str, str, Event]] = [
("homework-HW-5678", "homework-HW-5678", remote_event)
]
plan = compute_plan(pronote_data, remote_managed)
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
assert len(plan.homeworks_to_add) == 0
assert len(plan.homeworks_to_update) == 1
@@ -335,9 +393,11 @@ def test_homework_remove_when_not_in_local() -> None:
dtstart=datetime(2026, 1, 20, 8, 0),
dtend=datetime(2026, 1, 20, 18, 0),
)
remote_managed: list[tuple[str, Event]] = [("homework-HW-9999", remote_event)]
remote_managed: list[tuple[str, str, Event]] = [
("homework-HW-9999", "homework-HW-9999", remote_event)
]
plan = compute_plan(pronote_data, remote_managed)
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
assert len(plan.homeworks_to_add) == 0
assert len(plan.homeworks_to_update) == 0
@@ -358,9 +418,9 @@ def test_school_event_add_when_not_in_remote() -> None:
from_date=date(2026, 12, 20),
)
pronote_data = _make_pronote_data(school_events=[school_event])
remote_managed: list[tuple[str, Event]] = []
remote_managed: list[tuple[str, str, Event]] = []
plan = compute_plan(pronote_data, remote_managed)
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
assert len(plan.school_events_to_add) == 1
assert plan.school_events_to_add[0].label == "Vacances de Noël"
@@ -390,11 +450,15 @@ def test_school_event_update_when_signature_differs() -> None:
dtstart=datetime(2026, 12, 20, 0, 0),
dtend=datetime(2027, 1, 5, 0, 0),
)
remote_managed: list[tuple[str, Event]] = [
("school-event-Vacances de Noël-2026-12-20", remote_event)
remote_managed: list[tuple[str, str, Event]] = [
(
"school-event-Vacances de Noël-2026-12-20",
"school-event-Vacances de Noël-2026-12-20",
remote_event,
)
]
plan = compute_plan(pronote_data, remote_managed)
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
assert len(plan.school_events_to_add) == 0
assert len(plan.school_events_to_update) == 1
@@ -415,11 +479,15 @@ def test_school_event_remove_when_not_in_local() -> None:
dtstart=datetime(2026, 1, 1, 0, 0),
dtend=datetime(2026, 1, 2, 0, 0),
)
remote_managed: list[tuple[str, Event]] = [
("school-event-Ancien événement-2026-01-01", remote_event)
remote_managed: list[tuple[str, str, Event]] = [
(
"school-event-Ancien événement-2026-01-01",
"school-event-Ancien événement-2026-01-01",
remote_event,
)
]
plan = compute_plan(pronote_data, remote_managed)
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
assert len(plan.school_events_to_add) == 0
assert len(plan.school_events_to_update) == 0
@@ -443,9 +511,11 @@ def test_uid_routing_homework_to_remove() -> None:
dtstart=datetime(2026, 1, 20, 8, 0),
dtend=datetime(2026, 1, 20, 18, 0),
)
remote_managed: list[tuple[str, Event]] = [("homework-HW-9999", remote_event)]
remote_managed: list[tuple[str, str, Event]] = [
("homework-HW-9999", "homework-HW-9999", remote_event)
]
plan = compute_plan(pronote_data, remote_managed)
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
# Ne doit PAS aller dans lessons_to_remove
assert len(plan.lessons_to_remove) == 0
@@ -466,9 +536,11 @@ def test_uid_routing_school_event_to_remove() -> None:
dtstart=datetime(2026, 12, 20, 0, 0),
dtend=datetime(2027, 1, 5, 0, 0),
)
remote_managed: list[tuple[str, Event]] = [("school-event-Vacances-2026-12-20", remote_event)]
remote_managed: list[tuple[str, str, Event]] = [
("school-event-Vacances-2026-12-20", "school-event-Vacances-2026-12-20", remote_event)
]
plan = compute_plan(pronote_data, remote_managed)
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
# Ne doit PAS aller dans lessons_to_remove
assert len(plan.lessons_to_remove) == 0
@@ -485,9 +557,9 @@ def test_empty_inputs_empty_plan() -> None:
:return: None
"""
pronote_data = _make_pronote_data(lessons=[], homeworks=[], school_events=[])
remote_managed: list[tuple[str, Event]] = []
remote_managed: list[tuple[str, str, Event]] = []
plan = compute_plan(pronote_data, remote_managed)
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
assert len(plan.lessons_to_add) == 0
assert len(plan.lessons_to_update) == 0
@@ -535,12 +607,12 @@ def test_mixed_scenario() -> None:
dtend=datetime(2026, 1, 15, 9, 0),
)
remote_managed: list[tuple[str, Event]] = [
("L-0002", remote_lesson2),
("L-0003", remote_lesson3),
remote_managed: list[tuple[str, str, Event]] = [
("L-0002", "L-0002", remote_lesson2),
("L-0003", "L-0003", remote_lesson3),
]
plan = compute_plan(pronote_data, remote_managed)
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
# Ajouts
assert len(plan.lessons_to_add) == 1
@@ -570,9 +642,9 @@ def test_unmanaged_events_not_in_remote_managed() -> None:
# remote_managed ne contient que des événements gérés (par hypothèse)
# Donc pas besoin de tester le filtrage ici - c'est la responsabilité de list_managed_events
remote_managed: list[tuple[str, Event]] = []
remote_managed: list[tuple[str, str, Event]] = []
plan = compute_plan(pronote_data, remote_managed)
plan, raw_mapping = compute_plan(pronote_data, remote_managed)
# Le cours doit être dans lessons_to_add
assert len(plan.lessons_to_add) == 1