From d26cef8d3da4a8cefcbc120fc8061e92a47b0ff0 Mon Sep 17 00:00:00 2001 From: Antoine Van Elstraete Date: Thu, 10 Sep 2026 12:10:42 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20persister=20le=20token=20apr=C3=A8s=20ch?= =?UTF-8?q?aque=20op=C3=A9ration=20de=20donn=C3=A9es=20+=20corriger=20doc?= =?UTF-8?q?=20QR=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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%) --- .env.example | 16 +- AGENTS.md | 9 +- pronote_sync/sources/pronote/client.py | 29 +++- tests/unit/test_pronote_client.py | 200 ++++++++++++++++++++++++- 4 files changed, 240 insertions(+), 14 deletions(-) diff --git a/.env.example b/.env.example index 6c35742..0b6ed02 100644 --- a/.env.example +++ b/.env.example @@ -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 --- diff --git a/AGENTS.md b/AGENTS.md index be8ff2a..e994041 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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'à diff --git a/pronote_sync/sources/pronote/client.py b/pronote_sync/sources/pronote/client.py index 1c2d024..86719c1 100644 --- a/pronote_sync/sources/pronote/client.py +++ b/pronote_sync/sources/pronote/client.py @@ -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 diff --git a/tests/unit/test_pronote_client.py b/tests/unit/test_pronote_client.py index ad4185d..f5d5d1d 100644 --- a/tests/unit/test_pronote_client.py +++ b/tests/unit/test_pronote_client.py @@ -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