feat: authentification QR code / token pour Pronote

Ajoute le mode d'authentification PRONOTE_AUTH_MODE=qr_token comme alternative
au mode password pour les instances Pronote utilisant HubEduConnect/EduConnect
où l'authentification par mot de passe échoue (CAPTCHA, MFA, flux SAML).

Nouveaux éléments :
- PronoteSettings : auth_mode, qr_code_file, qr_pin (SecretStr)
- PronoteAuthState : persistance du token rotatif dans .pronote_auth_state.json
  (écriture atomique, permissions 0600, symlink-safe via O_EXCL|O_NOFOLLOW)
- PronoteClient._connect_qr_token() : token_login avec creds persistés,
  qrcode_login pour l'enrôlement initial, export_credentials persisté après
  chaque login réussi
- PronoteAuthRotationError : levée en cas d'échec de rotation du token,
  propagée sans wrapping à travers PronoteFetcher et fetch_step jusqu'à
  PipelineRunner.run() qui notifie via XMPP (si canal disponible et dry_run inactif)
- _is_pronotepy_configured() mode-aware : qr_token ne requiert que PRONOTE_URL
- _collect_auth_secrets() : redaction des secrets explicites (token, PIN, jeton QR)
  dans tous les logs du chemin d'authentification

Documentation :
- .env.example : PRONOTE_AUTH_MODE, PRONOTE_QR_CODE_FILE, PRONOTE_QR_PIN
- AGENTS.md : contrat d'authentification QR code / token
- Wiki GuidePronote : section enrôlement, exécutions suivantes, ré-enrôlement

Tests (686 passés, couverture 94.87%) :
- 5 tests config QR, 9 tests auth_state, 10 tests client QR, 3 tests propagation,
  4 tests intégration rotation end-to-end, 4 tests fallback mode-aware
- Tests de non-fuite : sentinelles distinctes pour token, PIN, jeton QR

Co-authored-by: coder/litellm/coder <coder@agents.invalid>
This commit is contained in:
2026-09-08 23:15:06 +02:00
parent 4a6207f716
commit 0363898669
16 changed files with 2128 additions and 39 deletions

View File

