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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user