fix: serialize QR token client operations
This commit is contained in:
@@ -12,6 +12,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from collections.abc import Generator
|
||||||
|
from contextlib import contextmanager
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Protocol
|
from typing import Any, Protocol
|
||||||
@@ -252,6 +254,26 @@ class PronoteClient:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("Échec de la persistance des credentials : %s", redact_exception(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:
|
def _connect_password(self) -> pronotepy.Client:
|
||||||
"""Connecte le client ``pronotepy`` en mode ``password``.
|
"""Connecte le client ``pronotepy`` en mode ``password``.
|
||||||
|
|
||||||
@@ -426,6 +448,7 @@ class PronoteClient:
|
|||||||
:return: Liste des messages des professeurs ; vide en cas d'erreur.
|
:return: Liste des messages des professeurs ; vide en cas d'erreur.
|
||||||
:rtype: list[Message]
|
:rtype: list[Message]
|
||||||
"""
|
"""
|
||||||
|
with self._qr_token_operation_lock():
|
||||||
try:
|
try:
|
||||||
client = self._connect()
|
client = self._connect()
|
||||||
messages: list[Message] = []
|
messages: list[Message] = []
|
||||||
@@ -467,6 +490,7 @@ class PronoteClient:
|
|||||||
:return: Liste des informations et sondages ; vide en cas d'erreur.
|
:return: Liste des informations et sondages ; vide en cas d'erreur.
|
||||||
:rtype: list[Message]
|
:rtype: list[Message]
|
||||||
"""
|
"""
|
||||||
|
with self._qr_token_operation_lock():
|
||||||
try:
|
try:
|
||||||
client = self._connect()
|
client = self._connect()
|
||||||
messages: list[Message] = []
|
messages: list[Message] = []
|
||||||
@@ -523,6 +547,7 @@ class PronoteClient:
|
|||||||
:raises ConnectionError: Si la connexion réseau échoue.
|
:raises ConnectionError: Si la connexion réseau échoue.
|
||||||
:raises TimeoutError: Si la requête réseau expire.
|
:raises TimeoutError: Si la requête réseau expire.
|
||||||
"""
|
"""
|
||||||
|
with self._qr_token_operation_lock():
|
||||||
client = self._connect()
|
client = self._connect()
|
||||||
lessons: list[Lesson] = []
|
lessons: list[Lesson] = []
|
||||||
for lesson in client.lessons(start, end):
|
for lesson in client.lessons(start, end):
|
||||||
@@ -576,6 +601,7 @@ class PronoteClient:
|
|||||||
:raises ConnectionError: Si la connexion réseau échoue.
|
:raises ConnectionError: Si la connexion réseau échoue.
|
||||||
:raises TimeoutError: Si la requête réseau expire.
|
:raises TimeoutError: Si la requête réseau expire.
|
||||||
"""
|
"""
|
||||||
|
with self._qr_token_operation_lock():
|
||||||
client = self._connect()
|
client = self._connect()
|
||||||
homeworks: list[Homework] = []
|
homeworks: list[Homework] = []
|
||||||
for hw in client.homework(start, end):
|
for hw in client.homework(start, end):
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from collections.abc import Generator
|
||||||
|
from contextlib import contextmanager
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
@@ -1203,6 +1205,91 @@ def test_no_raw_secrets_in_logs(
|
|||||||
# --- Persistence of credentials after data operations ---
|
# --- 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:
|
def _make_auth_state_mock(mocker: pytest_mock.MockerFixture) -> MagicMock:
|
||||||
"""Retourne un mock de PronoteAuthState sans credentials persistés.
|
"""Retourne un mock de PronoteAuthState sans credentials persistés.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user