fix: persister le token même dans le chemin d'erreur de get_informations/get_messages

Cause racine : quand pronotepy reçoit une PronoteAPIError (ex. code 20 « page
expirée »), il appelle refresh() en interne, qui peut roter le token en mémoire
(client.password mis à jour avec un nouveau jetonConnexionAppliMobile). Le retry
peut aussi échouer — l'exception atteint get_informations()/get_messages() qui
l'attrapent et retournent [] (mode dégradé). Mais _persist_credentials()
n'était appelé que dans le chemin de SUCCÈS — le token rafraîchi en mémoire
n'était jamais persisté. Au run suivant, token_login utilisait le token
périmé → échec KeyError 'dataSec' → PronoteAuthRotationError.

Correction : appeler _persist_credentials() aussi dans le chemin d'erreur de
get_informations() et get_messages(), avant le return []. L'ancien token est
déjà invalidé côté serveur lors du refresh — ne pas persister le nouveau token
garantit la perte du seul token valide.

Tests : 3 nouveaux tests (persistance dans le chemin d'erreur, sauvegarde du
token rafraîchi sur erreur). 694 passés, couverture 94.93%.
This commit is contained in:
2026-09-10 13:34:51 +02:00
parent d26cef8d3d
commit 4df930bfe6
2 changed files with 95 additions and 0 deletions

View File

@@ -455,6 +455,7 @@ class PronoteClient:
"Échec de la récupération des messages Pronote : %s", "Échec de la récupération des messages Pronote : %s",
redact_exception(exc), redact_exception(exc),
) )
self._persist_credentials()
return [] return []
def get_informations(self) -> list[Message]: def get_informations(self) -> list[Message]:
@@ -494,6 +495,7 @@ class PronoteClient:
"Échec de la récupération des informations Pronote : %s", "Échec de la récupération des informations Pronote : %s",
redact_exception(exc), redact_exception(exc),
) )
self._persist_credentials()
return [] return []
def get_lessons(self, start: date, end: date) -> list[Lesson]: def get_lessons(self, start: date, end: date) -> list[Lesson]:

View File

@@ -1397,4 +1397,97 @@ def test_persist_credentials_failure_does_not_crash(
assert "persistance des credentials" in caplog.text assert "persistance des credentials" in caplog.text
def test_persist_credentials_in_error_path_get_informations(
mocker: pytest_mock.MockerFixture,
pronote_settings: PronoteSettings,
) -> None:
"""Vérifie que get_informations persiste les credentials même en mode dégradé.
Le refresh pronotepy peut avoir roté le token en mémoire alors que la
requête échoue ; la persistance doit intervenir aussi dans le chemin
d'erreur, avant le ``return []``.
:param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides.
:return: None
"""
mock_client = mocker.MagicMock()
mock_client.information_and_surveys.side_effect = pronotepy.PronoteAPIError("API error")
mock_client.export_credentials.return_value = {
"password": "token-apres-refresh", # pragma: allowlist secret
}
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
messages = client.get_informations()
assert messages == []
auth_state.save.assert_called_once_with(
{"password": "token-apres-refresh"} # pragma: allowlist secret
)
def test_persist_credentials_in_error_path_get_messages(
mocker: pytest_mock.MockerFixture,
pronote_settings: PronoteSettings,
) -> None:
"""Vérifie que get_messages persiste les credentials même en mode dégradé.
:param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides.
:return: None
"""
mock_client = mocker.MagicMock()
mock_client.discussions.side_effect = pronotepy.PronoteAPIError("API error")
mock_client.export_credentials.return_value = {
"password": "token-apres-refresh", # pragma: allowlist secret
}
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
messages = client.get_messages()
assert messages == []
auth_state.save.assert_called_once_with(
{"password": "token-apres-refresh"} # pragma: allowlist secret
)
def test_persist_credentials_saves_refreshed_token_on_error(
mocker: pytest_mock.MockerFixture,
pronote_settings: PronoteSettings,
) -> None:
"""Vérifie que le token roté par le refresh pronotepy est persisté en cas d'erreur.
``export_credentials()`` retourne un mot de passe (token) différent du
token initial, simulant la rotation opérée par ``refresh()`` : c'est ce
nouveau credential qui doit être passé à ``save``.
:param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides.
:return: None
"""
initial_creds = {"password": "token-initial"} # pragma: allowlist secret
refreshed_creds = {"password": "token-rafraichi"} # pragma: allowlist secret
mock_client = mocker.MagicMock()
mock_client.information_and_surveys.side_effect = pronotepy.PronoteAPIError("API error")
mock_client.export_credentials.return_value = refreshed_creds
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
messages = client.get_informations()
assert messages == []
assert auth_state.save.call_count == 1
saved = auth_state.save.call_args.args[0]
assert saved == refreshed_creds
assert saved != initial_creds
# Ensure trailing newline # Ensure trailing newline