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

View File

@@ -131,4 +131,80 @@ def test_url_from_pronote_url_env_var(monkeypatch: MonkeyPatch) -> None:
assert settings.pronote.url == test_url
def test_auth_mode_default_password() -> None:
"""Vérifie que ``auth_mode`` vaut ``"password"`` par défaut.
:return: None
"""
settings = PronoteSettings()
assert settings.auth_mode == "password"
def test_auth_mode_env_qr_token(monkeypatch: MonkeyPatch) -> None:
"""Vérifie que ``PRONOTE_AUTH_MODE=qr_token`` est chargé correctement.
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
:return: None
"""
monkeypatch.setenv("PRONOTE_AUTH_MODE", "qr_token")
settings = load_settings()
assert settings.pronote.auth_mode == "qr_token"
def test_qr_pin_loaded_as_secretstr_and_masked(monkeypatch: MonkeyPatch) -> None:
"""Vérifie que ``PRONOTE_QR_PIN`` est chargé en ``SecretStr`` et masqué.
Le PIN ne doit apparaître nulle part dans les représentations textuelles
(str, repr, JSON) : seul le masque ``**********`` est visible.
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
:return: None
"""
monkeypatch.setenv("PRONOTE_QR_PIN", "123456")
settings = load_settings()
assert isinstance(settings.pronote.qr_pin, SecretStr)
assert settings.pronote.qr_pin.get_secret_value() == "123456"
str_repr = str(settings)
assert "123456" not in str_repr
assert "**********" in str_repr
repr_repr = repr(settings)
assert "123456" not in repr_repr
assert "**********" in repr_repr
json_str = settings.model_dump_json()
assert "123456" not in json_str
assert "**********" in json_str
def test_qr_code_file_loaded_as_plain_string(monkeypatch: MonkeyPatch) -> None:
"""Vérifie que ``PRONOTE_QR_CODE_FILE`` est chargé comme chaîne simple.
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
:return: None
"""
monkeypatch.setenv("PRONOTE_QR_CODE_FILE", "/data/qr_code.png")
settings = load_settings()
assert isinstance(settings.pronote.qr_code_file, str)
assert settings.pronote.qr_code_file == "/data/qr_code.png"
def test_qr_pin_in_redaction_secrets(monkeypatch: MonkeyPatch) -> None:
"""Vérifie que le PIN QR est collecté pour la rédaction des secrets.
Le ``SecretStr`` du PIN doit figurer dans ``redaction_secrets()`` et sa
représentation textuelle doit rester masquée.
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
:return: None
"""
monkeypatch.setenv("PRONOTE_QR_PIN", "654321")
settings = load_settings()
secrets = settings.redaction_secrets()
assert settings.pronote.qr_pin in secrets
assert "654321" not in repr(settings.pronote.qr_pin)
assert "**********" in repr(settings.pronote.qr_pin)
# Ensure trailing newline

View File