@@ -0,0 +1,180 @@
"""Unit tests for PronoteAuthRotationError propagation through each layer.
These tests verify that the rotation error propagates correctly through the
real call chain without being wrapped in PipelineCriticalError at any layer.
"""
from __future__ import annotations
from datetime import date
import pytest
from pydantic import SecretStr
from pronote_sync.config.settings import PronoteSettings, Settings
from pronote_sync.errors import PipelineCriticalError, PronoteAuthRotationError
from pronote_sync.models.agenda import Lesson, SchoolEvent
from pronote_sync.models.homework import Homework
from pronote_sync.models.message import Message
from pronote_sync.pipeline.steps.fetch import fetch_step
from pronote_sync.sources.pronote.fallback import PronoteFetcher
class StubPronoteClientWithRotationError:
"""Stub PronoteClient that raises PronoteAuthRotationError from its methods."""
def get_lessons(self, start: date, end: date) -> list[Lesson]:
"""Raise rotation error when fetching lessons.
:param start: Start date (unused).
:param end: End date (unused).
:return: Never returns.
:raises PronoteAuthRotationError: Always.
"""
del start, end
raise PronoteAuthRotationError("Token persisté expiré : ré-enrôlement requis")
def get_homeworks(self, start: date, end: date) -> list[Homework]:
"""Raise rotation error when fetching homeworks.
:param start: Start date (unused).
:param end: End date (unused).
:return: Never returns.
:raises PronoteAuthRotationError: Always.
"""
del start, end
raise PronoteAuthRotationError("Token persisté expiré : ré-enrôlement requis")
def get_messages(self) -> list[Message]:
"""Return empty messages list.
:return: Empty list.
:rtype: list[Message]
"""
return []
def get_informations(self) -> list[Message]:
"""Return empty information messages list.
:return: Empty list.
:rtype: list[Message]
"""
return []
class StubSettings:
"""Minimal settings stub for PronoteFetcher."""
def __init__(self) -> None:
"""Initialize with minimal configuration."""
self.pronote = PronoteSettings(
url="https://pronote.example.com",
username="test",
password=SecretStr("test_password"),
ent="bordeaux",
account_type="parent",
agenda_source="pronotepy",
homework_source="pronotepy",
messages_source="pronotepy",
auth_mode="password",
qr_code_file=None,
qr_pin=None,
ical_url=None,
)
self.app = type("AppSettings", (), {"sync_past_days": 7, "sync_future_days": 7})()
class StubFetcherWithRotationError:
"""Stub PronoteFetcher that raises PronoteAuthRotationError from its methods."""
def __init__(self) -> None:
"""Initialize the stub fetcher."""
self._settings = StubSettings()
self._client = StubPronoteClientWithRotationError()
def fetch_agenda(self) -> tuple[list[Lesson], list[SchoolEvent]]:
"""Raise rotation error when fetching agenda.
:return: Never returns.
:rtype: tuple[list[Lesson], list[SchoolEvent]]
:raises PronoteAuthRotationError: Always.
"""
raise PronoteAuthRotationError("Token persisté expiré : ré-enrôlement requis")
def fetch_homework(self, target_date: date) -> list[Homework]:
"""Raise rotation error when fetching homework.
:param target_date: Target date (unused).
:return: Never returns.
:rtype: list[Homework]
:raises PronoteAuthRotationError: Always.
"""
del target_date
raise PronoteAuthRotationError("Token persisté expiré : ré-enrôlement requis")
def fetch_messages(self) -> list[Message]:
"""Return empty messages list.
:return: Empty list.
:rtype: list[Message]
"""
return []
def fetch_informations(self) -> list[Message]:
"""Return empty information messages list.
:return: Empty list.
:rtype: list[Message]
"""
return []
def test_pronote_fetcher_fetch_agenda_propagates_rotation_error() -> None:
"""PronoteFetcher.fetch_agenda() propagates PronoteAuthRotationError without wrapping.
This test verifies that when the underlying PronoteClient raises
PronoteAuthRotationError, the fetcher propagates it directly without
converting it to PipelineCriticalError.
"""
settings = Settings(pronote=StubSettings().pronote)
fetcher = PronoteFetcher(settings, StubPronoteClientWithRotationError())
with pytest.raises(PronoteAuthRotationError) as exc_info:
fetcher.fetch_agenda()
assert "Token persisté expiré" in str(exc_info.value)
assert not isinstance(exc_info.value, PipelineCriticalError)
def test_pronote_fetcher_fetch_homework_propagates_rotation_error() -> None:
"""PronoteFetcher.fetch_homework() propagates PronoteAuthRotationError without wrapping.
This test verifies that when the underlying PronoteClient raises
PronoteAuthRotationError, the fetcher propagates it directly without
converting it to PipelineCriticalError.
"""
settings = Settings(pronote=StubSettings().pronote)
fetcher = PronoteFetcher(settings, StubPronoteClientWithRotationError())
target_date = date(2026, 9, 9)
with pytest.raises(PronoteAuthRotationError) as exc_info:
fetcher.fetch_homework(target_date)
assert "Token persisté expiré" in str(exc_info.value)
assert not isinstance(exc_info.value, PipelineCriticalError)
def test_fetch_step_propagates_rotation_error() -> None:
"""fetch_step() propagates PronoteAuthRotationError without wrapping.
This test verifies that the pipeline step fetch_step() propagates
PronoteAuthRotationError directly from the fetcher without converting
it to PipelineCriticalError.
"""
fetcher = StubFetcherWithRotationError()
with pytest.raises(PronoteAuthRotationError) as exc_info:
fetch_step(fetcher, today=date(2026, 9, 8))
assert "Token persisté expiré" in str(exc_info.value)
assert not isinstance(exc_info.value, PipelineCriticalError)