Compare commits

...

11 Commits

Author SHA1 Message Date
0dd4ee68c1 docs: define strict dry-run contract
# Conflicts:
#	.secrets.baseline
2026-09-11 11:57:21 +02:00
fde8fbe264 fix: reject QR token dry-run 2026-09-11 11:44:13 +02:00
145dace289 fix: wire dry-run source state policy 2026-09-11 11:44:13 +02:00
642bbf16c1 fix: prevent source state writes in dry-run 2026-09-11 11:44:13 +02:00
2b82ffb38d fix(auth) : sérialiser le cycle QR/token local (#23) 2026-09-11 11:41:57 +02:00
4228c1e636 fix: ignore QR token lockfile 2026-09-10 23:41:19 +02:00
8b924b55d1 docs: document QR token state locking 2026-09-10 21:26:43 +02:00
22a662ab39 fix: serialize QR token client operations 2026-09-10 21:24:11 +02:00
5188761209 fix: add QR token state lock primitive 2026-09-10 21:15:08 +02:00
999ed76ba7 release: v0.1.2 — authentification QR code / token Pronote
Bump version 0.1.0 → 0.1.2.
CHANGELOG mis à jour avec les ajouts, corrections et issues connues.
2026-09-10 14:02:37 +02:00
6a8685fc9d feat: authentification QR code / token pour Pronote (#6)
Ajoute le mode d'authentification PRONOTE_AUTH_MODE=qr_token comme alternative au mode password pour les instances Pronote utilisant HubEduConnect/EduConnect où l'authentification par mot de passe échoue.

Validé en production sur l'instance Bordeaux (0640036s.index-education.net) :
- Enrôlement QR code → token persisté → second run avec token_login → succès
- 144 événements agenda récupérés
- Token roté et persisté correctement même en cas d'erreur 20 (page expirée)

Issues connues non bloquantes : #20 (triple auth), #21 (erreur 20 get_informations)

Co-authored-by: coder/litellm/coder <coder@agents.invalid>
2026-09-10 13:59:42 +02:00
19 changed files with 752 additions and 127 deletions

1
.gitignore vendored
View File

@@ -50,6 +50,7 @@ Thumbs.db
.caldav_sync_state.json
# État d'authentification pronotepy (QR code / token rotation)
.pronote_auth_state.json
.pronote_auth_state.json.lock
*.state.json
# --- Local scratch / WIP files ---

View File

@@ -140,7 +140,7 @@
"filename": "GUIDE_DEV_PYTHON.md",
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
"is_verified": true,
"line_number": 5064,
"line_number": 5085,
"is_secret": false
}
],
@@ -177,5 +177,5 @@
}
]
},
"generated_at": "2026-09-08T10:45:46Z"
"generated_at": "2026-09-11T09:57:08Z"
}

View File

@@ -5,6 +5,43 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.2] - 2026-09-10
### Added
- **Authentification QR code / token** (`PRONOTE_AUTH_MODE=qr_token`) : alternative au mode `password` pour les instances Pronote utilisant HubEduConnect/EduConnect où l'authentification par mot de passe échoue (CAPTCHA, MFA, flux SAML modifié).
- Enrôlement initial via QR code (`pronotepy.qrcode_login`)
- Persistance du token rotatif dans `.pronote_auth_state.json` (permissions `0600`, écriture atomique, symlink-safe)
- Login subsequent via `pronotepy.token_login` avec token persisté
- Notification XMPP actionnable en cas d'échec de rotation (`PronoteAuthRotationError`)
- Persistance du token après chaque opération de données réussie et dans le chemin d'erreur (refresh pronotepy)
- `_is_pronotepy_configured()` mode-aware : `qr_token` ne requiert que `PRONOTE_URL`
- Redaction des secrets explicites (token, PIN, jeton QR) dans tous les logs
- Nouvelles variables d'environnement : `PRONOTE_AUTH_MODE`, `PRONOTE_QR_CODE_FILE`, `PRONOTE_QR_PIN`
- Documentation : section "Contrat d'authentification QR code / token" dans `AGENTS.md`, section QR code dans le wiki `GuidePronote`
### Fixed
- `PRONOTE_URL` ignoré à cause du double préfixe `env_prefix` (renommage `pronote_url``url` dans `PronoteSettings`)
- `PRONOTE_ENT` rendu optionnel pour les connexions pronotepy directes
- `.env.example` corrigé (`eleve.html``parent.html`)
### Changed
- Wiki `GuidePronote` enrichi : section "Quand l'ENT est obligatoire" (EduConnect/HubEduConnect), exemple Bordeaux
- `AGENTS.md` : ajout de la section §13 "Versionnage et releases"
### Known Issues
- #20 — Triple authentification pronotepy (double INIT + refresh) lors d'un run
- #21 — Erreur pronotepy 20 « La page a expiré ! (11) » sur `get_informations`
### Tests
- 694 tests passés, couverture 94.93%
- 35 nouveaux tests QR/token : config, auth_state, client, propagation, intégration end-to-end
## [0.1.0] - 2026-09-08
Initial release covering milestones M1 through M15.

View File

