From 4df930bfe6d35df8c6547a7aad92c2e570c29ff6 Mon Sep 17 00:00:00 2001 From: Antoine Van Elstraete Date: Thu, 10 Sep 2026 13:34:51 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20persister=20le=20token=20m=C3=AAme=20dan?= =?UTF-8?q?s=20le=20chemin=20d'erreur=20de=20get=5Finformations/get=5Fmess?= =?UTF-8?q?ages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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%. --- pronote_sync/sources/pronote/client.py | 2 + tests/unit/test_pronote_client.py | 93 ++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/pronote_sync/sources/pronote/client.py b/pronote_sync/sources/pronote/client.py index 86719c1..57051c3 100644 --- a/pronote_sync/sources/pronote/client.py +++ b/pronote_sync/sources/pronote/client.py @@ -455,6 +455,7 @@ class PronoteClient: "Échec de la récupération des messages Pronote : %s", redact_exception(exc), ) + self._persist_credentials() return [] def get_informations(self) -> list[Message]: @@ -494,6 +495,7 @@ class PronoteClient: "Échec de la récupération des informations Pronote : %s", redact_exception(exc), ) + self._persist_credentials() return [] def get_lessons(self, start: date, end: date) -> list[Lesson]: diff --git a/tests/unit/test_pronote_client.py b/tests/unit/test_pronote_client.py index f5d5d1d..838ef2b 100644 --- a/tests/unit/test_pronote_client.py +++ b/tests/unit/test_pronote_client.py @@ -1397,4 +1397,97 @@ def test_persist_credentials_failure_does_not_crash( 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