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>
1457 lines
48 KiB
Python
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
|