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