diff --git a/.secrets.baseline b/.secrets.baseline index 0ed8438..e0ab0fc 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -140,7 +140,7 @@ "filename": "GUIDE_DEV_PYTHON.md", "hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa", "is_verified": false, - "line_number": 5125 + "line_number": 5133 } ], "tests/unit/test_caldav_gateway.py": [ @@ -185,5 +185,5 @@ } ] }, - "generated_at": "2026-09-12T12:04:07Z" + "generated_at": "2026-09-12T17:18:46Z" } diff --git a/AGENTS.md b/AGENTS.md index e994041..3b9ada2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -159,9 +159,12 @@ pronote-sync --dry-run - Après chaque login réussi, les credentials exportées par `pronotepy.export_credentials()` sont persistées dans `.pronote_auth_state.json` (permissions `0600`, format JSON versionné, écriture atomique). Le token rotate à chaque session et peut également être rafraîchi pendant l'exécution - (refresh automatique pronotepy après une `PronoteAPIError`). Les credentials sont persistées après - chaque login réussi **et après chaque opération de données réussie** (agenda, devoirs, messages, - informations) pour garantir la persistance du token valide. + (refresh automatique pronotepy après une `PronoteAPIError`). La persistance s'applique après + chaque login réussi **et après chaque opération de données réussie** (agenda, devoirs, messages) + pour garantir la persistance du token valide ; seules les opérations qui se connectent réellement + et récupèrent des données déclenchent la persistance. En mode `qr_token`, `get_informations()` + est ignorée (retour immédiat `[]` sans connexion ni verrou) et ne déclenche donc aucune + persistance. - Les logins suivants utilisent `pronotepy.token_login(**credentials)` avec le token persisté. - En cas d'échec de `token_login` (token expiré/invalide), une `PronoteAuthRotationError` est levée. Cette erreur se propage sans wrapping à travers `PronoteFetcher` et `fetch_step` jusqu'à @@ -172,6 +175,11 @@ pronote-sync --dry-run - `PronoteAuthRotationError` est re-levée telle quelle (`except PronoteAuthRotationError: raise`) dans toutes les couches d'enveloppement du chemin critique (fetch_agenda, fetch_homework, fetch_step). Ne pas l'attraper avec `except Exception` sans la re-léver d'abord. +- En mode `qr_token`, `get_informations()` retourne **inconditionnellement** une liste vide + (`[]`) sans connexion, verrou, chargement d'état ni appel réseau, et journalise un message + INFO unique : l'endpoint `PageActualites` renvoie une erreur pronotepy 20 sur les instances + HubEduConnect testées, provoquant un refresh redondant du token. Ce contournement n'est pas + configurable ; aucun état anti-répétition n'est conservé. - Le fichier `.pronote_auth_state.json` ne doit jamais être committé (couvert par `.gitignore`). Son contenu (token vivant) ne doit jamais apparaître dans les logs, les messages d'erreur ou les notifications XMPP. diff --git a/CHANGELOG.md b/CHANGELOG.md index ff336d3..4e6ddb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,17 +25,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `PRONOTE_URL` ignoré à cause du double préfixe `env_prefix` (renommage `pronote_url` → `url` dans `PronoteSettings`) - `PRONOTE_ENT` rendu optionnel pour les connexions pronotepy directes - `.env.example` corrigé (`eleve.html` → `parent.html`) +- #20/#21 — `get_informations()` ignorée en mode `qr_token` : retourne `[]` immédiatement, sans connexion, verrou ni appel réseau, évitant l'échec systématique de l'endpoint `PageActualites` (erreur pronotepy 20 sur les instances HubEduConnect testées) et le refresh redondant du token associé. ### Changed - Wiki `GuidePronote` enrichi : section "Quand l'ENT est obligatoire" (EduConnect/HubEduConnect), exemple Bordeaux - `AGENTS.md` : ajout de la section §13 "Versionnage et releases" -### Known Issues - -- #20 — Triple authentification pronotepy (double INIT + refresh) lors d'un run -- #21 — Erreur pronotepy 20 « La page a expiré ! (11) » sur `get_informations` - ### Tests - 694 tests passés, couverture 94.93% diff --git a/GUIDE_DEV_PYTHON.md b/GUIDE_DEV_PYTHON.md index 96f2116..c172337 100644 --- a/GUIDE_DEV_PYTHON.md +++ b/GUIDE_DEV_PYTHON.md @@ -2279,6 +2279,12 @@ méthodes agenda/devoirs ne transforment jamais une erreur en liste vide : elles version expurgée puis lèvent une erreur expurgée avec `from None`. Les méthodes de messages et d'informations sont non critiques et peuvent retourner une liste vide avec un warning. +En mode `qr_token`, `get_informations()` est ignorée : elle retourne immédiatement `[]` sans +connexion, verrou ni appel réseau, et journalise un message INFO unique. L'endpoint +`PageActualites` renvoie en effet une erreur pronotepy 20 sur les instances HubEduConnect +testées, provoquant un refresh redondant du token. Ce comportement n'est pas configurable ; +`get_messages()` n'est pas concernée par ce contournement. + Les objets renvoyés par `client.homework(start, end)` couvrent une fenêtre. Le résultat destiné à un jour cible est donc filtré explicitement sur `homework.date == target_date`. @@ -2290,11 +2296,13 @@ d'authentification et de récupération par un verrou POSIX local non bloquant, `.pronote_auth_state.json.lock`, à côté de `.pronote_auth_state.json`. Le verrou couvre l'ensemble du cycle QR/token : chargement de l'état, connexion par token ou -enrôlement QR initial, opération de données (agenda, devoirs, messages ou informations), puis -persistance des credentials actualisées. Une tentative concurrente échoue immédiatement avec une -erreur d'état d'authentification expurgée ; elle ne patiente pas et ne relance pas -l'authentification. Le contenu du token, le PIN et les autres credentials ne sont jamais inclus -dans les logs ni dans ce message d'erreur. +enrôlement QR initial, opération de données (agenda, devoirs, messages ; informations hors mode +`qr_token`), puis persistance des credentials actualisées. En mode `qr_token`, `get_informations()` +est ignorée (retour immédiat `[]` sans connexion ni verrou) : elle n'acquiert pas le verrou et ne +déclenche aucune persistance. Une tentative concurrente échoue immédiatement avec une erreur d'état +d'authentification expurgée ; elle ne patiente pas et ne relance pas l'authentification. Le contenu +du token, le PIN et les autres credentials ne sont jamais inclus dans les logs ni dans ce message +d'erreur. Ce mécanisme est un contrat **local** : il coordonne des processus sur le même hôte Linux et un filesystem local. Pour des déploiements conteneurisés, les conteneurs qui partagent le même compte diff --git a/pronote_sync/sources/pronote/client.py b/pronote_sync/sources/pronote/client.py index 48670b1..6df44b6 100644 --- a/pronote_sync/sources/pronote/client.py +++ b/pronote_sync/sources/pronote/client.py @@ -488,9 +488,23 @@ class PronoteClient: Chaque entrée est mappée sur un modèle :class:`Message` de type ``SURVEY`` si c'est un sondage, ``INFORMATION`` sinon. - :return: Liste des informations et sondages ; vide en cas d'erreur. + En mode ``qr_token``, la récupération est ignorée sans connexion ni + appel réseau : l'endpoint ``PageActualites`` renvoie une erreur + pronotepy 20 sur les instances HubEduConnect testées, provoquant un + refresh redondant du token. La méthode retourne alors immédiatement + une liste vide et journalise un message INFO unique ; ce comportement + n'est pas configurable. + + :return: Liste des informations et sondages ; vide en cas d'erreur ou + en mode ``qr_token``. :rtype: list[Message] """ + if self._settings.auth_mode == "qr_token": + logger.info( + "Récupération des informations Pronote ignorée : endpoint " + "PageActualites indisponible en mode d'authentification qr_token." + ) + return [] with self._qr_token_operation_lock(): try: client = self._connect() diff --git a/tests/unit/test_pronote_client.py b/tests/unit/test_pronote_client.py index cdfa312..c9eb36a 100644 --- a/tests/unit/test_pronote_client.py +++ b/tests/unit/test_pronote_client.py @@ -621,12 +621,17 @@ def test_get_messages_degraded_on_error( def test_get_informations_degraded_on_error( - mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings + mocker: pytest_mock.MockerFixture, + pronote_settings: PronoteSettings, + caplog: pytest.LogCaptureFixture, ) -> None: """Vérifie que get_informations retourne une liste vide en cas d'erreur réseau. + Assert que le chemin d'erreur retourne toujours [] avec un log ERROR. + :param mocker: Fixture pytest-mock pour le mocking. :param pronote_settings: Paramètres Pronote valides. + :param caplog: Fixture pour capturer les logs. :return: None """ mock_client = mocker.MagicMock() @@ -634,14 +639,254 @@ def test_get_informations_degraded_on_error( mocker.patch.object(PronoteClient, "_connect", return_value=mock_client) client = PronoteClient(pronote_settings) - messages = client.get_informations() + + with caplog.at_level(logging.ERROR, logger="pronote_sync.sources.pronote.client"): + messages = client.get_informations() assert messages == [] + # Assert ERROR log is present + error_records = [r for r in caplog.records if r.levelno == logging.ERROR] + assert len(error_records) >= 1 + assert any( + "Échec de la récupération des informations Pronote" in r.message for r in error_records + ) # --- QR code / token authentication tests --- +# --- get_informations qr_token mode guard tests --- + + +def test_get_informations_skips_in_qr_token_mode( + mocker: pytest_mock.MockerFixture, +) -> None: + """Vérifie que get_informations retourne [] immédiatement en mode qr_token. + + :param mocker: Fixture pytest-mock pour le mocking. + :return: None + """ + settings = PronoteSettings( + url="https://pronote.example.com", + username="testuser", + password=SecretStr("testpass"), + ent="bordeaux", + account_type="parent", + auth_mode="qr_token", + ) + client = PronoteClient(settings) + assert client._client is None + + messages = client.get_informations() + + assert messages == [] + assert client._client is None + + +def test_get_informations_no_connect_in_qr_token_mode( + mocker: pytest_mock.MockerFixture, +) -> None: + """Vérifie que _connect n'est jamais appelé en mode qr_token pour get_informations. + + :param mocker: Fixture pytest-mock pour le mocking. + :return: None + """ + settings = PronoteSettings( + url="https://pronote.example.com", + username="testuser", + password=SecretStr("testpass"), + ent="bordeaux", + account_type="parent", + auth_mode="qr_token", + ) + connect_spy = mocker.spy(PronoteClient, "_connect") + + client = PronoteClient(settings) + messages = client.get_informations() + + assert messages == [] + connect_spy.assert_not_called() + + +def test_get_informations_no_information_and_surveys_in_qr_token_mode( + mocker: pytest_mock.MockerFixture, +) -> None: + """Vérifie que information_and_surveys n'est jamais appelé en mode qr_token. + + :param mocker: Fixture pytest-mock pour le mocking. + :return: None + """ + settings = PronoteSettings( + url="https://pronote.example.com", + username="testuser", + password=SecretStr("testpass"), + ent="bordeaux", + account_type="parent", + auth_mode="qr_token", + ) + mock_client = mocker.MagicMock() + mock_client.information_and_surveys = mocker.MagicMock() + mocker.patch.object(PronoteClient, "_connect", return_value=mock_client) + + client = PronoteClient(settings) + messages = client.get_informations() + + assert messages == [] + mock_client.information_and_surveys.assert_not_called() + + +def test_get_informations_qr_token_no_side_effects( + mocker: pytest_mock.MockerFixture, + caplog: pytest.LogCaptureFixture, +) -> None: + """Prouve le contrat complet sans effet de bordure du chemin de saut qr_token. + + Avec PronoteAuthState et _qr_token_operation_lock et _persist_credentials + mockés, assert que sur le saut : le gestionnaire de contexte de verrou n'est + PAS entré, auth-state load() n'est PAS appelé, _persist_credentials() n'est + PAS appelé, et l'export des credentials n'est PAS invoqué. Assert aussi que + self._client est inchangé. + + :param mocker: Fixture pytest-mock pour le mocking. + :param caplog: Fixture pour capturer les logs. + :return: None + """ + auth_state = mocker.MagicMock(spec=PronoteAuthState) + auth_state.load = mocker.MagicMock() + auth_state.lock = mocker.MagicMock() + + settings = PronoteSettings( + url="https://pronote.example.com", + username="testuser", + password=SecretStr("testpass"), + ent="bordeaux", + account_type="parent", + auth_mode="qr_token", + ) + + client = PronoteClient(settings, auth_state=auth_state) + sentinel = MagicMock() + client._client = sentinel + persist_spy = mocker.spy(client, "_persist_credentials") + + with caplog.at_level(logging.INFO, logger="pronote_sync.sources.pronote.client"): + messages = client.get_informations() + + # Assert no side effects + assert messages == [] + assert client._client is sentinel + assert isinstance(sentinel, MagicMock) + sentinel.export_credentials.assert_not_called() + persist_spy.assert_not_called() + auth_state.load.assert_not_called() + auth_state.lock.assert_not_called() + + # Assert exactly one INFO log record with the exact message + info_records = [r for r in caplog.records if r.levelno == logging.INFO] + assert len(info_records) == 1 + assert ( + info_records[0].message + == "Récupération des informations Pronote ignorée : endpoint PageActualites " + "indisponible en mode d'authentification qr_token." + ) + # Assert no secret sentinel appears in any log record + for record in caplog.records: + assert "testpass" not in record.message + assert "testuser" not in record.message + assert "pronote.example.com" not in record.message + + +def test_get_informations_logs_info_in_qr_token_mode( + mocker: pytest_mock.MockerFixture, + caplog: pytest.LogCaptureFixture, +) -> None: + """Vérifie que get_informations log un message INFO exact en mode qr_token. + + Assert exactement un enregistrement logging.INFO avec le message exact + (inspection de caplog.records, pas seulement caplog.text), et qu'aucune + sentinelle de secret n'apparaît. + + :param mocker: Fixture pytest-mock pour le mocking. + :param caplog: Fixture pour capturer les logs. + :return: None + """ + settings = PronoteSettings( + url="https://pronote.example.com", + username="testuser", + password=SecretStr("testpass"), + ent="bordeaux", + account_type="parent", + auth_mode="qr_token", + ) + + with caplog.at_level(logging.INFO, logger="pronote_sync.sources.pronote.client"): + client = PronoteClient(settings) + messages = client.get_informations() + + assert messages == [] + # Assert exactly one INFO record with the exact message + info_records = [r for r in caplog.records if r.levelno == logging.INFO] + assert len(info_records) == 1 + assert ( + info_records[0].message + == "Récupération des informations Pronote ignorée : endpoint PageActualites " + "indisponible en mode d'authentification qr_token." + ) + # Assert no secret sentinel appears in any record + for record in caplog.records: + assert "testpass" not in record.message + assert "testuser" not in record.message + assert "pronote.example.com" not in record.message + + +def test_get_informations_unchanged_in_password_mode( + mocker: pytest_mock.MockerFixture, + pronote_settings: PronoteSettings, +) -> None: + """Vérifie que get_informations en mode password reste inchangé (régression). + + Assert que _connect() A ÉTÉ appelé et information_and_surveys() A ÉTÉ appelé + (en cas de succès), en conservant les assertions de mappage existantes. + + :param mocker: Fixture pytest-mock pour le mocking. + :param pronote_settings: Paramètres Pronote valides en mode password. + :return: None + """ + mock_client = mocker.MagicMock() + mock_info = mocker.MagicMock() + mock_info.id = "info-456" + mock_info.title = "Important Info" + mock_info.content.return_value = "Important content" + mock_info.author = "Admin" + mock_info.creation_date = datetime(2024, 9, 2, 14, 30, 0) + mock_info.read = False + mock_info.survey = True + mock_client.information_and_surveys.return_value = [mock_info] + + # Patch _connect to return mock_client and track calls + connect_patch = mocker.patch.object(PronoteClient, "_connect", return_value=mock_client) + + client = PronoteClient(pronote_settings) + messages = client.get_informations() + + # Assert _connect() WAS called and information_and_surveys() WAS called + connect_patch.assert_called_once() + mock_client.information_and_surveys.assert_called_once() + + # Retain existing mapping assertions + assert isinstance(messages, list) + assert len(messages) == 1 + message = messages[0] + assert isinstance(message, Message) + assert message.id == "info-456" + assert message.type == MessageType.SURVEY + assert message.title == "Important Info" + assert message.content == "Important content" + assert message.author == "Admin" + assert message.date == datetime(2024, 9, 2, 14, 30, 0) + assert message.read is False + + def test_connect_password_mode_unchanged( mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings,