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

@@ -103,15 +103,14 @@ Le projet doit implémenter les fonctionnalités suivantes, dans l'ordre logique
┌───────────────────────────────────────────────────────────────────────────────┐
│ SYNCHRONISATION CALDAV │
│ ┌─────────────────┐ ┌─────────────────┐ ┌───────────────────────────┐ │
│ │ Plan de sync │ │ Sync │ │ État local │ │
│ │ (CalDavSyncPlan)│ │ différentielle │ │ (SQLite/JSON) │ │
│ └────────┬────────┘ └────────┬────────┘ └──────────────┬────────────┘ │
│ │ │ │ │
│ └───────────────────────┼────────────────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
│ │ Résultat de sync (CalDavSyncResult) │ │
│ │ - La synchronisation CalDAV est **différentielle et idempotente** : │
│ │ le scan du calendrier distant est la source de vérité. │
│ │ Aucun état local (SQLite/JSON) n'est utilisé. │
│ └─────────────────────────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────────────────┘
@@ -202,8 +201,7 @@ pronote_sync/
│ │ └── fallback.py # Logique de repli
│ ├── blog/ # Blog (RSS)
│ │ ├── __init__.py
│ │ ── rss.py # Client RSS (feedparser)
│ │ └── state.py # État local (déduplication, cache HTTP)
│ │ ── rss.py # Client RSS (feedparser)
│ └── theoretical/ # Agenda théorique
│ ├── __init__.py
│ ├── file.py # Lecture fichier JSON (parité + vacances)
@@ -211,8 +209,6 @@ pronote_sync/
├── sync/ # Synchronisation CalDAV + Blog
│ ├── __init__.py
│ ├── caldav.py # Client CalDAV (caldav)
│ ├── state.py # État de sync CalDAV (SQLite/JSON)
│ ├── blog_state.py # État de sync Blog (déduplication, cache HTTP)
│ └── diff.py # Logique de comparaison
├── synthesis/ # Synthèse IA
│ ├── __init__.py
@@ -1070,135 +1066,18 @@ class BlogRSSClient:
return text
```
#### 5 bis.7.2 Déduplication et état local
#### 5 bis.7.2 Déduplication et cache HTTP
La déduplication des articles du blog repose sur leur **GUID** (ou leur URL si le GUID est vide).
**Stratégie** :
1. Stocker un **fichier d'état local** (ex: `.blog_rss_state.json`) contenant la version du
format, l'**ensemble des GUID déjà traités** et les en-têtes de cache HTTP (`ETag` /
`Last-Modified`) de la dernière réponse.
2. À chaque récupération, ignorer les articles dont le GUID est **déjà présent** dans
l'ensemble des GUID connus.
3. Utiliser le **cache HTTP** (`If-Modified-Since` / `If-None-Match`) via `feedparser` pour
éviter les requêtes inutiles.
- Conserver en mémoire, **au sein du run**, l'ensemble des GUID déjà traités pour la déduplication.
- Utiliser le **cache HTTP** (`ETag` / `Last-Modified`) via `feedparser` pour éviter les requêtes inutiles.
**Exemple de fichier d'état** :
```json
{
"version": 1,
"known_guids": [
"https://blogpeda.ac-bordeaux.fr/cjeliote/?p=1625",
"https://blogpeda.ac-bordeaux.fr/cjeliote/?p=1626"
],
"etag": "abc123",
"last_modified": "Wed, 01 Sep 2026 00:00:00 GMT"
}
```
Les GUID sont triés alphabétiquement pour une sortie JSON déterministe.
**Gestionnaire d'état** (`sources/blog/state.py`) :
```python
import json
import logging
from collections.abc import Iterable
from pathlib import Path
logger = logging.getLogger(__name__)
_STATE_VERSION = 1
class BlogRSSState:
"""
Gère l'état local pour la déduplication des articles du blog et le cache HTTP.
"""
def __init__(self, state_file: str = ".blog_rss_state.json"):
self._state_file = Path(state_file)
self._known_guids: set[str] = set()
self._etag: str | None = None
self._last_modified: str | None = None
self._load()
def _load(self) -> None:
"""Charge l'état depuis le fichier."""
if not self._state_file.exists():
return
try:
data = json.loads(self._state_file.read_text(encoding="utf-8"))
if not isinstance(data, dict) or data.get("version") != _STATE_VERSION:
logger.warning(
"Fichier d'état blog RSS : version absente ou non supportée, "
"démarrage avec un état vide."
)
return
guids_data = data.get("known_guids", [])
if isinstance(guids_data, list):
self._known_guids = {guid for guid in guids_data if isinstance(guid, str)}
etag_data = data.get("etag")
if isinstance(etag_data, str):
self._etag = etag_data
last_modified_data = data.get("last_modified")
if isinstance(last_modified_data, str):
self._last_modified = last_modified_data
except Exception as e:
logger.warning(f"Échec du chargement de l'état du blog: {e}")
self._known_guids = set()
self._etag = None
self._last_modified = None
def _save(self) -> None:
"""Sauvegarde l'état dans le fichier."""
payload = {
"version": _STATE_VERSION,
"known_guids": sorted(self._known_guids),
"etag": self._etag,
"last_modified": self._last_modified,
}
try:
with self._state_file.open("w", encoding="utf-8") as f:
json.dump(payload, f, indent=2)
except Exception as e:
logger.error(f"Échec de la sauvegarde de l'état du blog: {e}")
def get_known_guids(self) -> frozenset[str]:
"""Retourne une copie immuable des GUID connus."""
return frozenset(self._known_guids)
def add_guids(self, guids: Iterable[str]) -> None:
"""Ajoute des GUID à l'ensemble des GUID connus et sauvegarde."""
new_guids = set(guids)
if not new_guids:
return
self._known_guids.update(new_guids)
self._save()
def get_cache_headers(self) -> tuple[str | None, str | None]:
"""Retourne les en-têtes de cache HTTP mémorisés (etag, last_modified)."""
return self._etag, self._last_modified
def update_cache_headers(self, etag: str | None, last_modified: str | None) -> None:
"""Met à jour les en-têtes de cache HTTP et sauvegarde."""
self._etag = etag
self._last_modified = last_modified
self._save()
def clear(self) -> None:
"""Efface l'état (GUID et en-têtes de cache) et sauvegarde."""
self._known_guids = set()
self._etag = None
self._last_modified = None
self._save()
```
**Utilisation dans le pipeline** :
```python
Aucun fichier d'état local n'est utilisé : l'état est géré en mémoire par run.
# Initialisation
rss_client = BlogRSSClient(rss_url=settings.blog.rss_url)
blog_state = BlogRSSState()
blog_state = ## (section obsolète supprimée)()
# Récupération des nouveaux articles
known_guids = blog_state.get_known_guids()
@@ -1224,12 +1103,12 @@ blog_state.update_cache_headers(result.etag, result.last_modified)
List
from ..models.blog import BlogArticle
from ..sources.blog.rss import BlogRSSClient
from ..sources.blog.state import BlogRSSState
from ..sources.blog.state import ## (section obsolète supprimée)
def fetch_blog_step(
rss_client: BlogRSSClient,
blog_state: BlogRSSState,
blog_state: ## (section obsolète supprimée),
enabled: bool = False,
) -> List[BlogArticle]:
"""
@@ -1430,11 +1309,11 @@ def test_parse_blog_rss(mock_blog_rss_client):
@pytest.mark.unittest
def test_blog_deduplication(tmp_path):
"""Test la déduplication des articles du blog."""
from pronote_sync.sources.blog.state import BlogRSSState
from pronote_sync.sources.blog.state import ## (section obsolète supprimée)
# Créer un fichier d'état temporaire
state_file = tmp_path / "blog_state.json"
state = BlogRSSState(state_file=str(state_file))
state = ## (section obsolète supprimée)(state_file=str(state_file))
# Initialement, aucun article connu
assert state.get_known_guids() == frozenset()
@@ -3300,9 +3179,8 @@ class CalDAVClient:
sauvegardes).
> **Évolution future** : si les performances l'exigent (calendrier très chargé, scans
> trop coûteux), un **état local** (fichier JSON, SQLite ou sync-token CalDAV) pourra
> être ajouté dans un jalon ultérieur, sans changer le contrat de la synchronisation
> (§7.1, §7.2 et §7.4 restent valables).
> trop coûteux), un **état local** (sync-token CalDAV) pourra être ajouté dans un jalon
> ultérieur, sans changer le contrat de la synchronisation (§7.1, §7.2 et §7.4 restent valables).
### 7.4 Points clés
- **Différentielle** : La synchronisation compare les UID existants avec ceux à synchroniser.
@@ -4606,7 +4484,7 @@ class PipelineRunner:
synthesis_provider: Optional[SynthesisProvider],
channel: Channel,
blog_rss_client: Optional["BlogRSSClient"] = None,
blog_state: Optional["BlogRSSState"] = None,
blog_state: Optional["## (section obsolète supprimée)"] = None,
dry_run: bool = False,
):
self.pronote_fetcher = pronote_fetcher
@@ -4800,13 +4678,13 @@ exception externe brute susceptible de contenir un secret.
from pronote_sync.models.blog import BlogArticle
from pronote_sync.sources.blog.result import BlogRSSFetchResult
from pronote_sync.sources.blog.rss import BlogRSSClient
from pronote_sync.sources.blog.state import BlogRSSState
from pronote_sync.sources.blog.state import ## (section obsolète supprimée)
from .errors import PipelineError, ErrorSeverity
def fetch_blog_step(
rss_client: BlogRSSClient,
blog_state: BlogRSSState,
blog_state: ## (section obsolète supprimée),
enabled: bool = False,
) -> list[BlogArticle]:
"""
@@ -5511,13 +5389,13 @@ TOTAL 1000 10 99%
| Tokens dans les commits Git | Utiliser `.gitignore` pour `.env` et `pre-commit` pour bloquer les secrets. | `git grep "icalsecurise\|password" -- .` (contenu suivi courant) | ❌ Interdit |
| Clés API dans le code | Toujours charger depuis les variables d'environnement. | `grep -r "api_key\s*=" src/` | ❌ Interdit |
| Mots de passe en clair | Toujours utiliser `SecretStr` ou `getpass`. | `grep -r "password\s*=" src/` | ❌ Interdit |
| Fichiers d'état non protégés | Appliquer `chmod 600` et exclure du Git (`.gitignore`). | `ls -la .pronote_sync_state.json` (doit être `-rw-------`) | ✅ Obligatoire |
| Fichiers d'état non protégés | Appliquer `chmod 600` et exclure du Git (`.gitignore`). | Vérification manuelle des fichiers locaux sensibles | ✅ Obligatoire |
### 13.2 Validation des entrées
| **Risque** | **Mesure de mitigation** | **Vérification** | **Statut** |
|-------------------------------------|----------------------------------------------------------------------------------------|-------------------------------------------|------------|
| Injection SQL (si SQLite) | Utiliser des requêtes paramétrées (pas de string formatting). | Revue du code utilisant SQLite. | ✅ Obligatoire |
| Validation des entrées SQL | Utiliser des requêtes paramétrées (pas de string formatting). | Revue du code utilisant des requêtes SQL. | ✅ Obligatoire |
| Injection XMPP | Échapper les messages XMPP (slixmpp le fait automatiquement). | Tests avec des messages contenant `<`, `>`, `&`. | ✅ Obligatoire |
| Parsing iCal malveillant | Valider que le flux contient `BEGIN:VCALENDAR` avant parsing. | Tests avec des flux invalides. | ✅ Obligatoire |
| URLs malveillantes | Valider les URLs avec `urllib.parse` avant utilisation. | Tests avec des URLs malformées. | ✅ Obligatoire |
@@ -5860,7 +5738,7 @@ Exemple de ligne cron (exécution tous les jours à 18h) :
| **Flux iCal incomplet** | Certains établissements désactivent l'export des devoirs dans iCal. | Impossible de récupérer les devoirs via iCal. | Basculer sur `pronotepy` pour les devoirs. |
| **`pronotepy` en maintenance** | `pronotepy` est en mode maintenance (bugfixes uniquement). | Risque de cassure si Pronote met à jour son protocole. | Surveiller les issues GitHub de `pronotepy`. |
| **Messages non disponibles dans iCal** | Les messages, discussions et informations ne sont **pas** dans le flux iCal. | Impossible de récupérer ces données sans `pronotepy`. | Utiliser `pronotepy` pour les messages. |
| **CalDAV : support variable** | Certains serveurs CalDAV ont des limitations (ex: pas de sync-token). | Synchronisation moins efficace. | Utiliser un état local (SQLite/JSON) pour compenser. |
| **CalDAV : support variable** | Certains serveurs CalDAV ont des limitations (ex: pas de sync-token). | Synchronisation moins efficace. | Utiliser un sync-token CalDAV pour compenser si nécessaire. |
| **XMPP : serveurs variés** | Les serveurs XMPP ont des configurations différentes (ex: authentification, TLS). | Problèmes de compatibilité possibles. | Tester avec le serveur XMPP cible avant déploiement. |
| **IA : coûts et latence** | Les API IA peuvent être coûteuses et lentes. | Synthèse IA peut être désactivée ou lente. | Limiter la taille du prompt et utiliser un timeout. |
| **Python 3.13.5+** | Le projet nécessite Python ≥ 3.13.5. | Incompatibilité avec les anciennes versions de Python. | Documenter clairement la version requise. |