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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user