fix: persister le token après chaque opération de données + corriger doc QR code

Cause racine : pronotepy peut rafraîchir (rotater) le token en mémoire pendant
l'exécution via refresh() automatique après une PronoteAPIError. L'ancien code
ne persistait les credentials qu'après le login initial, pas après les
opérations de données. Le token roté en mémoire était perdu → au run suivant,
token_login échouait avec le token périmé (KeyError 'dataSec').

Correction :
- PronoteClient._persist_credentials() : méthode centralisée qui persiste
  export_credentials() après chaque opération réussie (get_lessons,
  get_homeworks, get_messages, get_informations)
- Le token rafraîchi par le serveur pendant l'exécution est maintenant
  toujours persisté, même si le pipeline échoue ensuite

Documentation :
- .env.example : variables QR plus visibles (exemple qr_token décommentable)
- AGENTS.md : QR code depuis le site web Pronote (pas l'app mobile),
  persistance après chaque opération de données
- Wiki GuidePronote : procédure corrigée (site web, pas app Android/iOS),
  mention de la persistance après chaque opération

Tests : 5 nouveaux tests de persistance (691 passés, couverture 94.92%)
This commit is contained in:
2026-09-10 12:10:42 +02:00
parent 7dc48f6f43
commit d26cef8d3d
4 changed files with 240 additions and 14 deletions

View File

@@ -11,17 +11,17 @@ PRONOTE_AGENDA_SOURCE=auto
PRONOTE_HOMEWORK_SOURCE=auto
PRONOTE_MESSAGES_SOURCE=pronotepy
# Mode d'authentification Pronote
# "password" (défaut) : authentification classique URL + identifiant + mot de passe
# "qr_token" : authentification par QR code puis token persistant
# --- Authentification Pronote ---
# Mode d'authentification : "password" (défaut) ou "qr_token"
# qr_token : pour les instances utilisant HubEduConnect/EduConnect où le mot de passe échoue
# Voir le wiki GuidePronote pour la procédure d'enrôlement QR code
PRONOTE_AUTH_MODE=password
# Fichier JSON du QR code Pronote (enrôlement initial, mode qr_token uniquement)
# À générer depuis l'application mobile Pronote. Le QR code expire ~10 minutes.
# --- Mode qr_token (décommenter et renseigner pour l'authentification par QR code) ---
# Le QR code se génère sur le site web Pronote (espace parent > paramètres > QR code)
# Le QR code expire ~10 minutes après génération
# PRONOTE_AUTH_MODE=qr_token
# PRONOTE_QR_CODE_FILE=/path/to/qr_code.json
# PIN à 4 chiffres pour l'enrôlement QR code (mode qr_token uniquement)
# SENSIBLE : ne jamais committer cette valeur
# PRONOTE_QR_PIN=1234
# --- CalDAV ---

View File

@@ -153,10 +153,15 @@ pronote-sync --dry-run
- `qr_token` : authentification par QR code puis token persistant (pour les instances Pronote
utilisant HubEduConnect/EduConnect où l'authentification par mot de passe échoue).
- En mode `qr_token`, le premier login utilise `pronotepy.qrcode_login(qr_code, pin, uuid)` avec
les paramètres `PRONOTE_QR_CODE_FILE` (chemin du JSON QR) et `PRONOTE_QR_PIN` (PIN SecretStr).
les paramètres `PRONOTE_QR_CODE_FILE` (chemin du JSON QR) et `PRONOTE_QR_PIN` (PIN SecretStr). Le
QR code est obtenu depuis le site web Pronote (espace parent → paramètres → QR code), pas depuis
l'application mobile.
- Après chaque login réussi, les credentials exportées par `pronotepy.export_credentials()` sont
persistées dans `.pronote_auth_state.json` (permissions `0600`, format JSON versionné, écriture
atomique). Le token rotate à chaque session — le fichier doit être mis à jour après chaque run.
atomique). Le token rotate à chaque session et peut également être rafraîchi pendant l'exécution
(refresh automatique pronotepy après une `PronoteAPIError`). Les credentials sont persistées après
chaque login réussi **et après chaque opération de données réussie** (agenda, devoirs, messages,
informations) pour garantir la persistance du token valide.
- Les logins suivants utilisent `pronotepy.token_login(**credentials)` avec le token persisté.
- En cas d'échec de `token_login` (token expiré/invalide), une `PronoteAuthRotationError` est levée.
Cette erreur se propage sans wrapping à travers `PronoteFetcher` et `fetch_step` jusqu'à

View File

@@ -234,6 +234,24 @@ class PronoteClient:
self._client = self._connect_password()
return self._client
def _persist_credentials(self) -> None:
"""Persiste les credentials d'authentification après une opération réussie.
Le token pronotepy peut être rafraîchi (rotaté) par le serveur lors d'un
appel de données (agenda, devoirs, messages). Cette méthode persiste
systématiquement les credentials courantes pour garantir la disponibilité
du token valide au prochain run.
Ne fait rien si aucun :class:`PronoteAuthState` n'est configuré (mode
``password``) ou si le client n'est pas connecté.
"""
if self._auth_state is None or self._client is None:
return
try:
self._auth_state.save(self._client.export_credentials())
except Exception as exc:
logger.debug("Échec de la persistance des credentials : %s", redact_exception(exc))
def _connect_password(self) -> pronotepy.Client:
"""Connecte le client ``pronotepy`` en mode ``password``.
@@ -298,7 +316,8 @@ class PronoteClient:
try:
client = client_class.token_login(**creds)
if client.logged_in:
self._auth_state.save(client.export_credentials())
self._client = client
self._persist_credentials()
return client
# logged_in est False — le token est invalide
raise PronoteAuthRotationError(
@@ -324,8 +343,8 @@ class PronoteClient:
# Enrôlement : premier login via QR code (aucun credential persisté)
client = self._enroll_qr_code(client_class)
# Persister le token rotaté immédiatement
if self._auth_state is not None:
self._auth_state.save(client.export_credentials())
self._client = client
self._persist_credentials()
return client
def _enroll_qr_code(self, client_class: type[pronotepy.Client]) -> pronotepy.Client:
@@ -423,6 +442,7 @@ class PronoteClient:
read=message.seen,
)
)
self._persist_credentials()
return messages
except (
pronotepy.PronoteAPIError,
@@ -461,6 +481,7 @@ class PronoteClient:
read=info.read,
)
)
self._persist_credentials()
return messages
except (
pronotepy.PronoteAPIError,
@@ -529,6 +550,7 @@ class PronoteClient:
content=content.description if content is not None else None,
)
)
self._persist_credentials()
return lessons
def get_homeworks(self, start: date, end: date) -> list[Homework]:
@@ -566,4 +588,5 @@ class PronoteClient:
html=hw.description,
)
)
self._persist_credentials()
return homeworks

View File

@@ -11,6 +11,7 @@ import json
import logging
from datetime import date, datetime
from pathlib import Path
from unittest.mock import MagicMock
import pronotepy
import pytest
@@ -1162,7 +1163,7 @@ def test_no_raw_secrets_in_logs(
auth_state.load.return_value = {
"pronote_url": "https://pronote.example.com",
"username": "testuser",
"password": sentinel_token,
"password": sentinel_token, # pragma: allowlist secret
"uuid": "old-uuid",
}
@@ -1199,4 +1200,201 @@ def test_no_raw_secrets_in_logs(
assert sentinel_jeton not in caplog.text
# --- Persistence of credentials after data operations ---
def _make_auth_state_mock(mocker: pytest_mock.MockerFixture) -> MagicMock:
"""Retourne un mock de PronoteAuthState sans credentials persistés.
:param mocker: Fixture pytest-mock pour le mocking.
:return: Mock de PronoteAuthState (load → None).
"""
auth_state = mocker.MagicMock(spec=PronoteAuthState)
auth_state.load.return_value = None
return auth_state # type: ignore[no-any-return]
def _make_lessons_mock_client(mocker: pytest_mock.MockerFixture) -> MagicMock:
"""Retourne un mock client pronotepy retournant un cours exploitable.
:param mocker: Fixture pytest-mock pour le mocking.
:return: Mock client avec une leçon mockée.
"""
mock_client = mocker.MagicMock()
mock_lesson = mocker.MagicMock()
mock_lesson.id = "lesson-789"
mock_lesson.start = datetime(2024, 9, 1, 8, 0, 0)
mock_lesson.end = datetime(2024, 9, 1, 9, 30, 0)
mock_lesson.subject = mocker.MagicMock()
mock_lesson.subject.name = "Maths"
mock_lesson.teacher_names = ["Prof A"]
mock_lesson.classrooms = ["Salle 101"]
mock_lesson.group_name = "Classe 1"
mock_lesson.canceled = False
mock_content = mocker.MagicMock()
mock_content.description = "Lesson content"
mock_lesson.content = mock_content
mock_client.lessons.return_value = [mock_lesson]
return mock_client # type: ignore[no-any-return]
def test_persist_credentials_after_get_lessons(
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None:
"""Vérifie que get_lessons persiste les credentials (token rotaté) après succès.
``export_credentials()`` retourne des valeurs différentes à chaque appel :
la dernière valeur (token rotaté) doit être celle passée à ``save``.
:param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides.
:return: None
"""
mock_client = _make_lessons_mock_client(mocker)
creds_sequence = iter(
[
{"password": "token-1"}, # pragma: allowlist secret
{"password": "token-2"}, # pragma: allowlist secret
]
)
mock_client.export_credentials.side_effect = lambda: next(creds_sequence)
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
auth_state = _make_auth_state_mock(mocker)
client = PronoteClient(pronote_settings, auth_state=auth_state)
client._client = mock_client
lessons = client.get_lessons(date(2024, 9, 1), date(2024, 9, 30))
assert len(lessons) == 1
# Deuxième opération sur le même client : export_credentials() retourne
# alors le token rotaté, qui doit être celui persisté.
client.get_lessons(date(2024, 9, 1), date(2024, 9, 30))
assert auth_state.save.call_count == 2
auth_state.save.assert_called_with({"password": "token-2"}) # pragma: allowlist secret
def test_persist_credentials_after_get_homeworks(
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None:
"""Vérifie que get_homeworks persiste les credentials (token rotaté) après succès.
:param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides.
:return: None
"""
mock_client = mocker.MagicMock()
mock_hw = mocker.MagicMock()
mock_hw.id = "hw-101"
mock_hw.subject = mocker.MagicMock()
mock_hw.subject.name = "Maths"
mock_hw.date = date(2024, 9, 15)
mock_hw.description = "Do your homework"
mock_client.homework.return_value = [mock_hw]
creds_sequence = iter(
[
{"password": "token-1"}, # pragma: allowlist secret
{"password": "token-2"}, # pragma: allowlist secret
]
)
mock_client.export_credentials.side_effect = lambda: next(creds_sequence)
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
auth_state = _make_auth_state_mock(mocker)
client = PronoteClient(pronote_settings, auth_state=auth_state)
client._client = mock_client
homeworks = client.get_homeworks(date(2024, 9, 1), date(2024, 9, 30))
assert len(homeworks) == 1
# Deuxième opération sur le même client : export_credentials() retourne
# alors le token rotaté, qui doit être celui persisté.
client.get_homeworks(date(2024, 9, 1), date(2024, 9, 30))
assert auth_state.save.call_count == 2
auth_state.save.assert_called_with({"password": "token-2"}) # pragma: allowlist secret
def test_persist_credentials_not_called_in_password_mode(
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None:
"""Vérifie qu'aucune persistance n'est tentée sans PronoteAuthState (mode password).
:param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides.
:return: None
"""
mock_client = _make_lessons_mock_client(mocker)
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
client = PronoteClient(pronote_settings, auth_state=None)
client._client = mock_client
lessons = client.get_lessons(date(2024, 9, 1), date(2024, 9, 30))
assert len(lessons) == 1 # Aucune exception levée malgré auth_state=None
def test_persist_credentials_called_after_login(
mocker: pytest_mock.MockerFixture,
) -> None:
"""Vérifie que _connect_qr_token persiste toujours les credentials après login.
Comportement historique préservé via la nouvelle méthode
``_persist_credentials()``.
:param mocker: Fixture pytest-mock pour le mocking.
:return: None
"""
creds = {
"pronote_url": "https://pronote.example.com",
"username": "testuser",
"password": "persisted-token", # pragma: allowlist secret
"uuid": "persisted-uuid",
}
auth_state = _make_auth_state_mock(mocker)
auth_state.load.return_value = creds
mock_client = mocker.MagicMock()
mock_client.logged_in = True
mock_client.export_credentials.return_value = {**creds, "uuid": "rotated-uuid"}
mocker.patch("pronotepy.ParentClient.token_login", return_value=mock_client)
settings = PronoteSettings(
url="https://pronote.example.com",
username="testuser",
password=SecretStr("testpass"),
ent=None,
account_type="parent",
auth_mode="qr_token",
)
client = PronoteClient(settings, auth_state=auth_state)
client._connect()
auth_state.save.assert_called_once_with({**creds, "uuid": "rotated-uuid"})
def test_persist_credentials_failure_does_not_crash(
mocker: pytest_mock.MockerFixture,
pronote_settings: PronoteSettings,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Vérifie qu'un échec de persistance n'interrompt pas la récupération des cours.
:param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides.
:param caplog: Fixture pytest de capture des logs.
:return: None
"""
mock_client = _make_lessons_mock_client(mocker)
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
auth_state = _make_auth_state_mock(mocker)
auth_state.save.side_effect = OSError("disque plein")
client = PronoteClient(pronote_settings, auth_state=auth_state)
client._client = mock_client
with caplog.at_level(logging.DEBUG, logger="pronote_sync.sources.pronote.client"):
lessons = client.get_lessons(date(2024, 9, 1), date(2024, 9, 30))
assert len(lessons) == 1
assert "persistance des credentials" in caplog.text
# Ensure trailing newline