diff --git a/pronote_sync/sources/pronote/client.py b/pronote_sync/sources/pronote/client.py index 57051c3..7f5c2ab 100644 --- a/pronote_sync/sources/pronote/client.py +++ b/pronote_sync/sources/pronote/client.py @@ -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,37 +448,38 @@ class PronoteClient: :return: Liste des messages des professeurs ; vide en cas d'erreur. :rtype: list[Message] """ - try: - client = self._connect() - messages: list[Message] = [] - for discussion in client.discussions(): - for message in discussion.messages: - messages.append( - Message( - id=message.id, - type=MessageType.DISCUSSION, - title=discussion.subject, - content=message.content, - author=message.author or "", - date=message.created, - read=message.seen, + with self._qr_token_operation_lock(): + try: + client = self._connect() + messages: list[Message] = [] + for discussion in client.discussions(): + for message in discussion.messages: + messages.append( + Message( + id=message.id, + type=MessageType.DISCUSSION, + title=discussion.subject, + content=message.content, + author=message.author or "", + date=message.created, + read=message.seen, + ) ) - ) - self._persist_credentials() - return messages - except ( - pronotepy.PronoteAPIError, - ValueError, - requests.RequestException, - ConnectionError, - TimeoutError, - ) as exc: - logger.error( - "Échec de la récupération des messages Pronote : %s", - redact_exception(exc), - ) - self._persist_credentials() - return [] + self._persist_credentials() + return messages + except ( + pronotepy.PronoteAPIError, + ValueError, + requests.RequestException, + ConnectionError, + TimeoutError, + ) as exc: + logger.error( + "Échec de la récupération des messages Pronote : %s", + redact_exception(exc), + ) + self._persist_credentials() + return [] def get_informations(self) -> list[Message]: """Récupère les informations et sondages Pronote. @@ -467,36 +490,37 @@ class PronoteClient: :return: Liste des informations et sondages ; vide en cas d'erreur. :rtype: list[Message] """ - try: - client = self._connect() - messages: list[Message] = [] - for info in client.information_and_surveys(): - messages.append( - Message( - id=info.id, - type=MessageType.SURVEY if info.survey else MessageType.INFORMATION, - title=info.title or "", - content=info.content(), - author=info.author, - date=info.creation_date, - read=info.read, + with self._qr_token_operation_lock(): + try: + client = self._connect() + messages: list[Message] = [] + for info in client.information_and_surveys(): + messages.append( + Message( + id=info.id, + type=MessageType.SURVEY if info.survey else MessageType.INFORMATION, + title=info.title or "", + content=info.content(), + author=info.author, + date=info.creation_date, + read=info.read, + ) ) + self._persist_credentials() + return messages + except ( + pronotepy.PronoteAPIError, + ValueError, + requests.RequestException, + ConnectionError, + TimeoutError, + ) as exc: + logger.error( + "Échec de la récupération des informations Pronote : %s", + redact_exception(exc), ) - self._persist_credentials() - return messages - except ( - pronotepy.PronoteAPIError, - ValueError, - requests.RequestException, - ConnectionError, - TimeoutError, - ) as exc: - logger.error( - "Échec de la récupération des informations Pronote : %s", - redact_exception(exc), - ) - self._persist_credentials() - return [] + self._persist_credentials() + return [] def get_lessons(self, start: date, end: date) -> list[Lesson]: """Récupère les cours via ``pronotepy`` (repli iCal). @@ -523,37 +547,38 @@ class PronoteClient: :raises ConnectionError: Si la connexion réseau échoue. :raises TimeoutError: Si la requête réseau expire. """ - client = self._connect() - lessons: list[Lesson] = [] - for lesson in client.lessons(start, end): - content = lesson.content - raw_uid = lesson.id - if raw_uid: - uid = normalize_pronote_uid(raw_uid) - else: - uid = generate_deterministic_uid( - start=lesson.start, - end=lesson.end, - subject=lesson.subject.name if lesson.subject is not None else "", - teachers=list(lesson.teacher_names or ()), - rooms=list(lesson.classrooms or ()), - group=lesson.group_name, + with self._qr_token_operation_lock(): + client = self._connect() + lessons: list[Lesson] = [] + for lesson in client.lessons(start, end): + content = lesson.content + raw_uid = lesson.id + if raw_uid: + uid = normalize_pronote_uid(raw_uid) + else: + uid = generate_deterministic_uid( + start=lesson.start, + end=lesson.end, + subject=lesson.subject.name if lesson.subject is not None else "", + teachers=list(lesson.teacher_names or ()), + rooms=list(lesson.classrooms or ()), + group=lesson.group_name, + ) + lessons.append( + Lesson( + id=uid, + start=lesson.start, + end=lesson.end, + subject=lesson.subject.name if lesson.subject is not None else "", + teachers=tuple(lesson.teacher_names or ()), + rooms=tuple(lesson.classrooms or ()), + group=lesson.group_name, + status=(LessonStatus.CANCELLED if lesson.canceled else LessonStatus.NORMAL), + content=content.description if content is not None else None, + ) ) - lessons.append( - Lesson( - id=uid, - start=lesson.start, - end=lesson.end, - subject=lesson.subject.name if lesson.subject is not None else "", - teachers=tuple(lesson.teacher_names or ()), - rooms=tuple(lesson.classrooms or ()), - group=lesson.group_name, - status=(LessonStatus.CANCELLED if lesson.canceled else LessonStatus.NORMAL), - content=content.description if content is not None else None, - ) - ) - self._persist_credentials() - return lessons + self._persist_credentials() + return lessons def get_homeworks(self, start: date, end: date) -> list[Homework]: """Récupère les devoirs via ``pronotepy``. @@ -576,19 +601,20 @@ class PronoteClient: :raises ConnectionError: Si la connexion réseau échoue. :raises TimeoutError: Si la requête réseau expire. """ - client = self._connect() - homeworks: list[Homework] = [] - for hw in client.homework(start, end): - homeworks.append( - Homework( - id=hw.id, - subject=hw.subject.name, - teachers=(), - assigned_on=None, - due_on=hw.date, - text=hw.description, - html=hw.description, + with self._qr_token_operation_lock(): + client = self._connect() + homeworks: list[Homework] = [] + for hw in client.homework(start, end): + homeworks.append( + Homework( + id=hw.id, + subject=hw.subject.name, + teachers=(), + assigned_on=None, + due_on=hw.date, + text=hw.description, + html=hw.description, + ) ) - ) - self._persist_credentials() - return homeworks + self._persist_credentials() + return homeworks diff --git a/tests/unit/test_pronote_client.py b/tests/unit/test_pronote_client.py index 838ef2b..74d2701 100644 --- a/tests/unit/test_pronote_client.py +++ b/tests/unit/test_pronote_client.py @@ -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.