@@ -310,7 +310,7 @@ d'un besoin réel et testé.
| `AI_API_KEY` | Clé API pour l'API IA. | `None` | `SecretStr` |
| `AI_MODEL` | Modèle IA à utiliser (exemple recommandé : `gpt-4o-mini`). | `None` | `str \| None`|
| `AI_ALLOW_INSECURE_HTTP` | Autoriser HTTP (non sécurisé) pour `openai-compatible` uniquement. | `False` | `bool` |
| `DRY_RUN` | Mode dry-run (pas de modifications CalDAV/XMPP). | `False` | `bool` |
| `DRY_RUN` | Simulation sans sortie distante ni état local persistant ; incompatible avec `qr_token`. | `False` | `bool` |
| `LOG_LEVEL` | Niveau de log (`DEBUG`, `INFO`, `WARNING`, `ERROR`). | `INFO` | `str` |
> ⚠️ **Décision d'implémentation** :
@@ -2259,7 +2259,27 @@ d'informations sont non critiques et peuvent retourner une liste vide avec un wa
Les objets renvoyés par `client.homework(start, end)` couvrent une fenêtre. Le résultat destiné à
un jour cible est donc filtré explicitement sur `homework.date == target_date`.
#### 5.1.8 Logique de repli (`sources/pronote/fallback.py`)
#### 5.1.8 Verrou du cycle d'authentification QR/token
En mode `qr_token`, le token Pronote est un état partagé et rotatif. Afin d'éviter que deux
exécutions ne réutilisent ou n'écrasent cet état simultanément, le client protège chaque cycle
d'authentification et de récupération par un verrou POSIX local non bloquant, situé dans
`.pronote_auth_state.json.lock`, à côté de `.pronote_auth_state.json`.
Le verrou couvre l'ensemble du cycle QR/token : chargement de l'état, connexion par token ou
enrôlement QR initial, opération de données (agenda, devoirs, messages ou informations), puis
persistance des credentials actualisées. Une tentative concurrente échoue immédiatement avec une
erreur d'état d'authentification expurgée ; elle ne patiente pas et ne relance pas
l'authentification. Le contenu du token, le PIN et les autres credentials ne sont jamais inclus
dans les logs ni dans ce message d'erreur.
Ce mécanisme est un contrat **local** : il coordonne des processus sur le même hôte Linux et un
filesystem local. Pour des déploiements conteneurisés, les conteneurs qui partagent le même compte
Pronote doivent également partager le fichier d'état et son fichier de verrou. Le verrou ne fournit
aucune exclusion fiable entre plusieurs hôtes ou via NFS ; dans ces cas, l'opérateur doit prévoir
une exclusion externe ou utiliser un token distinct par instance.
#### 5.1.9 Logique de repli (`sources/pronote/fallback.py`)
Le `PronoteFetcher` dépend de `Settings` et d'un protocole de client injecté ; il ne construit pas
de singleton et ne contient pas d'identifiants dupliqués.
@@ -3221,7 +3241,8 @@ class CalDAVClient:
### 7.4 Points clés
- **Différentielle** : La synchronisation compare les UID existants avec ceux à synchroniser.
- **Idempotence** : Deux exécutions identiques ne modifient pas le calendrier.
- **Dry-run** : Mode obligatoire pour tester sans effet de bord.
- **Dry-run** : Les lectures sont autorisées, sans sortie distante ni état local persistant ; le
mode `qr_token` est refusé avant connexion car son authentification implique une rotation distante.
- **Marquage** : Les événements gérés sont marqués avec `X-PRONOTE-SYNC-MANAGED: v1` pour éviter les conflits.
- **Cours annulés** : Conservés avec `STATUS:CANCELLED` (ne pas supprimer).
- **Plan explicite** : Le `CalDAVSyncPlan` est calculé avant l'exécution.
@@ -5760,7 +5781,7 @@ repos:
|----------------------------------------|-----------------------------------------------------------------------------------------------------|-----------------|------------|
| Configuration des variables d'environnement | Vérifier que toutes les variables obligatoires sont définies (voir [Section 3.1](#31-variables-denvironnement)). | ✅ Oui | |
| Vérification des secrets | Exécuter le script de vérification de sécurité (voir [Section 13.6](#136-exemple-de-script-de-vérification-de-sécurité)). | ✅ Oui | |
| Test en mode dry-run | Exécuter le pipeline avec `DRY_RUN=true` pour vérifier que tout fonctionne sans effet de bord. | ✅ Oui | |
| Test en mode dry-run | Exécuter le pipeline avec `DRY_RUN=true` sans sortie distante ni état local persistant (`qr_token` exclu). | ✅ Oui | |
| Configuration des logs | Vérifier que les logs sont configurés avec masquage des secrets (voir [Section 4.2](#42-implémentation)). | ✅ Oui | |
| Vérification des dépendances | Exécuter `pip check` pour vérifier que toutes les dépendances sont installées. | ✅ Oui | |
| Configuration du cron (si planifié) | Configurer une tâche cron pour exécuter le script régulièrement (ex: tous les jours à 18h). | ⚠️ Non | |
@@ -5940,7 +5961,7 @@ Exemple de ligne cron (exécution tous les jours à 18h) :
| **CalDAV** | Protocole pour synchroniser des calendriers via HTTP. |
| **XMPP** | Protocole de messagerie instantanée (anciennement Jabber). |
| **UID** | Identifiant unique pour un événement iCal/CalDAV. |
| **Dry-run** | Mode de test où aucune modification n'est appliquée (lecture seule). |
| **Dry-run** | Simulation avec lectures autorisées, sans sortie distante ni état local persistant ; incompatible avec `qr_token`. |
| **Idempotence** | Propriété d'une opération qui produit le même résultat si elle est exécutée plusieurs fois. |
| **Reverse-engineering** | Technique consistant à analyser un logiciel pour en comprendre le fonctionnement interne. |
@@ -6107,7 +6128,7 @@ Ce guide fournit une **base architecturale et technique solide** pour développe
| **CalDAV** | Protocole pour synchroniser des calendriers via HTTP. |
| **XMPP** | Protocole de messagerie instantanée (anciennement Jabber). |
| **UID** | Identifiant unique pour un événement iCal/CalDAV. |
| **Dry-run** | Mode de test où aucune modification n'est appliquée (lecture seule). |
| **Dry-run** | Simulation avec lectures autorisées, sans sortie distante ni état local persistant ; incompatible avec `qr_token`. |
| **Idempotence** | Propriété d'une opération qui produit le même résultat si elle est exécutée plusieurs fois. |
| **Reverse-engineering** | Technique consistant à analyser un logiciel pour en comprendre le fonctionnement interne. |

View File

@@ -36,10 +36,16 @@ pronote-sync --dry-run --log-level DEBUG
```bash
pronote-sync # Exécute la synchronisation
pronote-sync --dry-run # Simulation sans écriture
pronote-sync --dry-run # Simulation : lectures autorisées, aucune écriture persistante ni sortie distante
pronote-sync --log-level DEBUG # Verbosité des journaux
```
En `--dry-run`, les données peuvent être lues pour construire la simulation, mais aucun état local
de source n'est enregistré : l'état RSS reste en mémoire pendant l'exécution. Les écritures CalDAV
et l'envoi XMPP sont également désactivés. Le mode
`PRONOTE_AUTH_MODE=qr_token` est refusé avant toute connexion, car la rotation de son token ne peut
pas garantir un état persistant cohérent pendant une simulation.
---
## 🛠️ Déploiement

14
TODO.md
View File

@@ -147,7 +147,8 @@ Synchroniser différentiellement les événements Pronote vers le calendrier Cal
- Le plan de sync est correctement calculé (données Pronote vs événements distants gérés).
- Un changement de source iCal ↔ `pronotepy` ne crée ni doublon ni suppression/ajout artificiel pour un cours équivalent.
- Un run dry-run n'écrit rien ; deux runs identiques donnent un résultat identique.
- Un run dry-run autorise les lectures, mais ne produit aucune sortie distante ni écriture locale
persistante ; `qr_token` est refusé avant connexion. Deux runs identiques donnent le même résultat.
- Les événements annulés restent (`STATUS:CANCELLED`) et sont marqués `MANAGED`.
- Les événements non marqués ne sont jamais modifiés ni supprimés.
@@ -223,7 +224,9 @@ Composer et orchestrer toutes les étapes avec gestion d'erreurs dégradée et m
- [x] Créer les étapes `pipeline/steps/` : `fetch.py`, `normalize.py`, `compare.py`, `caldav_sync.py`, `synthesis.py`, `send.py`, `fetch_blog.py`.
- [x] Créer `pipeline/run.py` : `PipelineRunner` (composition root) orchestrant fetch → normalize → fetch_blog → compare → caldav_sync → synthesis → send.
- [x] Gérer les erreurs dégradées (continuer sauf critique) et renvoyer `(PronoteData, erreurs + warns)`.
- [x] Implémenter le mode `dry_run` (aucune écriture CalDAV/XMPP).
- [x] Implémenter le mode `dry_run` : lectures autorisées, aucune écriture CalDAV/XMPP ni écriture
persistante locale ; l'état RSS reste en mémoire pendant l'exécution, et
`PRONOTE_AUTH_MODE=qr_token` est refusé avant toute connexion.
- [x] Câbler l'injection des dépendances (Protocol + composition root), sans singleton global.
- [x] Réutiliser, dans une même exécution, un unique téléchargement/parsing iCal pour l'agenda et les devoirs lorsque les sources sélectionnées le permettent ; rester sur un cache local au run, sans cache global ni persistant.
@@ -231,7 +234,9 @@ Composer et orchestrer toutes les étapes avec gestion d'erreurs dégradée et m
- [x] Le pipeline complet s'exécute de bout en bout (mocks) dans le bon ordre.
- [x] Une sélection iCal commune à l'agenda et aux devoirs ne déclenche qu'un téléchargement/parsing du flux par run.
- [x] Une erreur non critique (ex : synthèse IA) n'empêche pas l'envoi XMPP.
- [x] `dry_run=True` n'effectue aucune écriture ; aucune source disponible → erreur critique explicite.
- [x] `dry_run=True` autorise les lectures mais n'effectue aucune écriture persistante locale ni
sortie distante ; aucune source disponible → erreur critique explicite. L'état RSS n'est pas
enregistré et `qr_token` est refusé avant toute connexion.
- [x] Si `THEORETICAL_AGENDA_PATH` est absent, le pipeline produit un diff vide sans erreur et n'instancie pas `AgendaComparator` ; si présent, il instancie le comparateur et effectue la comparaison.
- [x] Les erreurs critiques (`PipelineCriticalError`) propagées depuis une étape non-bloquante arrêtent le pipeline.
@@ -247,7 +252,8 @@ Exposer le lancement du pipeline via une interface en ligne de commande.
- [x] Gérer le code de retour et l'affichage des erreurs (redactées).
### Critères d'acceptation
- `pronote-sync --dry-run --log-level DEBUG` s'exécute sans effet de bord.
- `pronote-sync --dry-run --log-level DEBUG` s'exécute sans sortie distante ni état local persistant ;
le mode `qr_token`, qui implique une rotation distante, est refusé avant connexion.
- Le script console est installable (`[project.scripts]` dans `pyproject.toml`).
- Les erreurs affichées ne contiennent aucun secret, y compris avec l'affichage d'un traceback complet en mode debug.

View File

@@ -42,8 +42,11 @@ bloquent donc pas le déploiement. Il ne valide ni les valeurs ni les permission
du fichier d'environnement. Pour analyser seulement le contenu indexé avant un
commit, utilisez `scripts/check_secrets.py --staged`.
Le dry-run vérifie le pipeline sans appliquer les écritures de synchronisation ;
il ne remplace pas une vérification des paramètres réellement chargés.
Le dry-run autorise les lectures nécessaires à la simulation, mais n'applique aucune sortie
CalDAV/XMPP et ne modifie aucun état local persistant. L'état RSS reste limité à la mémoire du
processus. Le mode `PRONOTE_AUTH_MODE=qr_token` est incompatible avec cette garantie : la commande
le refuse avant toute connexion afin de ne pas désynchroniser le token local du token distant.
Le dry-run ne remplace pas une vérification des paramètres réellement chargés.
## Installation systemd

View File

@@ -32,7 +32,10 @@ def _parse_arguments(arguments: Sequence[str] | None = None) -> argparse.Namespa
"--dry-run",
action="store_true",
default=None,
help="Simule la synchronisation sans écrire vers CalDAV ni XMPP.",
help=(
"Simule la synchronisation sans sortie distante ni état local persistant "
"(incompatible avec PRONOTE_AUTH_MODE=qr_token)."
),
)
parser.add_argument(
"--log-level",

View File

@@ -38,6 +38,21 @@ class PronoteAuthRotationError(PronoteSyncError):
super().__init__(message)
class PronoteAuthStateLockError(PronoteSyncError):
"""Erreur levée lorsqu'un autre processus détient l'état d'authentification.
Cette erreur indique qu'une opération QR code / token concurrente est en
cours. Son message ne contient ni chemin local sensible ni credential.
"""
def __init__(self, message: str) -> None:
"""Initialise l'erreur de contention du verrou d'état.
:param message: Message actionnable expurgé décrivant la contention.
"""
super().__init__(message)
class ErrorSeverity(StrEnum):
"""Niveau de gravité d'une erreur produite par le pipeline."""

View File

@@ -126,6 +126,9 @@ class PipelineRunner:
:rtype: PipelineRunner
"""
effective_dry_run = settings.app.dry_run if dry_run is None else dry_run
if effective_dry_run and settings.pronote.auth_mode == "qr_token":
raise ValueError("Le mode qr_token n'est pas compatible avec le dry-run.")
persistence_enabled = not effective_dry_run
theoretical_provider = get_theoretical_provider(
settings.app.theoretical_agenda_path,
settings.app.school_holidays_path,
@@ -136,7 +139,9 @@ class PipelineRunner:
AgendaComparator(theoretical_provider) if theoretical_provider is not None else None
)
blog_client = BlogRSSClient(settings.blog.rss_url) if settings.blog.enabled else None
blog_state = BlogRSSState() if settings.blog.enabled else None
blog_state = (
BlogRSSState(persistence_enabled=persistence_enabled) if settings.blog.enabled else None
)
return cls(
settings=settings,
pronote_fetcher=PronoteFetcher(
@@ -144,7 +149,9 @@ class PipelineRunner:
PronoteClient(
settings.pronote,
auth_state=(
PronoteAuthState() if settings.pronote.auth_mode == "qr_token" else None
PronoteAuthState(persistence_enabled=persistence_enabled)
if settings.pronote.auth_mode == "qr_token"
else None
),
),
),

View File

@@ -37,15 +37,24 @@ class BlogRSSState:
:param state_file: Chemin du fichier d'état JSON (``str`` ou
:class:`~pathlib.Path`). ``".blog_rss_state.json"`` par défaut.
:param persistence_enabled: Si ``False``, charge l'état existant mais ne
modifie jamais le fichier d'état. ``True`` par défaut.
"""
def __init__(self, state_file: Path | str = ".blog_rss_state.json") -> None:
def __init__(
self,
state_file: Path | str = ".blog_rss_state.json",
persistence_enabled: bool = True,
) -> None:
"""Initialise le gestionnaire d'état depuis le fichier JSON.
:param state_file: Chemin du fichier d'état JSON (``str`` ou
:class:`~pathlib.Path`). ``".blog_rss_state.json"`` par défaut.
:param persistence_enabled: Si ``False``, charge l'état existant mais
désactive toutes les écritures sur disque. ``True`` par défaut.
"""
self._state_file = Path(state_file)
self._persistence_enabled = persistence_enabled
self._known_guids: set[str] = set()
self._etag: str | None = None
self._last_modified: str | None = None
@@ -98,6 +107,8 @@ class BlogRSSState:
d'erreur d'écriture, une erreur est journalisée sans être
propagée et le fichier temporaire est supprimé.
"""
if not self._persistence_enabled:
return
payload = {
"version": _STATE_VERSION,
"known_guids": sorted(self._known_guids),

View File

@@ -14,10 +14,13 @@ from __future__ import annotations
import json
import logging
import os
from collections.abc import Generator
from contextlib import contextmanager
from fcntl import LOCK_EX, LOCK_NB, LOCK_UN, flock
from pathlib import Path
from typing import Any
from pronote_sync.errors import PronoteSyncError
from pronote_sync.errors import PronoteAuthStateLockError, PronoteSyncError
from pronote_sync.utils.redaction import redact_exception, redact_secrets
logger = logging.getLogger(__name__)
@@ -38,9 +41,15 @@ class PronoteAuthState:
:param state_file: Chemin du fichier d'état JSON (``str`` ou
:class:`~pathlib.Path`). ``".pronote_auth_state.json"`` par défaut.
:param persistence_enabled: Si ``False``, charge l'état existant mais ne
modifie jamais le fichier d'état. ``True`` par défaut.
"""
def __init__(self, state_file: Path | str = ".pronote_auth_state.json") -> None:
def __init__(
self,
state_file: Path | str = ".pronote_auth_state.json",
persistence_enabled: bool = True,
) -> None:
"""Initialise le gestionnaire d'état d'authentification Pronote.
Le fichier d'état n'est pas créé à l'initialisation : il n'est écrit
@@ -48,8 +57,14 @@ class PronoteAuthState:
:param state_file: Chemin du fichier d'état JSON (``str`` ou
:class:`~pathlib.Path`). ``".pronote_auth_state.json"`` par défaut.
:param persistence_enabled: Si ``False``, charge l'état existant mais
désactive toutes les écritures ou suppressions sur disque. ``True``
par défaut.
"""
self._state_file = Path(state_file)
self._persistence_enabled = persistence_enabled
self._in_memory_credentials: dict[str, str] | None = None
self._in_memory_state_cleared = False
def load(self) -> dict[str, str] | None:
"""Charge les credentials d'authentification depuis le fichier d'état.
@@ -65,6 +80,10 @@ class PronoteAuthState:
aucun état valide n'est disponible.
:rtype: dict[str, str] | None
"""
if self._in_memory_state_cleared:
return None
if self._in_memory_credentials is not None:
return self._in_memory_credentials.copy()
if not self._state_file.exists():
logger.debug(
"Fichier d'état d'authentification Pronote %s absent, aucun token à charger.",
@@ -108,6 +127,69 @@ class PronoteAuthState:
credentials[key] = value
return credentials
@contextmanager
def lock(self) -> Generator[None]:
"""Protège une opération d'état par un verrou POSIX non bloquant.
Le verrou est conservé dans le fichier frère ``<state_file>.lock`` afin
de survivre à l'écriture atomique du fichier d'état. Le fichier de
verrou reste présent après libération et est créé en ``0600`` pour ne
pas élargir l'accès aux métadonnées de l'état sensible.
:return: Un gestionnaire de contexte qui tient le verrou exclusif.
:rtype: collections.abc.Generator[None, None, None]
:raises PronoteAuthStateLockError: Si un autre processus détient déjà
le verrou ou si son acquisition échoue.
"""
lock_file = self._state_file.with_name(f"{self._state_file.name}.lock")
descriptor: int | None = None
try:
descriptor = os.open(
str(lock_file),
os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW,
0o600,
)
os.fchmod(descriptor, 0o600)
except OSError:
logger.error("Impossible d'ouvrir le verrou d'état d'authentification Pronote.")
if descriptor is not None:
os.close(descriptor)
if descriptor is None:
raise PronoteAuthStateLockError(
"Impossible d'acquérir le verrou d'état d'authentification Pronote."
) from None
is_contended = False
lock_acquisition_failed = False
try:
flock(descriptor, LOCK_EX | LOCK_NB)
except BlockingIOError:
is_contended = True
except OSError:
logger.error("Impossible d'acquérir le verrou d'état d'authentification Pronote.")
os.close(descriptor)
lock_acquisition_failed = True
if lock_acquisition_failed:
raise PronoteAuthStateLockError(
"Impossible d'acquérir le verrou d'état d'authentification Pronote."
) from None
if is_contended:
os.close(descriptor)
raise PronoteAuthStateLockError(
"Une autre opération d'authentification Pronote est déjà en cours."
)
try:
yield
finally:
try:
flock(descriptor, LOCK_UN)
finally:
os.close(descriptor)
def save(self, credentials: dict[str, str]) -> None:
"""Sauvegarde les credentials dans le fichier d'état, de manière atomique.
@@ -126,6 +208,10 @@ class PronoteAuthState:
:raises PronoteSyncError: Si l'écriture ou le remplacement du fichier
échoue.
"""
if not self._persistence_enabled:
self._in_memory_credentials = credentials.copy()
self._in_memory_state_cleared = False
return
payload: dict[str, Any] = {
"version": _STATE_VERSION,
"credentials": credentials,
@@ -182,10 +268,16 @@ class PronoteAuthState:
"""Supprime le fichier d'état d'authentification.
Si le fichier n'existe pas, la méthode ne fait rien et aucune erreur
n'est levée.
n'est levée. Lorsque la persistance est désactivée, elle efface
uniquement les credentials conservés en mémoire et ne modifie jamais
le fichier d'état.
:raises OSError: Si la suppression du fichier existant échoue.
"""
self._in_memory_credentials = None
if not self._persistence_enabled:
self._in_memory_state_cleared = True
return
if not self._state_file.exists():
return
logger.debug(

View File

@@ -12,6 +12,8 @@ from __future__ import annotations
import json
import logging
from collections.abc import Generator
from contextlib import contextmanager
from datetime import date
from pathlib import Path
from typing import Any, Protocol
@@ -252,6 +254,26 @@ class PronoteClient:
except Exception as exc:
logger.debug("Échec de la persistance des credentials : %s", redact_exception(exc))
@contextmanager
def _qr_token_operation_lock(self) -> Generator[None]:
"""Verrouille un cycle d'authentification et de récupération QR/token.
Le verrou englobe le chargement du token, le login, l'opération de
données et la persistance qui suit. Il est volontairement absent du
mode ``password``, qui ne partage pas de fichier d'état de token.
:return: Un gestionnaire de contexte protégeant le cycle QR/token.
:rtype: collections.abc.Generator[None, None, None]
:raises PronoteAuthStateLockError: Si l'état QR/token est déjà utilisé
par une autre opération.
"""
if self._settings.auth_mode != "qr_token" or self._auth_state is None:
yield
return
with self._auth_state.lock():
yield
def _connect_password(self) -> pronotepy.Client:
"""Connecte le client ``pronotepy`` en mode ``password``.
@@ -426,6 +448,7 @@ class PronoteClient:
:return: Liste des messages des professeurs ; vide en cas d'erreur.
:rtype: list[Message]
"""
with self._qr_token_operation_lock():
try:
client = self._connect()
messages: list[Message] = []
@@ -467,6 +490,7 @@ class PronoteClient:
:return: Liste des informations et sondages ; vide en cas d'erreur.
:rtype: list[Message]
"""
with self._qr_token_operation_lock():
try:
client = self._connect()
messages: list[Message] = []
@@ -523,6 +547,7 @@ class PronoteClient:
:raises ConnectionError: Si la connexion réseau échoue.
:raises TimeoutError: Si la requête réseau expire.
"""
with self._qr_token_operation_lock():
client = self._connect()
lessons: list[Lesson] = []
for lesson in client.lessons(start, end):
@@ -576,6 +601,7 @@ class PronoteClient:
:raises ConnectionError: Si la connexion réseau échoue.
:raises TimeoutError: Si la requête réseau expire.
"""
with self._qr_token_operation_lock():
client = self._connect()
homeworks: list[Homework] = []
for hw in client.homework(start, end):

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pronote-sync"
version = "0.1.0"
version = "0.1.2"
description = "Synchronisation Pronote → CalDAV + XMPP"
license = {text = "MIT"}
requires-python = ">=3.13.5"

View File

@@ -11,6 +11,23 @@ from pronote_sync.errors import PipelineCriticalError, PipelineWarning
from pronote_sync.models.pronote import PronoteData
def test_help_documents_strict_dry_run_contract(capsys: pytest.CaptureFixture[str]) -> None:
"""L'aide CLI expose le contrat strict et l'incompatibilité QR/token.
:param capsys: Capture des sorties standard de pytest.
:return: None
"""
from pronote_sync.cli.main import main
with pytest.raises(SystemExit) as exc_info:
main(["--help"])
output = " ".join(capsys.readouterr().out.split())
assert exc_info.value.code == 0
assert "sans sortie distante ni état local persistant" in output
assert "PRONOTE_AUTH_MODE=qr_token" in output
def test_main_runs_composition_root_in_dry_run_with_requested_log_level(
mocker: MockerFixture,
) -> None:

View File

@@ -8,7 +8,13 @@ from typing import Any, cast
import pytest
from pydantic import SecretStr
from pronote_sync.config.settings import AISettings, AppSettings, PronoteSettings, Settings
from pronote_sync.config.settings import (
AISettings,
AppSettings,
BlogSettings,
PronoteSettings,
Settings,
)
from pronote_sync.errors import PipelineCriticalError, PipelineWarning, PronoteAuthRotationError
from pronote_sync.models.agenda import Lesson, LessonStatus, SchoolEvent
from pronote_sync.models.blog import BlogArticle
@@ -422,6 +428,47 @@ def test_from_settings_password_mode_passes_auth_state_none(
assert constructed[0][1] is None
def test_from_settings_rejects_qr_token_dry_run_before_constructing_pronote_client(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""QR-token dry-run fails before authentication or data dependencies are created."""
import pronote_sync.pipeline.run as run_module
qr_pin_sentinel = "qr-pin-must-not-appear"
constructed: list[object] = []
class FailingClient:
"""Pronote client sentinel that makes unexpected construction explicit."""
def __init__(self, settings: PronoteSettings, *, auth_state: object) -> None:
"""Record and reject any unexpected client construction.
:param settings: Pronote settings supplied by the composition root.
:param auth_state: Authentication state supplied by the composition root.
"""
del settings, auth_state
constructed.append(object())
raise AssertionError("PronoteClient must not be constructed for QR-token dry-run")
monkeypatch.setattr(run_module, "PronoteClient", FailingClient)
with pytest.raises(ValueError) as exc_info:
PipelineRunner.from_settings(
Settings(
app=AppSettings(dry_run=True),
pronote=PronoteSettings(
auth_mode="qr_token",
qr_pin=SecretStr(qr_pin_sentinel),
),
)
)
assert "qr_token" in str(exc_info.value)
assert "dry-run" in str(exc_info.value)
assert qr_pin_sentinel not in str(exc_info.value)
assert constructed == []
def test_from_settings_qr_token_mode_passes_auth_state_instance(
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -449,6 +496,50 @@ def test_from_settings_qr_token_mode_passes_auth_state_instance(
assert isinstance(constructed[0][1], PronoteAuthState)
@pytest.mark.parametrize("dry_run", [False, True])
def test_from_settings_configures_source_state_persistence_for_dry_run(
monkeypatch: pytest.MonkeyPatch,
dry_run: bool,
) -> None:
"""Composition disables source-state persistence only in dry-run mode."""
import pronote_sync.pipeline.run as run_module
blog_persistence: list[bool] = []
class RecordingBlogState:
"""Blog state factory recording its persistence configuration."""
def __init__(self, *, persistence_enabled: bool = True) -> None:
"""Record the requested persistence setting.
:param persistence_enabled: Whether disk writes are enabled.
"""
blog_persistence.append(persistence_enabled)
class RecordingClient:
"""Pronote client constructor accepting the injected auth state."""
def __init__(self, settings: PronoteSettings, *, auth_state: object) -> None:
"""Accept the composition-root dependencies.
:param settings: Pronote settings.
:param auth_state: Injected authentication state.
"""
del settings, auth_state
monkeypatch.setattr(run_module, "BlogRSSState", RecordingBlogState)
monkeypatch.setattr(run_module, "PronoteClient", RecordingClient)
PipelineRunner.from_settings(
Settings(
app=AppSettings(dry_run=dry_run),
blog=BlogSettings(enabled=True),
)
)
assert blog_persistence == [not dry_run]
def test_runner_reuses_ical_download_and_parse_within_one_run(
monkeypatch: pytest.MonkeyPatch,
pipeline_inputs: tuple[Lesson, Homework],

View File

@@ -35,6 +35,49 @@ def test_state_file_absent_empty_state(tmp_path: Path) -> None:
assert state.get_cache_headers() == (None, None)
def test_disabled_persistence_keeps_updates_in_memory_without_creating_file(tmp_path: Path) -> None:
"""Vérifie que la persistance désactivée conserve l'état uniquement en mémoire.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "state.json"
state = BlogRSSState(state_file, persistence_enabled=False)
state.add_guids(["guid-1"])
state.update_cache_headers("etag-123", "Wed, 01 Sep 2026 GMT")
assert state.get_known_guids() == frozenset({"guid-1"})
assert state.get_cache_headers() == ("etag-123", "Wed, 01 Sep 2026 GMT")
assert not state_file.exists()
def test_disabled_persistence_preserves_existing_file(tmp_path: Path) -> None:
"""Vérifie que la persistance désactivée ne modifie pas l'état déjà stocké.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "state.json"
original_content = json.dumps(
{
"version": 1,
"known_guids": ["existing-guid"],
"etag": "old-etag",
"last_modified": "Tue, 31 Aug 2026 GMT",
}
)
state_file.write_text(original_content, encoding="utf-8")
state = BlogRSSState(state_file, persistence_enabled=False)
state.add_guids(["new-guid"])
state.update_cache_headers("new-etag", "Wed, 01 Sep 2026 GMT")
assert state.get_known_guids() == frozenset({"existing-guid", "new-guid"})
assert state.get_cache_headers() == ("new-etag", "Wed, 01 Sep 2026 GMT")
assert state_file.read_text(encoding="utf-8") == original_content
def test_add_guids_persists(tmp_path: Path) -> None:
"""Vérifie que l'ajout de GUID persiste dans le fichier JSON.

View File

@@ -17,10 +17,12 @@ from __future__ import annotations
import json
import logging
import os
from fcntl import LOCK_EX, LOCK_NB, LOCK_UN, flock
from pathlib import Path
import pytest
from pronote_sync.errors import PronoteAuthStateLockError
from pronote_sync.sources.pronote.auth_state import PronoteAuthState
@@ -35,6 +37,90 @@ def test_load_no_file_returns_none(tmp_path: Path) -> None:
assert state.load() is None
def test_disabled_persistence_keeps_credentials_in_memory_without_creating_file(
tmp_path: Path,
) -> None:
"""Vérifie que la persistance désactivée conserve les credentials en mémoire.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "auth.json"
credentials = {
"pronote_url": "https://example.com/pronote",
"username": "parent-1",
"password": "token-123", # pragma: allowlist secret
"uuid": "uuid-456",
}
state = PronoteAuthState(state_file, persistence_enabled=False)
state.save(credentials)
assert state.load() == credentials
assert not state_file.exists()
def test_disabled_persistence_preserves_existing_file(tmp_path: Path) -> None:
"""Vérifie que la persistance désactivée garde les nouveaux credentials en mémoire.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "auth.json"
original_credentials = {
"pronote_url": "https://example.com/pronote",
"username": "parent-1",
"password": "old-token", # pragma: allowlist secret
"uuid": "old-uuid",
}
original_content = json.dumps({"version": 1, "credentials": original_credentials}).encode()
new_credentials = {
"pronote_url": "https://example.com/pronote",
"username": "parent-1",
"password": "new-token", # pragma: allowlist secret
"uuid": "new-uuid",
}
state_file.write_bytes(original_content)
state = PronoteAuthState(state_file, persistence_enabled=False)
assert state.load() == original_credentials
state.save(new_credentials)
assert state.load() == new_credentials
assert state_file.read_bytes() == original_content
def test_disabled_persistence_clear_discards_in_memory_credentials_only(tmp_path: Path) -> None:
"""Vérifie que clear oublie l'état en mémoire sans modifier le fichier existant.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "auth.json"
original_credentials = {
"pronote_url": "https://example.com/pronote",
"username": "parent-1",
"password": "old-token", # pragma: allowlist secret
"uuid": "old-uuid",
}
original_content = json.dumps({"version": 1, "credentials": original_credentials}).encode()
credentials = {
"pronote_url": "https://example.com/pronote",
"username": "parent-1",
"password": "new-token", # pragma: allowlist secret
"uuid": "new-uuid",
}
state_file.write_bytes(original_content)
state = PronoteAuthState(state_file, persistence_enabled=False)
state.save(credentials)
state.clear()
assert state.load() is None
assert state_file.read_bytes() == original_content
def test_save_then_load_roundtrip(tmp_path: Path) -> None:
"""Vérifie que des credentials sauvegardés sont rechargés à l'identique.
@@ -206,3 +292,76 @@ def test_no_credentials_in_logs(tmp_path: Path, caplog: pytest.LogCaptureFixture
assert "SENTINEL_USER_ZZZ" not in caplog.text
assert "SENTINEL_PASSWORD_ZZZ" not in caplog.text
assert "SENTINEL_UUID_ZZZ" not in caplog.text
def test_lock_rejects_concurrent_access_with_a_redacted_dedicated_error(tmp_path: Path) -> None:
"""Vérifie qu'un verrou concurrent échoue immédiatement sans fuite interne.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / ".pronote_auth_state.json"
state = PronoteAuthState(state_file)
competing_state = PronoteAuthState(state_file)
with state.lock():
assert state_file.with_name(f"{state_file.name}.lock").exists()
with pytest.raises(PronoteAuthStateLockError) as exc_info:
with competing_state.lock():
pass
assert "BlockingIOError" not in str(exc_info.value)
assert exc_info.value.__cause__ is None
assert exc_info.value.__context__ is None
def test_lock_open_failure_does_not_log_sensitive_lock_path(
tmp_path: Path, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Vérifie qu'un échec d'ouverture du verrou ne divulgue pas son chemin.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:param caplog: Fixture pytest pour capturer les logs.
:param monkeypatch: Fixture pytest pour remplacer l'ouverture du verrou.
:return: None
"""
sentinel_path = "/SENTINEL_LOCK_PATH_ZZZ/.pronote_auth_state.json.lock"
def raise_lock_open_error(*args: object, **kwargs: object) -> int:
"""Simule un refus d'ouverture portant un chemin sensible."""
del args, kwargs
raise OSError(13, "Permission denied", sentinel_path)
monkeypatch.setattr(os, "open", raise_lock_open_error)
with caplog.at_level(logging.ERROR):
with pytest.raises(PronoteAuthStateLockError) as exc_info:
with PronoteAuthState(tmp_path / ".pronote_auth_state.json").lock():
pass
assert "Impossible d'ouvrir le verrou d'état d'authentification Pronote" in caplog.text
assert "SENTINEL_LOCK_PATH_ZZZ" not in caplog.text
assert exc_info.value.__cause__ is None
assert exc_info.value.__context__ is None
def test_lock_is_released_when_the_protected_operation_raises(tmp_path: Path) -> None:
"""Vérifie que le verrou est libéré même si le bloc protégé échoue.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / ".pronote_auth_state.json"
lock_file = state_file.with_name(f"{state_file.name}.lock")
state = PronoteAuthState(state_file)
with pytest.raises(RuntimeError, match="échec simulé"):
with state.lock():
raise RuntimeError("échec simulé")
descriptor = os.open(lock_file, os.O_RDWR)
try:
flock(descriptor, LOCK_EX | LOCK_NB)
flock(descriptor, LOCK_UN)
finally:
os.close(descriptor)

View File

@@ -9,6 +9,8 @@ from __future__ import annotations
import json
import logging
from collections.abc import Generator
from contextlib import contextmanager
from datetime import date, datetime
from pathlib import Path
from unittest.mock import MagicMock
@@ -1203,6 +1205,91 @@ def test_no_raw_secrets_in_logs(
# --- Persistence of credentials after data operations ---
def test_qr_token_lock_covers_login_retrieval_and_credential_persistence(
mocker: pytest_mock.MockerFixture,
) -> None:
"""Vérifie que le verrou d'état couvre tout le cycle QR/token des cours.
:param mocker: Fixture pytest-mock pour le mocking.
:return: None
"""
events: list[str] = []
credentials = {
"pronote_url": "https://pronote.example.com",
"username": "testuser",
"password": "persisted-token", # pragma: allowlist secret
"uuid": "persisted-uuid",
}
auth_state = mocker.MagicMock(spec=PronoteAuthState)
@contextmanager
def record_lock() -> Generator[None]:
events.append("lock_acquired")
try:
yield
finally:
events.append("lock_released")
def load_credentials() -> dict[str, str]:
"""Simule le chargement des credentials persistés."""
events.append("load")
return credentials
def get_no_lessons(*_: object) -> list[object]:
"""Simule une récupération de cours vide."""
events.append("lessons")
return []
def export_credentials() -> dict[str, str]:
"""Simule l'export des credentials courantes."""
events.append("export")
return credentials
def token_login(**_: object) -> MagicMock:
"""Simule le login par token."""
events.append("token_login")
return mock_client
auth_state.lock.side_effect = record_lock
auth_state.load.side_effect = load_credentials
auth_state.save.side_effect = lambda _: events.append("save")
mock_client = _make_lessons_mock_client(mocker)
mock_client.logged_in = True
mock_client.lessons.side_effect = get_no_lessons
mock_client.export_credentials.side_effect = export_credentials
mocker.patch(
"pronotepy.ParentClient.token_login",
side_effect=token_login,
)
settings = PronoteSettings(
url="https://pronote.example.com",
username="testuser",
password=SecretStr("testpass"),
ent=None,
account_type="parent",
auth_mode="qr_token",
)
lessons = PronoteClient(settings, auth_state=auth_state).get_lessons(
date(2024, 9, 1), date(2024, 9, 30)
)
assert lessons == []
assert events == [
"lock_acquired",
"load",
"token_login",
"export",
"save",
"lessons",
"export",
"save",
"lock_released",
]
def _make_auth_state_mock(mocker: pytest_mock.MockerFixture) -> MagicMock:
"""Retourne un mock de PronoteAuthState sans credentials persistés.