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:
180
tests/unit/test_rotation_propagation.py
Normal file
180
tests/unit/test_rotation_propagation.py
Normal 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)
|
||||
Reference in New Issue
Block a user