@@ -1315,6 +1315,110 @@ def test_is_pronotepy_configured_without_ent_returns_true() -> None:
assert fetcher._is_pronotepy_configured() is True
def test_is_pronotepy_configured_password_mode_all_set() -> None:
"""Test _is_pronotepy_configured() en mode password avec tous les champs définis.
URL, identifiant et mot de passe sont présents : la fonction retourne True.
:return: None
:rtype: None
"""
settings = Settings(
pronote=PronoteSettings(
url="https://pronote.example.com",
username="testuser",
password=SecretStr("testpass"),
agenda_source="pronotepy",
homework_source="pronotepy",
),
app=Settings().app,
)
client: _MockPronoteClientProtocol = MagicMock()
fetcher = PronoteFetcher(settings=settings, pronote_client=client)
assert fetcher._is_pronotepy_configured() is True
def test_is_pronotepy_configured_password_mode_missing_password() -> None:
"""Test _is_pronotepy_configured() en mode password sans mot de passe.
Le mot de passe est None : la fonction retourne False.
:return: None
:rtype: None
"""
settings = Settings(
pronote=PronoteSettings(
url="https://pronote.example.com",
username="testuser",
password=None,
agenda_source="pronotepy",
homework_source="pronotepy",
),
app=Settings().app,
)
client: _MockPronoteClientProtocol = MagicMock()
fetcher = PronoteFetcher(settings=settings, pronote_client=client)
assert fetcher._is_pronotepy_configured() is False
def test_is_pronotepy_configured_qr_token_mode_url_only() -> None:
"""Test _is_pronotepy_configured() en mode qr_token avec URL uniquement.
En mode qr_token, seul l'URL est requis : l'identifiant et le mot de
passe peuvent être absents, la fonction retourne True.
:return: None
:rtype: None
"""
settings = Settings(
pronote=PronoteSettings(
url="https://pronote.example.com",
username=None,
password=None,
auth_mode="qr_token",
agenda_source="pronotepy",
homework_source="pronotepy",
),
app=Settings().app,
)
client: _MockPronoteClientProtocol = MagicMock()
fetcher = PronoteFetcher(settings=settings, pronote_client=client)
assert fetcher._is_pronotepy_configured() is True
def test_is_pronotepy_configured_qr_token_mode_no_url() -> None:
"""Test _is_pronotepy_configured() en mode qr_token sans URL.
L'URL est None : la fonction retourne False, même si le mode qr_token
ne requiert que PRONOTE_URL.
:return: None
:rtype: None
"""
settings = Settings(
pronote=PronoteSettings(
url=None,
username=None,
password=None,
auth_mode="qr_token",
agenda_source="pronotepy",
homework_source="pronotepy",
),
app=Settings().app,
)
client: _MockPronoteClientProtocol = MagicMock()
fetcher = PronoteFetcher(settings=settings, pronote_client=client)
assert fetcher._is_pronotepy_configured() is False
def test_agenda_sources_auto_without_ical_and_without_ent_returns_pronotepy() -> None:
"""Test _agenda_sources() en mode AUTO sans iCal URL et sans ent retourne pronotepy.

View File

@@ -0,0 +1,208 @@
"""Tests unitaires pour le gestionnaire d'état d'authentification Pronote.
Ce module valide le comportement de :class:`PronoteAuthState` dans
:mod:`pronote_sync.sources.pronote.auth_state`. Les tests couvrent :
- Le chargement des credentials (absent, corrompu, version invalide),
- La persistance et le rechargement des credentials,
- Les permissions ``0600`` du fichier d'état,
- La suppression via :meth:`clear`,
- L'absence de fuite des credentials dans les journaux.
Tous les tests utilisent des fichiers temporaires via la fixture ``tmp_path``.
"""
from __future__ import annotations
import json
import logging
import os
from pathlib import Path
import pytest
from pronote_sync.sources.pronote.auth_state import PronoteAuthState
def test_load_no_file_returns_none(tmp_path: Path) -> None:
"""Vérifie qu'un fichier d'état absent renvoie ``None``.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state = PronoteAuthState(tmp_path / "missing.json")
assert state.load() is None
def test_save_then_load_roundtrip(tmp_path: Path) -> None:
"""Vérifie que des credentials sauvegardés sont rechargés à l'identique.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "auth.json"
credentials = {
"pronote_url": "https://example.com/pronote",
"username": "parent-1",
"password": "token-123", # pragma: allowlist secret
"uuid": "uuid-456",
}
state = PronoteAuthState(state_file)
state.save(credentials)
loaded = PronoteAuthState(state_file).load()
assert loaded == credentials
def test_load_corrupted_json_returns_none(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
"""Vérifie qu'un fichier JSON corrompu renvoie ``None`` et journalise un avertissement.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:param caplog: Fixture pytest pour capturer les logs.
:return: None
"""
state_file = tmp_path / "corrupt.json"
state_file.write_text("not json{", encoding="utf-8")
with caplog.at_level("WARNING"):
result = PronoteAuthState(state_file).load()
assert result is None
assert "Impossible de charger le fichier d'état d'authentification Pronote" in caplog.text
def test_load_wrong_version_returns_none(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
"""Vérifie qu'une version non supportée renvoie ``None`` et journalise un avertissement.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:param caplog: Fixture pytest pour capturer les logs.
:return: None
"""
state_file = tmp_path / "wrong_version.json"
state_file.write_text(
json.dumps(
{
"version": 2,
"credentials": {
"pronote_url": "https://example.com",
"username": "u",
"password": "t",
"uuid": "i",
},
}
),
encoding="utf-8",
)
with caplog.at_level("WARNING"):
result = PronoteAuthState(state_file).load()
assert result is None
assert "version absente ou non supportée" in caplog.text
def test_load_missing_version_returns_none(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Vérifie qu'un fichier sans champ version renvoie ``None``.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:param caplog: Fixture pytest pour capturer les logs.
:return: None
"""
state_file = tmp_path / "missing_version.json"
state_file.write_text(
json.dumps(
{
"credentials": {
"pronote_url": "https://example.com",
"username": "u",
"password": "t",
"uuid": "i",
}
}
),
encoding="utf-8",
)
with caplog.at_level("WARNING"):
result = PronoteAuthState(state_file).load()
assert result is None
def test_clear_removes_file(tmp_path: Path) -> None:
"""Vérifie que clear supprime le fichier d'état existant.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "auth.json"
state = PronoteAuthState(state_file)
state.save({"pronote_url": "u", "username": "u", "password": "t", "uuid": "i"})
assert state_file.exists()
state.clear()
assert not state_file.exists()
def test_clear_no_file_noop(tmp_path: Path) -> None:
"""Vérifie que clear ne fait rien quand le fichier n'existe pas.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state = PronoteAuthState(tmp_path / "missing.json")
state.clear()
def test_save_creates_file_with_0600_permissions(tmp_path: Path) -> None:
"""Vérifie que le fichier d'état est créé avec les permissions ``0600``.
Le fichier contient un token vivant : il doit être lisible uniquement
par le propriétaire.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "auth.json"
state = PronoteAuthState(state_file)
state.save({"pronote_url": "u", "username": "u", "password": "t", "uuid": "i"})
assert os.stat(state_file).st_mode & 0o777 == 0o600
def test_no_credentials_in_logs(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
"""Vérifie qu'aucun contenu des credentials n'apparaît dans les journaux.
Des sentinelles distinctes sont utilisées pour ``pronote_url``,
``username``, ``password`` et ``uuid`` ; aucun de ces marqueurs ne doit
apparaître dans les messages journalisés lors d'une sauvegarde, d'un
chargement et d'une suppression.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:param caplog: Fixture pytest pour capturer les logs.
:return: None
"""
state_file = tmp_path / "auth.json"
credentials = {
"pronote_url": "https://SENTINEL_URL_ZZZ.example/pronote",
"username": "SENTINEL_USER_ZZZ",
"password": "SENTINEL_PASSWORD_ZZZ", # pragma: allowlist secret
"uuid": "SENTINEL_UUID_ZZZ",
}
state = PronoteAuthState(state_file)
with caplog.at_level(logging.DEBUG):
state.save(credentials)
state.load()
state.clear()
assert "SENTINEL_URL_ZZZ" not in caplog.text
assert "SENTINEL_USER_ZZZ" not in caplog.text
assert "SENTINEL_PASSWORD_ZZZ" not in caplog.text
assert "SENTINEL_UUID_ZZZ" not in caplog.text

View File

@@ -7,7 +7,10 @@ utilisent des mocks pour éviter tout accès réseau réel à Pronote.
from __future__ import annotations
import json
import logging
from datetime import date, datetime
from pathlib import Path
import pronotepy
import pytest
@@ -15,9 +18,11 @@ import pytest_mock
from pydantic import SecretStr
from pronote_sync.config.settings import PronoteSettings
from pronote_sync.errors import PronoteAuthRotationError
from pronote_sync.models.agenda import Lesson, LessonStatus
from pronote_sync.models.homework import Homework
from pronote_sync.models.message import Message, MessageType
from pronote_sync.sources.pronote.auth_state import PronoteAuthState
from pronote_sync.sources.pronote.client import PronoteClient, PronoteClientProtocol
# --- Protocol tests ---
@@ -613,4 +618,585 @@ def test_get_informations_degraded_on_error(
assert messages == []
# --- QR code / token authentication tests ---
def test_connect_password_mode_unchanged(
mocker: pytest_mock.MockerFixture,
pronote_settings: PronoteSettings,
) -> None:
"""Vérifie que le mode password conserve le comportement historique.
:param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides en mode password.
:return: None
"""
from unittest.mock import Mock
mock_client = mocker.MagicMock()
mock_client_class = Mock(return_value=mock_client)
mocker.patch("pronotepy.ParentClient", new=mock_client_class)
mocker.patch("pronotepy.Client")
client = PronoteClient(pronote_settings, auth_state=None)
connected = client._connect()
assert connected is mock_client
mock_client_class.assert_called_once_with(
pronote_url="https://pronote.example.com",
username="testuser",
password="testpass", # pragma: allowlist secret
ent=mocker.ANY,
)
# Connexion paresseuse : un second appel réutilise le client déjà créé
client._connect()
assert mock_client_class.call_count == 1
def test_connect_qr_token_with_persisted_creds(
mocker: pytest_mock.MockerFixture,
) -> None:
"""Vérifie le login par token persisté en mode qr_token.
Les credentials chargés depuis :class:`PronoteAuthState` sont rejoués via
``token_login`` et le token rotate est resauvegardé.
:param mocker: Fixture pytest-mock pour le mocking.
:return: None
"""
creds = {
"pronote_url": "https://pronote.example.com",
"username": "testuser",
"password": "persisted-token", # pragma: allowlist secret
"uuid": "persisted-uuid",
}
rotated_creds = {**creds, "uuid": "rotated-uuid"}
auth_state = mocker.MagicMock(spec=PronoteAuthState)
auth_state.load.return_value = creds
mock_client = mocker.MagicMock()
mock_client.logged_in = True
mock_client.export_credentials.return_value = rotated_creds
mocker.patch("pronotepy.ParentClient.token_login", return_value=mock_client)
settings = PronoteSettings(
url="https://pronote.example.com",
username="testuser",
password=SecretStr("testpass"),
ent=None,
account_type="parent",
auth_mode="qr_token",
)
client = PronoteClient(settings, auth_state=auth_state)
connected = client._connect()
assert connected is mock_client
pronotepy.ParentClient.token_login.assert_called_once_with(**creds) # type: ignore[attr-defined]
auth_state.save.assert_called_once_with(rotated_creds)
def test_connect_qr_token_no_creds_with_qr_code(
mocker: pytest_mock.MockerFixture,
tmp_path: Path,
) -> None:
"""Vérifie l'enrôlement initial par QR code quand aucun token n'est persisté.
:param mocker: Fixture pytest-mock pour le mocking.
:param tmp_path: Répertoire temporaire de test.
:return: None
"""
qr_file = tmp_path / "qr_code.json"
qr_file.write_text(
json.dumps(
{
"login": "testuser",
"jeton": "qr-jeton",
"url": "https://pronote.example.com",
}
),
encoding="utf-8",
)
auth_state = mocker.MagicMock(spec=PronoteAuthState)
auth_state.load.return_value = None
creds = {
"pronote_url": "https://pronote.example.com",
"username": "testuser",
"password": "new-token", # pragma: allowlist secret
"uuid": "new-uuid",
}
mock_client = mocker.MagicMock()
mock_client.logged_in = True
mock_client.export_credentials.return_value = creds
mocker.patch("pronotepy.ParentClient.qrcode_login", return_value=mock_client)
settings = PronoteSettings(
url="https://pronote.example.com",
username="testuser",
password=SecretStr("testpass"),
ent=None,
account_type="parent",
auth_mode="qr_token",
qr_code_file=str(qr_file),
qr_pin=SecretStr("123456"),
)
client = PronoteClient(settings, auth_state=auth_state)
connected = client._connect()
assert connected is mock_client
qrcode_login = pronotepy.ParentClient.qrcode_login
qrcode_login.assert_called_once() # type: ignore[attr-defined]
kwargs = qrcode_login.call_args.kwargs # type: ignore[attr-defined]
assert kwargs["pin"] == "123456"
assert kwargs["qr_code"] == {
"login": "testuser",
"jeton": "qr-jeton",
"url": "https://pronote.example.com",
}
assert kwargs["uuid"].startswith("pronote-sync-")
auth_state.save.assert_called_once_with(creds)
def test_connect_qr_token_token_login_fails_raises_rotation_error(
mocker: pytest_mock.MockerFixture,
tmp_path: Path,
) -> None:
"""Vérifie la levée de PronoteAuthRotationError quand le token persisté est invalide.
En cas d'échec du login par token, aucun repli vers l'enrôlement QR
n'est tenté : l'erreur de rotation est levée immédiatement, même si un
fichier QR est disponible.
:param mocker: Fixture pytest-mock pour le mocking.
:param tmp_path: Répertoire temporaire de test.
:return: None
"""
qr_file = tmp_path / "qr_code.json"
qr_file.write_text(
json.dumps(
{
"login": "testuser",
"jeton": "qr-jeton",
"url": "https://pronote.example.com",
}
),
encoding="utf-8",
)
auth_state = mocker.MagicMock(spec=PronoteAuthState)
auth_state.load.return_value = {
"pronote_url": "https://pronote.example.com",
"username": "testuser",
"password": "expired-token", # pragma: allowlist secret
"uuid": "old-uuid",
}
token_login = mocker.patch("pronotepy.ParentClient.token_login")
token_login.side_effect = pronotepy.PronoteAPIError("token invalide")
qrcode_login = mocker.patch("pronotepy.ParentClient.qrcode_login")
settings = PronoteSettings(
url="https://pronote.example.com",
username="testuser",
password=SecretStr("testpass"),
ent=None,
account_type="parent",
auth_mode="qr_token",
qr_code_file=str(qr_file),
qr_pin=SecretStr("123456"),
)
client = PronoteClient(settings, auth_state=auth_state)
with pytest.raises(PronoteAuthRotationError) as exc_info:
client._connect()
token_login.assert_called_once()
qrcode_login.assert_not_called()
auth_state.save.assert_not_called()
message = str(exc_info.value)
assert "expiré ou invalide" in message
assert ".pronote_auth_state.json" in message
assert "PRONOTE_QR_CODE_FILE" in message
def test_token_login_failure_raises_rotation_not_enroll(
mocker: pytest_mock.MockerFixture,
tmp_path: Path,
) -> None:
"""Vérifie qu'un login par token non connecté lève PronoteAuthRotationError sans enrôlement QR.
``token_login`` retourne un client non connecté (``logged_in`` False) :
l'erreur de rotation est levée immédiatement et ``qrcode_login`` n'est
jamais appelé, même avec un QR code disponible.
:param mocker: Fixture pytest-mock pour le mocking.
:param tmp_path: Répertoire temporaire de test.
:return: None
"""
qr_file = tmp_path / "qr_code.json"
qr_file.write_text(
json.dumps(
{
"login": "testuser",
"jeton": "qr-jeton",
"url": "https://pronote.example.com",
}
),
encoding="utf-8",
)
auth_state = mocker.MagicMock(spec=PronoteAuthState)
auth_state.load.return_value = {
"pronote_url": "https://pronote.example.com",
"username": "testuser",
"password": "expired-token", # pragma: allowlist secret
"uuid": "old-uuid",
}
mock_client = mocker.MagicMock()
mock_client.logged_in = False
token_login = mocker.patch("pronotepy.ParentClient.token_login", return_value=mock_client)
qrcode_login = mocker.patch("pronotepy.ParentClient.qrcode_login")
settings = PronoteSettings(
url="https://pronote.example.com",
username="testuser",
password=SecretStr("testpass"),
ent=None,
account_type="parent",
auth_mode="qr_token",
qr_code_file=str(qr_file),
qr_pin=SecretStr("123456"),
)
client = PronoteClient(settings, auth_state=auth_state)
with pytest.raises(PronoteAuthRotationError) as exc_info:
client._connect()
token_login.assert_called_once()
qrcode_login.assert_not_called()
auth_state.save.assert_not_called()
message = str(exc_info.value)
assert "non connecté" in message
assert ".pronote_auth_state.json" in message
def test_connect_qr_token_no_creds_no_qr_raises_rotation_error(
mocker: pytest_mock.MockerFixture,
) -> None:
"""Vérifie la levée de PronoteAuthRotationError sans token persisté ni QR code.
:param mocker: Fixture pytest-mock pour le mocking.
:return: None
"""
auth_state = mocker.MagicMock(spec=PronoteAuthState)
auth_state.load.return_value = None
settings = PronoteSettings(
url="https://pronote.example.com",
username="testuser",
password=SecretStr("testpass"),
ent=None,
account_type="parent",
auth_mode="qr_token",
)
client = PronoteClient(settings, auth_state=auth_state)
with pytest.raises(PronoteAuthRotationError) as exc_info:
client._connect()
message = str(exc_info.value)
assert "PRONOTE_QR_CODE_FILE" in message
assert "PRONOTE_QR_PIN" in message
assert ".pronote_auth_state.json" in message
def test_connect_qr_token_token_login_fails_no_qr_raises_rotation_error(
mocker: pytest_mock.MockerFixture,
) -> None:
"""Vérifie la levée de PronoteAuthRotationError quand le token échoue sans QR.
:param mocker: Fixture pytest-mock pour le mocking.
:return: None
"""
auth_state = mocker.MagicMock(spec=PronoteAuthState)
auth_state.load.return_value = {
"pronote_url": "https://pronote.example.com",
"username": "testuser",
"password": "expired-token", # pragma: allowlist secret
"uuid": "old-uuid",
}
token_login = mocker.patch("pronotepy.ParentClient.token_login")
token_login.side_effect = pronotepy.PronoteAPIError("token invalide")
settings = PronoteSettings(
url="https://pronote.example.com",
username="testuser",
password=SecretStr("testpass"),
ent=None,
account_type="parent",
auth_mode="qr_token",
)
client = PronoteClient(settings, auth_state=auth_state)
with pytest.raises(PronoteAuthRotationError) as exc_info:
client._connect()
message = str(exc_info.value)
assert "expiré ou invalide" in message
assert ".pronote_auth_state.json" in message
assert "PRONOTE_QR_CODE_FILE" in message
def test_connect_qr_token_invalid_qr_json_raises_rotation_error(
mocker: pytest_mock.MockerFixture,
tmp_path: Path,
) -> None:
"""Vérifie la levée de PronoteAuthRotationError pour un fichier QR illisible.
:param mocker: Fixture pytest-mock pour le mocking.
:param tmp_path: Répertoire temporaire de test.
:return: None
"""
qr_file = tmp_path / "qr_code.json"
qr_file.write_text("{json invalide", encoding="utf-8")
auth_state = mocker.MagicMock(spec=PronoteAuthState)
auth_state.load.return_value = None
settings = PronoteSettings(
url="https://pronote.example.com",
username="testuser",
password=SecretStr("testpass"),
ent=None,
account_type="parent",
auth_mode="qr_token",
qr_code_file=str(qr_file),
qr_pin=SecretStr("123456"),
)
client = PronoteClient(settings, auth_state=auth_state)
with pytest.raises(PronoteAuthRotationError) as exc_info:
client._connect()
assert "Impossible de lire le fichier QR code" in str(exc_info.value)
def test_connect_qr_token_missing_qr_key_raises_rotation_error(
mocker: pytest_mock.MockerFixture,
tmp_path: Path,
) -> None:
"""Vérifie la levée de PronoteAuthRotationError quand une clé QR requise manque.
:param mocker: Fixture pytest-mock pour le mocking.
:param tmp_path: Répertoire temporaire de test.
:return: None
"""
qr_file = tmp_path / "qr_code.json"
qr_file.write_text(
json.dumps({"login": "testuser", "url": "https://pronote.example.com"}),
encoding="utf-8",
)
auth_state = mocker.MagicMock(spec=PronoteAuthState)
auth_state.load.return_value = None
settings = PronoteSettings(
url="https://pronote.example.com",
username="testuser",
password=SecretStr("testpass"),
ent=None,
account_type="parent",
auth_mode="qr_token",
qr_code_file=str(qr_file),
qr_pin=SecretStr("123456"),
)
client = PronoteClient(settings, auth_state=auth_state)
with pytest.raises(PronoteAuthRotationError) as exc_info:
client._connect()
assert "jeton" in str(exc_info.value)
def test_connect_qr_token_qrcode_login_fails_raises_rotation_error(
mocker: pytest_mock.MockerFixture,
tmp_path: Path,
) -> None:
"""Vérifie la levée de PronoteAuthRotationError quand le login QR échoue.
:param mocker: Fixture pytest-mock pour le mocking.
:param tmp_path: Répertoire temporaire de test.
:return: None
"""
qr_file = tmp_path / "qr_code.json"
qr_file.write_text(
json.dumps(
{
"login": "testuser",
"jeton": "qr-jeton",
"url": "https://pronote.example.com",
}
),
encoding="utf-8",
)
auth_state = mocker.MagicMock(spec=PronoteAuthState)
auth_state.load.return_value = None
mocker.patch(
"pronotepy.ParentClient.qrcode_login",
side_effect=pronotepy.exceptions.QRCodeDecryptError("PIN incorrect"),
)
settings = PronoteSettings(
url="https://pronote.example.com",
username="testuser",
password=SecretStr("testpass"),
ent=None,
account_type="parent",
auth_mode="qr_token",
qr_code_file=str(qr_file),
qr_pin=SecretStr("123456"),
)
client = PronoteClient(settings, auth_state=auth_state)
with pytest.raises(PronoteAuthRotationError) as exc_info:
client._connect()
message = str(exc_info.value)
assert "PIN invalide ou QR code expiré" in message
assert "PRONOTE_QR_CODE_FILE" in message
def test_no_secrets_in_rotation_error_messages(
mocker: pytest_mock.MockerFixture,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Vérifie qu'aucun secret ne fuit dans les erreurs ni les logs de rotation.
:param mocker: Fixture pytest-mock pour le mocking.
:param tmp_path: Répertoire temporaire de test.
:param caplog: Fixture pytest de capture des logs.
:return: None
"""
sentinel_pin = "SENTINEL_PIN_42"
sentinel_token = "SENTINEL_TOKEN_7"
sentinel_url = "https://sentinel-url.pronote.example.com"
qr_file = tmp_path / "qr_code.json"
qr_file.write_text(
json.dumps(
{
"login": "testuser",
"jeton": sentinel_token,
"url": sentinel_url,
}
),
encoding="utf-8",
)
auth_state = mocker.MagicMock(spec=PronoteAuthState)
auth_state.load.return_value = None
mocker.patch(
"pronotepy.ParentClient.qrcode_login",
side_effect=pronotepy.exceptions.QRCodeDecryptError(
f"token: {sentinel_token} password: {sentinel_pin}"
),
)
settings = PronoteSettings(
url="https://pronote.example.com",
username="testuser",
password=SecretStr("testpass"),
ent=None,
account_type="parent",
auth_mode="qr_token",
qr_code_file=str(qr_file),
qr_pin=SecretStr(sentinel_pin),
)
client = PronoteClient(settings, auth_state=auth_state)
with caplog.at_level(logging.ERROR, logger="pronote_sync.sources.pronote.client"):
with pytest.raises(PronoteAuthRotationError) as exc_info:
client._connect()
message = str(exc_info.value)
assert sentinel_pin not in message
assert sentinel_token not in message
assert "sentinel-url" not in message
assert caplog.text
assert sentinel_pin not in caplog.text
assert sentinel_token not in caplog.text
assert "sentinel-url" not in caplog.text
def test_no_raw_secrets_in_logs(
mocker: pytest_mock.MockerFixture,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Vérifie l'expurgation de secrets bruts sans motif reconnaissable dans les logs.
Des sentinelles distinctes pour le token persisté, le PIN QR et le jeton
QR sont injectées dans le message d'exception de ``token_login`` sans
motif ``cle=valeur`` ni format d'URL ; elles ne doivent apparaître ni
dans les logs ni dans l'erreur de rotation levée.
:param mocker: Fixture pytest-mock pour le mocking.
:param tmp_path: Répertoire temporaire de test.
:param caplog: Fixture pytest de capture des logs.
:return: None
"""
sentinel_token = "SENTINEL_RAW_TOKEN_ALPHA"
sentinel_pin = "SENTINEL_RAW_PIN_BRAVO"
sentinel_jeton = "SENTINEL_RAW_JETON_CHARLIE"
qr_file = tmp_path / "qr_code.json"
qr_file.write_text(
json.dumps(
{
"login": "testuser",
"jeton": sentinel_jeton,
"url": "https://pronote.example.com",
}
),
encoding="utf-8",
)
auth_state = mocker.MagicMock(spec=PronoteAuthState)
auth_state.load.return_value = {
"pronote_url": "https://pronote.example.com",
"username": "testuser",
"password": sentinel_token,
"uuid": "old-uuid",
}
mocker.patch(
"pronotepy.ParentClient.token_login",
side_effect=pronotepy.PronoteAPIError(
f"login refusé {sentinel_token} puis {sentinel_pin} puis {sentinel_jeton}"
),
)
settings = PronoteSettings(
url="https://pronote.example.com",
username="testuser",
password=SecretStr("testpass"),
ent=None,
account_type="parent",
auth_mode="qr_token",
qr_code_file=str(qr_file),
qr_pin=SecretStr(sentinel_pin),
)
client = PronoteClient(settings, auth_state=auth_state)
with caplog.at_level(logging.ERROR, logger="pronote_sync.sources.pronote.client"):
with pytest.raises(PronoteAuthRotationError) as exc_info:
client._connect()
message = str(exc_info.value)
assert sentinel_token not in message
assert sentinel_pin not in message
assert sentinel_jeton not in message
assert caplog.text
assert sentinel_token not in caplog.text
assert sentinel_pin not in caplog.text
assert sentinel_jeton not in caplog.text
# Ensure trailing newline

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)