Files
college-infos/tests/integration/test_pipeline_runner.py
Antoine Van Elstraete 0363898669 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>
2026-09-08 23:15:06 +02:00

1457 lines
48 KiB
Python

"""Integration tests for the M11 pipeline orchestration."""
from __future__ import annotations
from datetime import date, datetime
from typing import Any, cast
import pytest
from pydantic import SecretStr
from pronote_sync.config.settings import AISettings, AppSettings, PronoteSettings, Settings
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
class StubFetcher:
"""Pronote fetcher returning deterministic data and recording its calls."""
def __init__(self, calls: list[str], lesson: Lesson, homework: Homework) -> None:
"""Store the dependencies required by the stub.
:param calls: Shared call-order recorder.
:param lesson: Lesson returned by the agenda method.
:param homework: Homework returned by the homework method.
"""
self._calls = calls
self._lesson = lesson
self._homework = homework
def fetch_agenda(self) -> tuple[list[Lesson], list[SchoolEvent]]:
"""Return one lesson for the target date.
:return: The lesson and no school event.
:rtype: tuple[list[Lesson], list[SchoolEvent]]
"""
self._calls.append("fetch")
return [self._lesson], []
def fetch_homework(self, target_date: date) -> list[Homework]:
"""Return the configured homework.
:param target_date: Requested due date.
:return: The configured homework.
:rtype: list[Homework]
"""
self._calls.append("fetch_homework")
assert target_date == self._lesson.start.date()
return [self._homework]
def fetch_messages(self) -> list[Message]:
"""Return no Pronote messages.
:return: An empty list.
:rtype: list[Message]
"""
self._calls.append("fetch_messages")
return []
def fetch_informations(self) -> list[Message]:
"""Return no Pronote information messages.
:return: An empty list.
:rtype: list[Message]
"""
self._calls.append("fetch_informations")
return []
class StubChannel:
"""Notification channel recording its send attempts."""
def __init__(self, calls: list[str]) -> None:
"""Store the shared call-order recorder.
:param calls: Shared call-order recorder.
"""
self._calls = calls
self.messages: list[Any] = []
def send(self, message: Any) -> bool:
"""Record an outgoing message.
:param message: Message produced by the runner.
:return: Always ``True``.
:rtype: bool
"""
self.messages.append(message)
self._calls.append("send")
return True
class StubComparator:
"""Agenda comparator recording comparison calls."""
def __init__(self, calls: list[str]) -> None:
"""Store the shared call-order recorder.
:param calls: Shared call-order recorder.
"""
self._calls = calls
def compare(self, lessons: list[Lesson], target_date: date) -> AgendaDiff:
"""Return an empty diff after recording the comparison.
:param lessons: Normalized lessons.
:param target_date: Target digest date.
:return: Empty agenda diff.
:rtype: AgendaDiff
"""
self._calls.append("compare")
return AgendaDiff(target_date=target_date)
@pytest.fixture
def pipeline_inputs() -> tuple[Lesson, Homework]:
"""Return deterministic Pronote data targeting 2026-09-09.
:return: A lesson and homework pair.
:rtype: tuple[Lesson, Homework]
"""
lesson = Lesson(
id="lesson-1",
start=datetime(2026, 9, 9, 8, 0),
end=datetime(2026, 9, 9, 9, 0),
subject="Maths",
group=None,
status=LessonStatus.NORMAL,
content=None,
)
homework = Homework(
id="homework-1",
subject="Maths",
assigned_on=None,
due_on=date(2026, 9, 9),
text="Exercise 1",
)
return lesson, homework
def successful_sync_result() -> CalDAVSyncResult:
"""Construit un résultat CalDAV de succès compatible avec mypy.
:return: Résultat de synchronisation sans changement.
:rtype: CalDAVSyncResult
"""
return CalDAVSyncResult(status=CalDAVSyncStatus.SUCCESS, added=0, updated=0, removed=0)
def _record_ical_fetch(calls: list[str], url: str) -> str:
"""Enregistre un téléchargement iCal simulé.
:param calls: Liste partagée des téléchargements.
:param url: URL iCal reçue par la source.
:return: Contenu iCal minimal simulé.
:rtype: str
"""
calls.append(url)
return "BEGIN:VCALENDAR"
def test_runner_executes_steps_in_contractual_order(
pipeline_inputs: tuple[Lesson, Homework],
) -> None:
"""The runner executes fetch, blog, comparison, CalDAV, synthesis, then XMPP."""
lesson, homework = pipeline_inputs
calls: list[str] = []
def synchronize(data: Any, settings: Settings) -> CalDAVSyncResult:
"""Record the CalDAV step.
:param data: Normalized Pronote data.
:param settings: Effective settings.
:return: Successful result.
:rtype: CalDAVSyncResult
"""
del data, settings
calls.append("caldav_sync")
return successful_sync_result()
class RaisingSynthesisProvider:
"""Synthesis provider used solely to record the optional stage."""
def generate(self, input_data: Any) -> None:
"""Record synthesis and return no result.
:param input_data: Synthesis input.
:return: No synthesis.
:rtype: None
"""
del input_data
calls.append("synthesis")
return None
runner = PipelineRunner(
settings=Settings(),
pronote_fetcher=StubFetcher(calls, lesson, homework),
caldav_synchronizer=synchronize,
agenda_comparator=cast("AgendaComparator", StubComparator(calls)),
synthesis_provider=RaisingSynthesisProvider(),
channel=StubChannel(calls),
now_provider=lambda: datetime(2026, 9, 8, 7, 0),
)
data, errors = runner.run()
assert data is not None
assert errors == []
assert calls == [
"fetch",
"fetch_homework",
"fetch_messages",
"fetch_informations",
"compare",
"caldav_sync",
"synthesis",
"send",
]
def test_runner_continues_to_xmpp_when_synthesis_fails(
pipeline_inputs: tuple[Lesson, Homework],
) -> None:
"""A non-critical synthesis exception produces a warning and still sends XMPP."""
lesson, homework = pipeline_inputs
calls: list[str] = []
class FailingSynthesisProvider:
"""Synthesis provider raising a non-critical error."""
def generate(self, input_data: Any) -> None:
"""Raise a deterministic optional-stage failure.
:param input_data: Synthesis input.
:raises RuntimeError: Always.
"""
del input_data
raise RuntimeError("synthetic AI failure")
runner = PipelineRunner(
settings=Settings(),
pronote_fetcher=StubFetcher(calls, lesson, homework),
caldav_synchronizer=lambda data, settings: successful_sync_result(),
synthesis_provider=FailingSynthesisProvider(),
channel=StubChannel(calls),
now_provider=lambda: datetime(2026, 9, 8, 7, 0),
)
data, errors = runner.run()
assert data is not None
assert calls[-1] == "send"
assert len(errors) == 1
assert isinstance(errors[0], PipelineWarning)
assert errors[0].step == "synthesis"
def test_runner_dry_run_skips_caldav_and_xmpp_writes(
pipeline_inputs: tuple[Lesson, Homework],
) -> None:
"""Dry-run bypasses both mutable destination boundaries."""
lesson, homework = pipeline_inputs
calls: list[str] = []
def dry_run_caldav(data: Any, settings: Settings) -> CalDAVSyncResult:
"""Verify that the CalDAV boundary receives dry-run settings.
:param data: Normalized Pronote data.
:param settings: Effective settings.
:return: A skipped result.
:rtype: CalDAVSyncResult
"""
del data
calls.append("caldav_sync")
assert settings.app.dry_run is True
return CalDAVSyncResult(status=CalDAVSyncStatus.SKIPPED, added=0, updated=0, removed=0)
runner = PipelineRunner(
settings=Settings(app=AppSettings(dry_run=True)),
pronote_fetcher=StubFetcher(calls, lesson, homework),
caldav_synchronizer=dry_run_caldav,
channel=StubChannel(calls),
now_provider=lambda: datetime(2026, 9, 8, 7, 0),
)
data, errors = runner.run()
assert data is not None
assert errors == []
assert "caldav_sync" in calls
assert "send" not in calls
def test_runner_without_theoretical_agenda_produces_an_empty_diff(
pipeline_inputs: tuple[Lesson, Homework],
) -> None:
"""A disabled theoretical agenda reaches XMPP with no agenda changes."""
lesson, homework = pipeline_inputs
calls: list[str] = []
channel = StubChannel(calls)
runner = PipelineRunner(
settings=Settings(app=AppSettings(theoretical_agenda_path=None)),
pronote_fetcher=StubFetcher(calls, lesson, homework),
caldav_synchronizer=lambda data, settings: successful_sync_result(),
channel=channel,
now_provider=lambda: datetime(2026, 9, 8, 7, 0),
)
data, errors = runner.run()
assert data is not None
assert errors == []
assert len(channel.messages) == 1
assert channel.messages[0].changes == ()
def test_runner_reports_critical_error_when_no_pronote_source_is_available() -> None:
"""No configured Pronote source returns an explicit critical pipeline error."""
settings = Settings(pronote=PronoteSettings())
runner = PipelineRunner(
settings=settings,
pronote_fetcher=PronoteFetcher(settings, object()), # type: ignore[arg-type]
)
data, errors = runner.run()
assert data is None
assert len(errors) == 1
assert isinstance(errors[0], PipelineCriticalError)
assert errors[0].step is None
assert "ni la source iCal ni pronotepy" in errors[0].message
def test_from_settings_without_theoretical_agenda_does_not_instantiate_comparator(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Missing theoretical configuration keeps comparison disabled without constructing it."""
import pronote_sync.pipeline.run as run_module
def forbidden_comparator(provider: object) -> None:
"""Fail if comparison is constructed without configuration.
:param provider: The provider unexpectedly supplied.
:raises AssertionError: Always.
"""
del provider
raise AssertionError("AgendaComparator must not be instantiated")
monkeypatch.setattr(run_module, "AgendaComparator", forbidden_comparator)
runner = PipelineRunner.from_settings(Settings(app=AppSettings(theoretical_agenda_path=None)))
assert runner._agenda_comparator is None
def test_from_settings_with_theoretical_agenda_instantiates_comparator(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Configured theoretical agenda constructs the comparator with its provider."""
import pronote_sync.pipeline.run as run_module
provider = object()
constructed_with: list[object] = []
class RecordingComparator:
"""Comparator constructor recording the supplied provider."""
def __init__(self, received_provider: object) -> None:
"""Record the provider used by the composition root.
:param received_provider: The constructed theoretical provider.
"""
constructed_with.append(received_provider)
monkeypatch.setattr(run_module, "get_theoretical_provider", lambda *args: provider)
monkeypatch.setattr(run_module, "AgendaComparator", RecordingComparator)
runner = PipelineRunner.from_settings(
Settings(app=AppSettings(theoretical_agenda_path="/agenda.json"))
)
assert constructed_with == [provider]
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],
) -> None:
"""An iCal agenda/homework selection downloads and parses once per runner run."""
import pronote_sync.sources.pronote.fallback as fallback_module
lesson, _ = pipeline_inputs
settings = Settings(
pronote=PronoteSettings(
ical_url=SecretStr("https://pronote.example.test/calendar.ics"),
agenda_source="ical",
homework_source="ical",
)
)
fetch_calls: list[str] = []
monkeypatch.setattr(
fallback_module,
"fetch_ical",
lambda url: _record_ical_fetch(fetch_calls, url),
)
monkeypatch.setattr(fallback_module, "parse_ical", lambda raw: ([lesson], [], []))
class NoMessageClient:
"""Minimal pronotepy client for non-critical M11 fetches."""
def get_messages(self) -> list[Message]:
"""Return no messages.
:return: An empty list.
:rtype: list[Message]
"""
return []
def get_informations(self) -> list[Message]:
"""Return no information messages.
:return: An empty list.
:rtype: list[Message]
"""
return []
def get_lessons(self, start: date, end: date) -> list[Lesson]:
"""Ne retourne aucun cours de repli.
:param start: Début de la fenêtre.
:param end: Fin de la fenêtre.
:return: Liste vide.
:rtype: list[Lesson]
"""
del start, end
return []
def get_homeworks(self, start: date, end: date) -> list[Homework]:
"""Ne retourne aucun devoir de repli.
:param start: Début de la fenêtre.
:param end: Fin de la fenêtre.
:return: Liste vide.
:rtype: list[Homework]
"""
del start, end
return []
runner = PipelineRunner(
settings=settings,
pronote_fetcher=PronoteFetcher(settings, NoMessageClient()),
now_provider=lambda: datetime(2026, 9, 8, 7, 0),
)
data, errors = runner.run()
assert data is not None
assert errors == []
assert fetch_calls == ["https://pronote.example.test/calendar.ics"]
class FailingBlogClient:
"""Blog client raising an exception to test error handling."""
def fetch_and_parse(
self,
*,
known_guids: frozenset[str] | None = None,
etag: str | None = None,
last_modified: str | None = None,
) -> Any:
"""Raise an exception to simulate blog fetch failure.
:param known_guids: Known GUIDs (unused).
:param etag: ETag header (unused).
:param last_modified: Last-Modified header (unused).
:return: Never returns.
:raises RuntimeError: Always.
"""
del known_guids, etag, last_modified
raise RuntimeError("Blog fetch failed: network error")
class FailingComparator:
"""Comparator raising an exception to test error handling."""
def compare(self, lessons: list[Lesson], target_date: date) -> AgendaDiff:
"""Raise an exception to simulate comparison failure.
:param lessons: Lessons to compare (unused).
:param target_date: Target date (unused).
:return: Never returns.
:raises RuntimeError: Always.
"""
del lessons, target_date
raise RuntimeError("Comparison failed: invalid data")
class FailingCaldavSynchronizer:
"""CalDAV synchronizer raising an exception to test error handling."""
def __call__(self, data: Any, settings: Settings) -> CalDAVSyncResult:
"""Raise an exception to simulate CalDAV sync failure.
:param data: Data to sync (unused).
:param settings: Settings (unused).
:return: Never returns.
:raises RuntimeError: Always.
"""
del data, settings
raise RuntimeError("CalDAV sync failed: connection error")
class FailingChannel:
"""Channel that returns False to test error handling."""
def __init__(self) -> None:
"""Initialize the failing channel."""
self.messages: list[Any] = []
def send(self, message: Any) -> bool:
"""Return False to simulate refused send.
:param message: Message to send (unused).
:return: Always False.
:rtype: bool
"""
self.messages.append(message)
return False
class ExceptionalChannel:
"""Channel that raises an exception to test error handling."""
def __init__(self) -> None:
"""Initialize the exceptional channel."""
self.messages: list[Any] = []
def send(self, message: Any) -> bool:
"""Raise an exception to simulate send failure.
:param message: Message to send (unused).
:return: Never returns.
:raises RuntimeError: Always.
"""
self.messages.append(message)
raise RuntimeError("XMPP send failed: connection closed")
class CriticalChannel:
"""Channel that raises PipelineCriticalError to test propagation."""
def send(self, message: Any) -> bool:
"""Raise PipelineCriticalError to test propagation.
:param message: Message to send (unused).
:return: Never returns.
:raises PipelineCriticalError: Always.
"""
del message
raise PipelineCriticalError("Critical send failure", step="send")
class EmptyFetcher:
"""Pronote fetcher returning empty agenda and homework."""
def fetch_agenda(self) -> tuple[list[Lesson], list[SchoolEvent]]:
"""Return empty agenda.
:return: Empty lessons and events.
:rtype: tuple[list[Lesson], list[SchoolEvent]]
"""
return [], []
def fetch_homework(self, target_date: date) -> list[Homework]:
"""Return empty homework.
:param target_date: Target date (unused).
:return: Empty homework list.
:rtype: list[Homework]
"""
del target_date
return []
def fetch_messages(self) -> list[Message]:
"""Return no messages.
:return: Empty list.
:rtype: list[Message]
"""
return []
def fetch_informations(self) -> list[Message]:
"""Return no information messages.
:return: Empty list.
:rtype: list[Message]
"""
return []
@pytest.fixture
def caldav_failed_result() -> CalDAVSyncResult:
"""Fixture providing a failed CalDAV sync result.
:return: Failed sync result with errors.
:rtype: CalDAVSyncResult
"""
return CalDAVSyncResult(
status=CalDAVSyncStatus.FAILED,
added=0,
updated=0,
removed=0,
errors=["Error 1: invalid credentials", "Error 2: server unavailable"],
)
def test_runner_blog_failure_produces_pipeline_warning(
pipeline_inputs: tuple[Lesson, Homework],
) -> None:
"""Blog failure produces PipelineWarning and continues with empty blog_articles."""
lesson, homework = pipeline_inputs
calls: list[str] = []
runner = PipelineRunner(
settings=Settings(blog=Settings().blog.model_copy(update={"enabled": True})),
pronote_fetcher=StubFetcher(calls, lesson, homework),
caldav_synchronizer=lambda data, settings: successful_sync_result(),
agenda_comparator=cast("AgendaComparator | None", StubComparator(calls)),
blog_client=cast("BlogRSSClient | None", FailingBlogClient()),
blog_state=BlogRSSState(),
channel=StubChannel(calls),
now_provider=lambda: datetime(2026, 9, 8, 7, 0),
)
data, errors = runner.run()
assert data is not None
assert len(errors) == 1
assert isinstance(errors[0], PipelineWarning)
assert errors[0].step == "fetch_blog"
assert "Blog fetch failed" in errors[0].message
# Verify pipeline continued to next steps
assert len(calls) >= 5 # fetch, fetch_homework, fetch_messages, fetch_informations, compare
def test_runner_compare_failure_produces_pipeline_warning(
pipeline_inputs: tuple[Lesson, Homework],
) -> None:
"""Compare failure produces PipelineWarning and continues with empty AgendaDiff."""
lesson, homework = pipeline_inputs
calls: list[str] = []
runner = PipelineRunner(
settings=Settings(),
pronote_fetcher=StubFetcher(calls, lesson, homework),
caldav_synchronizer=lambda data, settings: successful_sync_result(),
agenda_comparator=cast("AgendaComparator | None", FailingComparator()),
channel=StubChannel(calls),
now_provider=lambda: datetime(2026, 9, 8, 7, 0),
)
data, errors = runner.run()
assert data is not None
assert len(errors) == 1
assert isinstance(errors[0], PipelineWarning)
assert errors[0].step == "compare"
assert "Comparison failed" in errors[0].message
# Pipeline continues to next steps even if compare fails
def test_runner_caldav_sync_exception_produces_pipeline_warning(
pipeline_inputs: tuple[Lesson, Homework],
) -> None:
"""CalDAV synchronizer raises exception → PipelineWarning with step='caldav_sync'."""
lesson, homework = pipeline_inputs
calls: list[str] = []
runner = PipelineRunner(
settings=Settings(),
pronote_fetcher=StubFetcher(calls, lesson, homework),
caldav_synchronizer=FailingCaldavSynchronizer(),
agenda_comparator=cast("AgendaComparator | None", StubComparator(calls)),
channel=StubChannel(calls),
now_provider=lambda: datetime(2026, 9, 8, 7, 0),
)
data, errors = runner.run()
assert data is not None
assert len(errors) == 1
assert isinstance(errors[0], PipelineWarning)
assert errors[0].step == "caldav_sync"
assert "CalDAV sync failed" in errors[0].message
# Pipeline continues to next steps even if caldav_sync fails
def test_runner_caldav_sync_failed_status_produces_pipeline_warning(
pipeline_inputs: tuple[Lesson, Homework],
caldav_failed_result: CalDAVSyncResult,
) -> None:
"""CalDAV sync result has status=FAILED → PipelineWarning with redacted errors."""
lesson, homework = pipeline_inputs
calls: list[str] = []
def failing_sync_result(data: Any, settings: Settings) -> CalDAVSyncResult:
"""Return a failed sync result.
:param data: Data to sync (unused).
:param settings: Settings (unused).
:return: Failed sync result.
:rtype: CalDAVSyncResult
"""
del data, settings
return caldav_failed_result
runner = PipelineRunner(
settings=Settings(),
pronote_fetcher=StubFetcher(calls, lesson, homework),
caldav_synchronizer=failing_sync_result,
agenda_comparator=cast("AgendaComparator | None", StubComparator(calls)),
channel=StubChannel(calls),
now_provider=lambda: datetime(2026, 9, 8, 7, 0),
)
data, errors = runner.run()
assert data is not None
assert len(errors) == 1
assert isinstance(errors[0], PipelineWarning)
assert errors[0].step == "caldav_sync"
# Verify errors are redacted (should not contain plain secret if present)
assert "Error 1" in errors[0].message or "Error 2" in errors[0].message
# Pipeline continues to next steps even if caldav_sync fails
def test_runner_channel_send_returns_false_produces_pipeline_warning(
pipeline_inputs: tuple[Lesson, Homework],
) -> None:
"""Channel.send() returns False → PipelineWarning with step='send' and 'refusé'."""
lesson, homework = pipeline_inputs
calls: list[str] = []
runner = PipelineRunner(
settings=Settings(),
pronote_fetcher=StubFetcher(calls, lesson, homework),
caldav_synchronizer=lambda data, settings: successful_sync_result(),
agenda_comparator=cast("AgendaComparator | None", StubComparator(calls)),
channel=FailingChannel(),
now_provider=lambda: datetime(2026, 9, 8, 7, 0),
)
data, errors = runner.run()
assert data is not None
assert len(errors) == 1
assert isinstance(errors[0], PipelineWarning)
assert errors[0].step == "send"
assert "refusé" in errors[0].message
def test_runner_channel_send_raises_exception_produces_pipeline_warning(
pipeline_inputs: tuple[Lesson, Homework],
) -> None:
"""Channel.send() raises exception → PipelineWarning with step='send'."""
lesson, homework = pipeline_inputs
calls: list[str] = []
runner = PipelineRunner(
settings=Settings(),
pronote_fetcher=StubFetcher(calls, lesson, homework),
caldav_synchronizer=lambda data, settings: successful_sync_result(),
agenda_comparator=cast("AgendaComparator | None", StubComparator(calls)),
channel=ExceptionalChannel(),
now_provider=lambda: datetime(2026, 9, 8, 7, 0),
)
data, errors = runner.run()
assert data is not None
assert len(errors) == 1
assert isinstance(errors[0], PipelineWarning)
assert errors[0].step == "send"
assert "XMPP send failed" in errors[0].message
def test_runner_pipeline_critical_error_from_non_blocking_step_propagates(
pipeline_inputs: tuple[Lesson, Homework],
) -> None:
"""PipelineCriticalError from non-blocking step propagates and stops pipeline."""
lesson, homework = pipeline_inputs
calls: list[str] = []
runner = PipelineRunner(
settings=Settings(),
pronote_fetcher=StubFetcher(calls, lesson, homework),
caldav_synchronizer=lambda data, settings: successful_sync_result(),
agenda_comparator=cast("AgendaComparator | None", StubComparator(calls)),
channel=CriticalChannel(),
now_provider=lambda: datetime(2026, 9, 8, 7, 0),
)
data, errors = runner.run()
# Pipeline should return None data and critical error in errors list
assert data is None
assert len(errors) == 1
assert isinstance(errors[0], PipelineCriticalError)
assert errors[0].step == "send"
assert "Critical send failure" in errors[0].message
def test_runner_blog_success_delivers_articles_into_xmpp_message_external_info(
pipeline_inputs: tuple[Lesson, Homework],
) -> None:
"""Blog client returns articles → they appear in XmppMessage.external_info.blog_articles."""
lesson, homework = pipeline_inputs
calls: list[str] = []
class SuccessBlogClient:
"""Blog client returning BlogRSSFetchResult with articles."""
def fetch_and_parse(
self,
*,
known_guids: frozenset[str] | None = None,
etag: str | None = None,
last_modified: str | None = None,
) -> BlogRSSFetchResult:
"""Return BlogRSSFetchResult with articles.
:param known_guids: Known GUIDs (unused).
:param etag: ETag header (unused).
:param last_modified: Last-Modified header (unused).
:return: BlogRSSFetchResult with articles.
:rtype: BlogRSSFetchResult
"""
del known_guids, etag, last_modified
article = BlogArticle(
id="article-1",
title="Test Article",
url="https://example.com/article1",
published_at=datetime(2026, 9, 8, 12, 0, tzinfo=datetime.now().astimezone().tzinfo),
updated_at=None,
category="News",
author="Test Author",
content_html="<p>Test content</p>",
content_text="Test content",
)
return BlogRSSFetchResult(
articles=(article,),
etag=None,
last_modified=None,
not_modified=False,
)
runner = PipelineRunner(
settings=Settings(blog=Settings().blog.model_copy(update={"enabled": True})),
pronote_fetcher=StubFetcher(calls, lesson, homework),
caldav_synchronizer=lambda data, settings: successful_sync_result(),
agenda_comparator=cast("AgendaComparator | None", StubComparator(calls)),
blog_client=cast("BlogRSSClient | None", SuccessBlogClient()),
blog_state=BlogRSSState(),
channel=StubChannel(calls),
now_provider=lambda: datetime(2026, 9, 8, 7, 0),
)
data, errors = runner.run()
assert data is not None
assert len(errors) == 0
# Check that channel received message with external_info containing blog_articles
channel = runner._channel
assert channel is not None
assert hasattr(channel, "messages")
assert len(channel.messages) == 1
xmpp_message = channel.messages[0]
assert xmpp_message.external_info is not None
assert len(xmpp_message.external_info.blog_articles) == 1
assert xmpp_message.external_info.blog_articles[0].title == "Test Article"
def test_runner_secret_redaction_in_pipeline_errors(
pipeline_inputs: tuple[Lesson, Homework],
) -> None:
"""A configured AI key in a non-URL exception is masked by the runner.
The fake blog client embeds the raw API key in plain text, without any
URL or ``key=value`` form: only the propagation of
``Settings.redaction_secrets()`` into ``PipelineRunner._redact()`` can
mask it. No network call is performed.
"""
lesson, homework = pipeline_inputs
calls: list[str] = []
secret = SecretStr("sk-test-sentinel-12345")
class SecretLeakingBlogClient:
"""Blog client raising an exception containing a raw configured secret."""
def fetch_and_parse(
self,
*,
known_guids: frozenset[str] | None = None,
etag: str | None = None,
last_modified: str | None = None,
) -> Any:
"""Raise an exception embedding the raw API key.
:param known_guids: Known GUIDs (unused).
:param etag: ETag header (unused).
:param last_modified: Last-Modified header (unused).
:return: Never returns.
:raises RuntimeError: Always.
"""
del known_guids, etag, last_modified
raise RuntimeError(f"API key {secret.get_secret_value()} rejected")
runner = PipelineRunner(
settings=Settings(
ai=AISettings(api_key=secret),
blog=Settings().blog.model_copy(update={"enabled": True}),
),
pronote_fetcher=StubFetcher(calls, lesson, homework),
caldav_synchronizer=lambda data, settings: successful_sync_result(),
agenda_comparator=cast("AgendaComparator | None", StubComparator(calls)),
blog_client=cast("BlogRSSClient | None", SecretLeakingBlogClient()),
blog_state=BlogRSSState(),
channel=StubChannel(calls),
now_provider=lambda: datetime(2026, 9, 8, 7, 0),
)
data, errors = runner.run()
assert data is not None
assert len(errors) == 1
assert isinstance(errors[0], PipelineWarning)
assert errors[0].step == "fetch_blog"
# The configured AI key must be masked through _redact(), not by a
# generic URL pattern: the message contains no URL nor key=value form.
assert secret.get_secret_value() not in errors[0].message
assert "REDACTED" in errors[0].message
def test_runner_empty_agenda_homework_accepted_as_success() -> None:
"""Empty agenda and homework lists → pipeline succeeds (no critical error)."""
calls: list[str] = []
runner = PipelineRunner(
settings=Settings(),
pronote_fetcher=EmptyFetcher(),
caldav_synchronizer=lambda data, settings: successful_sync_result(),
channel=StubChannel(calls),
now_provider=lambda: datetime(2026, 9, 8, 7, 0),
)
data, errors = runner.run()
assert data is not None
assert len(errors) == 0
assert data.lessons == []
assert data.homeworks == []
def test_runner_ical_cache_cleanup_on_second_run(
monkeypatch: pytest.MonkeyPatch,
pipeline_inputs: tuple[Lesson, Homework],
) -> None:
"""Two independent PipelineRunner.run() calls each trigger their own iCal download."""
import pronote_sync.sources.pronote.fallback as fallback_module
lesson, _ = pipeline_inputs
settings = Settings(
pronote=PronoteSettings(
ical_url=SecretStr("https://pronote.example.test/calendar.ics"),
agenda_source="ical",
homework_source="ical",
)
)
fetch_calls: list[str] = []
monkeypatch.setattr(
fallback_module,
"fetch_ical",
lambda url: _record_ical_fetch(fetch_calls, url),
)
monkeypatch.setattr(fallback_module, "parse_ical", lambda raw: ([lesson], [], []))
class NoMessageClient:
"""Minimal pronotepy client for non-critical M11 fetches."""
def get_messages(self) -> list[Message]:
"""Return no messages.
:return: An empty list.
:rtype: list[Message]
"""
return []
def get_informations(self) -> list[Message]:
"""Return no information messages.
:return: An empty list.
:rtype: list[Message]
"""
return []
def get_lessons(self, start: date, end: date) -> list[Lesson]:
"""Return no fallback lessons.
:param start: Start date (unused).
:param end: End date (unused).
:return: Empty list.
:rtype: list[Lesson]
"""
del start, end
return []
def get_homeworks(self, start: date, end: date) -> list[Homework]:
"""Return no fallback homework.
:param start: Start date (unused).
:param end: End date (unused).
:return: Empty list.
:rtype: list[Homework]
"""
del start, end
return []
# First run
runner1 = PipelineRunner(
settings=settings,
pronote_fetcher=PronoteFetcher(settings, NoMessageClient()),
now_provider=lambda: datetime(2026, 9, 8, 7, 0),
)
data1, errors1 = runner1.run()
# Second run
runner2 = PipelineRunner(
settings=settings,
pronote_fetcher=PronoteFetcher(settings, NoMessageClient()),
now_provider=lambda: datetime(2026, 9, 8, 7, 0),
)
data2, errors2 = runner2.run()
assert data1 is not None
assert data2 is not None
assert errors1 == []
assert errors2 == []
# Each run should have triggered its own iCal download
assert fetch_calls == [
"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