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

@@ -9,17 +9,19 @@ import pytest
from pydantic import SecretStr
from pronote_sync.config.settings import AISettings, AppSettings, PronoteSettings, Settings
from pronote_sync.errors import PipelineCriticalError, PipelineWarning
from pronote_sync.errors import PipelineCriticalError, PipelineWarning, PronoteAuthRotationError
from pronote_sync.models.agenda import Lesson, LessonStatus, SchoolEvent
from pronote_sync.models.blog import BlogArticle
from pronote_sync.models.diff import AgendaDiff
from pronote_sync.models.homework import Homework
from pronote_sync.models.message import Message
from pronote_sync.models.sync import CalDAVSyncResult, CalDAVSyncStatus
from pronote_sync.models.xmpp import XmppMessage
from pronote_sync.pipeline.run import PipelineRunner
from pronote_sync.sources.blog.result import BlogRSSFetchResult
from pronote_sync.sources.blog.rss import BlogRSSClient
from pronote_sync.sources.blog.state import BlogRSSState
from pronote_sync.sources.pronote.auth_state import PronoteAuthState
from pronote_sync.sources.pronote.fallback import PronoteFetcher
from pronote_sync.sync.diff import AgendaComparator
@@ -393,6 +395,60 @@ def test_from_settings_with_theoretical_agenda_instantiates_comparator(
assert isinstance(runner._agenda_comparator, RecordingComparator)
def test_from_settings_password_mode_passes_auth_state_none(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Password mode (default) constructs PronoteClient with auth_state=None."""
import pronote_sync.pipeline.run as run_module
constructed: list[tuple[object, object]] = []
class RecordingClient:
"""PronoteClient constructor recording the supplied auth_state."""
def __init__(self, settings: PronoteSettings, *, auth_state: object) -> None:
"""Record the constructor arguments used by the composition root.
:param settings: Pronote settings supplied by the composition root.
:param auth_state: Auth state handler supplied by the composition root.
"""
constructed.append((settings, auth_state))
monkeypatch.setattr(run_module, "PronoteClient", RecordingClient)
PipelineRunner.from_settings(Settings())
assert len(constructed) == 1
assert constructed[0][1] is None
def test_from_settings_qr_token_mode_passes_auth_state_instance(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""QR-token mode constructs PronoteClient with a PronoteAuthState instance."""
import pronote_sync.pipeline.run as run_module
constructed: list[tuple[object, object]] = []
class RecordingClient:
"""PronoteClient constructor recording the supplied auth_state."""
def __init__(self, settings: PronoteSettings, *, auth_state: object) -> None:
"""Record the constructor arguments used by the composition root.
:param settings: Pronote settings supplied by the composition root.
:param auth_state: Auth state handler supplied by the composition root.
"""
constructed.append((settings, auth_state))
monkeypatch.setattr(run_module, "PronoteClient", RecordingClient)
PipelineRunner.from_settings(Settings(pronote=PronoteSettings(auth_mode="qr_token")))
assert len(constructed) == 1
assert isinstance(constructed[0][1], PronoteAuthState)
def test_runner_reuses_ical_download_and_parse_within_one_run(
monkeypatch: pytest.MonkeyPatch,
pipeline_inputs: tuple[Lesson, Homework],
@@ -1063,3 +1119,338 @@ def test_runner_ical_cache_cleanup_on_second_run(
"https://pronote.example.test/calendar.ics",
"https://pronote.example.test/calendar.ics",
]
def test_rotation_error_sends_xmpp_notification() -> None:
"""Une PronoteAuthRotationError envoie une notification XMPP puis retourne un résultat dégradé.
Ce test vérifie que l'erreur de rotation se propage à travers le pipeline réel
(PronoteFetcher → fetch_step → PipelineRunner.run) et déclenche une notification XMPP
avec un message actionnable.
"""
calls: list[str] = []
channel = StubChannel(calls)
# Créer un client Pronote qui lève PronoteAuthRotationError
class RotatingPronoteClient:
"""Client Pronote qui simule une erreur de rotation de token."""
def get_lessons(self, start: date, end: date) -> list[Lesson]:
"""Lève l'erreur de rotation lors de la récupération des cours.
:param start: Début de la fenêtre (ignoré).
:param end: Fin de la fenêtre (ignoré).
:return: Ne retourne jamais.
:raises PronoteAuthRotationError: Toujours.
"""
del start, end
raise PronoteAuthRotationError("Token persisté expiré : ré-enrôlement requis")
def get_homeworks(self, start: date, end: date) -> list[Homework]:
"""Ne devrait pas être appelé si fetch_agenda échoue.
:param start: Début de la fenêtre (ignoré).
:param end: Fin de la fenêtre (ignoré).
:return: Liste vide.
:rtype: list[Homework]
"""
del start, end
return []
def get_messages(self) -> list[Message]:
"""Ne devrait pas être appelé si fetch_agenda échoue.
:return: Liste vide.
:rtype: list[Message]
"""
return []
def get_informations(self) -> list[Message]:
"""Ne devrait pas être appelé si fetch_agenda échoue.
:return: Liste vide.
:rtype: list[Message]
"""
return []
settings = Settings(
pronote=PronoteSettings(
url="https://pronote.example.com",
username="test",
password=SecretStr("test_password"),
ent="bordeaux",
account_type="parent",
agenda_source="pronotepy",
homework_source="pronotepy",
)
)
runner = PipelineRunner(
settings=settings,
pronote_fetcher=PronoteFetcher(settings, RotatingPronoteClient()),
channel=channel,
now_provider=lambda: datetime(2026, 9, 8, 7, 0),
)
data, errors = runner.run()
assert data is None
assert len(errors) == 1
assert isinstance(errors[0], PipelineCriticalError)
assert len(channel.messages) == 1
message = channel.messages[0]
assert isinstance(message, XmppMessage)
assert message.target_date == date(2026, 9, 8)
assert message.synthesis is not None
assert "Rotation" in message.synthesis
assert "token" in message.synthesis
assert "QR code" in message.synthesis
def test_rotation_error_no_channel_no_xmpp_send() -> None:
"""Sans canal XMPP, l'erreur de rotation ne tente aucun envoi.
Ce test vérifie que même sans canal XMPP configuré, l'erreur de rotation
est correctement capturée et retournée dans la liste des erreurs.
"""
class RotatingPronoteClient:
"""Client Pronote qui simule une erreur de rotation de token."""
def get_lessons(self, start: date, end: date) -> list[Lesson]:
"""Lève l'erreur de rotation lors de la récupération des cours.
:param start: Début de la fenêtre (ignoré).
:param end: Fin de la fenêtre (ignoré).
:return: Ne retourne jamais.
:raises PronoteAuthRotationError: Toujours.
"""
del start, end
raise PronoteAuthRotationError("Token persisté expiré : ré-enrôlement requis")
def get_homeworks(self, start: date, end: date) -> list[Homework]:
"""Ne devrait pas être appelé.
:param start: Début de la fenêtre (ignoré).
:param end: Fin de la fenêtre (ignoré).
:return: Liste vide.
:rtype: list[Homework]
"""
del start, end
return []
def get_messages(self) -> list[Message]:
"""Ne devrait pas être appelé.
:return: Liste vide.
:rtype: list[Message]
"""
return []
def get_informations(self) -> list[Message]:
"""Ne devrait pas être appelé.
:return: Liste vide.
:rtype: list[Message]
"""
return []
settings = Settings(
pronote=PronoteSettings(
url="https://pronote.example.com",
username="test",
password=SecretStr("test_password"),
ent="bordeaux",
account_type="parent",
agenda_source="pronotepy",
homework_source="pronotepy",
)
)
runner = PipelineRunner(
settings=settings,
pronote_fetcher=PronoteFetcher(settings, RotatingPronoteClient()),
channel=None,
now_provider=lambda: datetime(2026, 9, 8, 7, 0),
)
data, errors = runner.run()
assert data is None
assert len(errors) == 1
assert isinstance(errors[0], PipelineCriticalError)
def test_rotation_error_dry_run_no_xmpp_send() -> None:
"""En dry-run, l'erreur de rotation n'envoie aucune notification XMPP.
Ce test vérifie que même en mode dry-run, l'erreur de rotation est correctement
capturée et retournée, mais aucune notification XMPP n'est envoyée.
"""
class RotatingPronoteClient:
"""Client Pronote qui simule une erreur de rotation de token."""
def get_lessons(self, start: date, end: date) -> list[Lesson]:
"""Lève l'erreur de rotation lors de la récupération des cours.
:param start: Début de la fenêtre (ignoré).
:param end: Fin de la fenêtre (ignoré).
:return: Ne retourne jamais.
:raises PronoteAuthRotationError: Toujours.
"""
del start, end
raise PronoteAuthRotationError("Token persisté expiré : ré-enrôlement requis")
def get_homeworks(self, start: date, end: date) -> list[Homework]:
"""Ne devrait pas être appelé.
:param start: Début de la fenêtre (ignoré).
:param end: Fin de la fenêtre (ignoré).
:return: Liste vide.
:rtype: list[Homework]
"""
del start, end
return []
def get_messages(self) -> list[Message]:
"""Ne devrait pas être appelé.
:return: Liste vide.
:rtype: list[Message]
"""
return []
def get_informations(self) -> list[Message]:
"""Ne devrait pas être appelé.
:return: Liste vide.
:rtype: list[Message]
"""
return []
calls: list[str] = []
channel = StubChannel(calls)
settings = Settings(
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,
)
)
runner = PipelineRunner(
settings=settings,
pronote_fetcher=PronoteFetcher(settings, RotatingPronoteClient()),
channel=channel,
dry_run=True,
now_provider=lambda: datetime(2026, 9, 8, 7, 0),
)
data, errors = runner.run()
assert data is None
assert len(errors) == 1
assert isinstance(errors[0], PipelineCriticalError)
assert channel.messages == []
assert "send" not in calls
def test_no_secrets_in_xmpp_message() -> None:
"""La synthèse XMPP de rotation ne contient aucun secret (token, PIN, URL).
Ce test vérifie que le message XMPP généré pour une erreur de rotation
ne contient aucun secret sensible, même si l'erreur originale en contenait.
"""
class RotatingPronoteClient:
"""Client Pronote qui simule une erreur de rotation avec secrets dans message."""
def get_lessons(self, start: date, end: date) -> list[Lesson]:
"""Lève l'erreur de rotation avec message contenant des secrets.
:param start: Début de la fenêtre (ignoré).
:param end: Fin de la fenêtre (ignoré).
:return: Ne retourne jamais.
:raises PronoteAuthRotationError: Toujours, avec des secrets dans le message.
"""
del start, end
raise PronoteAuthRotationError(
"Token sk-sentinel-token-987654 invalide et PIN 000000 pour "
"https://pronote.sentinel.example/icalsecurise"
)
def get_homeworks(self, start: date, end: date) -> list[Homework]:
"""Ne devrait pas être appelé.
:param start: Début de la fenêtre (ignoré).
:param end: Fin de la fenêtre (ignoré).
:return: Liste vide.
:rtype: list[Homework]
"""
del start, end
return []
def get_messages(self) -> list[Message]:
"""Ne devrait pas être appelé.
:return: Liste vide.
:rtype: list[Message]
"""
return []
def get_informations(self) -> list[Message]:
"""Ne devrait pas être appelé.
:return: Liste vide.
:rtype: list[Message]
"""
return []
channel = StubChannel([])
settings = Settings(
pronote=PronoteSettings(
url="https://pronote.example.com",
username="test",
password=SecretStr("test_password"),
ent="bordeaux",
account_type="parent",
agenda_source="pronotepy",
homework_source="pronotepy",
)
)
runner = PipelineRunner(
settings=settings,
pronote_fetcher=PronoteFetcher(settings, RotatingPronoteClient()),
channel=channel,
now_provider=lambda: datetime(2026, 9, 8, 7, 0),
)
data, errors = runner.run()
assert data is None
assert len(errors) == 1
assert len(channel.messages) == 1
message = channel.messages[0]
assert isinstance(message, XmppMessage)
assert message.synthesis is not None
# Vérifier que les secrets ne sont pas dans le message final
assert "sk-sentinel-token-987654" not in message.synthesis
assert "000000" not in message.synthesis
assert "pronote.sentinel.example" not in message.synthesis
# Vérifier que le message contient les instructions actionnables
assert ".pronote_auth_state.json" in message.synthesis
assert "PRONOTE_QR_CODE_FILE" in message.synthesis
assert "PRONOTE_QR_PIN" in message.synthesis