From 642bbf16c11b1a7295972840bf78c58f43546d0d Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 10 Sep 2026 23:57:50 +0200 Subject: [PATCH 1/4] fix: prevent source state writes in dry-run --- pronote_sync/sources/blog/state.py | 13 +++- pronote_sync/sources/pronote/auth_state.py | 30 +++++++- tests/unit/test_blog_state.py | 43 +++++++++++ tests/unit/test_pronote_auth_state.py | 84 ++++++++++++++++++++++ 4 files changed, 167 insertions(+), 3 deletions(-) diff --git a/pronote_sync/sources/blog/state.py b/pronote_sync/sources/blog/state.py index a5907e8..6e5de47 100644 --- a/pronote_sync/sources/blog/state.py +++ b/pronote_sync/sources/blog/state.py @@ -37,15 +37,24 @@ class BlogRSSState: :param state_file: Chemin du fichier d'état JSON (``str`` ou :class:`~pathlib.Path`). ``".blog_rss_state.json"`` par défaut. + :param persistence_enabled: Si ``False``, charge l'état existant mais ne + modifie jamais le fichier d'état. ``True`` par défaut. """ - def __init__(self, state_file: Path | str = ".blog_rss_state.json") -> None: + def __init__( + self, + state_file: Path | str = ".blog_rss_state.json", + persistence_enabled: bool = True, + ) -> None: """Initialise le gestionnaire d'état depuis le fichier JSON. :param state_file: Chemin du fichier d'état JSON (``str`` ou :class:`~pathlib.Path`). ``".blog_rss_state.json"`` par défaut. + :param persistence_enabled: Si ``False``, charge l'état existant mais + désactive toutes les écritures sur disque. ``True`` par défaut. """ self._state_file = Path(state_file) + self._persistence_enabled = persistence_enabled self._known_guids: set[str] = set() self._etag: str | None = None self._last_modified: str | None = None @@ -98,6 +107,8 @@ class BlogRSSState: d'erreur d'écriture, une erreur est journalisée sans être propagée et le fichier temporaire est supprimé. """ + if not self._persistence_enabled: + return payload = { "version": _STATE_VERSION, "known_guids": sorted(self._known_guids), diff --git a/pronote_sync/sources/pronote/auth_state.py b/pronote_sync/sources/pronote/auth_state.py index 99153d1..7c3985c 100644 --- a/pronote_sync/sources/pronote/auth_state.py +++ b/pronote_sync/sources/pronote/auth_state.py @@ -41,9 +41,15 @@ class PronoteAuthState: :param state_file: Chemin du fichier d'état JSON (``str`` ou :class:`~pathlib.Path`). ``".pronote_auth_state.json"`` par défaut. + :param persistence_enabled: Si ``False``, charge l'état existant mais ne + modifie jamais le fichier d'état. ``True`` par défaut. """ - def __init__(self, state_file: Path | str = ".pronote_auth_state.json") -> None: + def __init__( + self, + state_file: Path | str = ".pronote_auth_state.json", + persistence_enabled: bool = True, + ) -> None: """Initialise le gestionnaire d'état d'authentification Pronote. Le fichier d'état n'est pas créé à l'initialisation : il n'est écrit @@ -51,8 +57,14 @@ class PronoteAuthState: :param state_file: Chemin du fichier d'état JSON (``str`` ou :class:`~pathlib.Path`). ``".pronote_auth_state.json"`` par défaut. + :param persistence_enabled: Si ``False``, charge l'état existant mais + désactive toutes les écritures ou suppressions sur disque. ``True`` + par défaut. """ self._state_file = Path(state_file) + self._persistence_enabled = persistence_enabled + self._in_memory_credentials: dict[str, str] | None = None + self._in_memory_state_cleared = False def load(self) -> dict[str, str] | None: """Charge les credentials d'authentification depuis le fichier d'état. @@ -68,6 +80,10 @@ class PronoteAuthState: aucun état valide n'est disponible. :rtype: dict[str, str] | None """ + if self._in_memory_state_cleared: + return None + if self._in_memory_credentials is not None: + return self._in_memory_credentials.copy() if not self._state_file.exists(): logger.debug( "Fichier d'état d'authentification Pronote %s absent, aucun token à charger.", @@ -192,6 +208,10 @@ class PronoteAuthState: :raises PronoteSyncError: Si l'écriture ou le remplacement du fichier échoue. """ + if not self._persistence_enabled: + self._in_memory_credentials = credentials.copy() + self._in_memory_state_cleared = False + return payload: dict[str, Any] = { "version": _STATE_VERSION, "credentials": credentials, @@ -248,10 +268,16 @@ class PronoteAuthState: """Supprime le fichier d'état d'authentification. Si le fichier n'existe pas, la méthode ne fait rien et aucune erreur - n'est levée. + n'est levée. Lorsque la persistance est désactivée, elle efface + uniquement les credentials conservés en mémoire et ne modifie jamais + le fichier d'état. :raises OSError: Si la suppression du fichier existant échoue. """ + self._in_memory_credentials = None + if not self._persistence_enabled: + self._in_memory_state_cleared = True + return if not self._state_file.exists(): return logger.debug( diff --git a/tests/unit/test_blog_state.py b/tests/unit/test_blog_state.py index 2c3a3e7..4d2bc57 100644 --- a/tests/unit/test_blog_state.py +++ b/tests/unit/test_blog_state.py @@ -35,6 +35,49 @@ def test_state_file_absent_empty_state(tmp_path: Path) -> None: assert state.get_cache_headers() == (None, None) +def test_disabled_persistence_keeps_updates_in_memory_without_creating_file(tmp_path: Path) -> None: + """Vérifie que la persistance désactivée conserve l'état uniquement en mémoire. + + :param tmp_path: Fixture pytest pour un répertoire temporaire. + :return: None + """ + state_file = tmp_path / "state.json" + state = BlogRSSState(state_file, persistence_enabled=False) + + state.add_guids(["guid-1"]) + state.update_cache_headers("etag-123", "Wed, 01 Sep 2026 GMT") + + assert state.get_known_guids() == frozenset({"guid-1"}) + assert state.get_cache_headers() == ("etag-123", "Wed, 01 Sep 2026 GMT") + assert not state_file.exists() + + +def test_disabled_persistence_preserves_existing_file(tmp_path: Path) -> None: + """Vérifie que la persistance désactivée ne modifie pas l'état déjà stocké. + + :param tmp_path: Fixture pytest pour un répertoire temporaire. + :return: None + """ + state_file = tmp_path / "state.json" + original_content = json.dumps( + { + "version": 1, + "known_guids": ["existing-guid"], + "etag": "old-etag", + "last_modified": "Tue, 31 Aug 2026 GMT", + } + ) + state_file.write_text(original_content, encoding="utf-8") + state = BlogRSSState(state_file, persistence_enabled=False) + + state.add_guids(["new-guid"]) + state.update_cache_headers("new-etag", "Wed, 01 Sep 2026 GMT") + + assert state.get_known_guids() == frozenset({"existing-guid", "new-guid"}) + assert state.get_cache_headers() == ("new-etag", "Wed, 01 Sep 2026 GMT") + assert state_file.read_text(encoding="utf-8") == original_content + + def test_add_guids_persists(tmp_path: Path) -> None: """Vérifie que l'ajout de GUID persiste dans le fichier JSON. diff --git a/tests/unit/test_pronote_auth_state.py b/tests/unit/test_pronote_auth_state.py index 6fa95d3..9a1d422 100644 --- a/tests/unit/test_pronote_auth_state.py +++ b/tests/unit/test_pronote_auth_state.py @@ -37,6 +37,90 @@ def test_load_no_file_returns_none(tmp_path: Path) -> None: assert state.load() is None +def test_disabled_persistence_keeps_credentials_in_memory_without_creating_file( + tmp_path: Path, +) -> None: + """Vérifie que la persistance désactivée conserve les credentials en mémoire. + + :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, persistence_enabled=False) + + state.save(credentials) + + assert state.load() == credentials + assert not state_file.exists() + + +def test_disabled_persistence_preserves_existing_file(tmp_path: Path) -> None: + """Vérifie que la persistance désactivée garde les nouveaux credentials en mémoire. + + :param tmp_path: Fixture pytest pour un répertoire temporaire. + :return: None + """ + state_file = tmp_path / "auth.json" + original_credentials = { + "pronote_url": "https://example.com/pronote", + "username": "parent-1", + "password": "old-token", # pragma: allowlist secret + "uuid": "old-uuid", + } + original_content = json.dumps({"version": 1, "credentials": original_credentials}).encode() + new_credentials = { + "pronote_url": "https://example.com/pronote", + "username": "parent-1", + "password": "new-token", # pragma: allowlist secret + "uuid": "new-uuid", + } + state_file.write_bytes(original_content) + state = PronoteAuthState(state_file, persistence_enabled=False) + + assert state.load() == original_credentials + + state.save(new_credentials) + + assert state.load() == new_credentials + assert state_file.read_bytes() == original_content + + +def test_disabled_persistence_clear_discards_in_memory_credentials_only(tmp_path: Path) -> None: + """Vérifie que clear oublie l'état en mémoire sans modifier le fichier existant. + + :param tmp_path: Fixture pytest pour un répertoire temporaire. + :return: None + """ + state_file = tmp_path / "auth.json" + original_credentials = { + "pronote_url": "https://example.com/pronote", + "username": "parent-1", + "password": "old-token", # pragma: allowlist secret + "uuid": "old-uuid", + } + original_content = json.dumps({"version": 1, "credentials": original_credentials}).encode() + credentials = { + "pronote_url": "https://example.com/pronote", + "username": "parent-1", + "password": "new-token", # pragma: allowlist secret + "uuid": "new-uuid", + } + state_file.write_bytes(original_content) + state = PronoteAuthState(state_file, persistence_enabled=False) + + state.save(credentials) + state.clear() + + assert state.load() is None + assert state_file.read_bytes() == original_content + + def test_save_then_load_roundtrip(tmp_path: Path) -> None: """Vérifie que des credentials sauvegardés sont rechargés à l'identique. From 145dace289e9b182ac33bb5b1da07de28a5b5295 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 11 Sep 2026 00:05:26 +0200 Subject: [PATCH 2/4] fix: wire dry-run source state policy --- pronote_sync/pipeline/run.py | 9 +++- tests/integration/test_pipeline_runner.py | 66 ++++++++++++++++++++++- 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/pronote_sync/pipeline/run.py b/pronote_sync/pipeline/run.py index 86bba04..4c873e0 100644 --- a/pronote_sync/pipeline/run.py +++ b/pronote_sync/pipeline/run.py @@ -126,6 +126,7 @@ class PipelineRunner: :rtype: PipelineRunner """ effective_dry_run = settings.app.dry_run if dry_run is None else dry_run + persistence_enabled = not effective_dry_run theoretical_provider = get_theoretical_provider( settings.app.theoretical_agenda_path, settings.app.school_holidays_path, @@ -136,7 +137,9 @@ class PipelineRunner: AgendaComparator(theoretical_provider) if theoretical_provider is not None else None ) blog_client = BlogRSSClient(settings.blog.rss_url) if settings.blog.enabled else None - blog_state = BlogRSSState() if settings.blog.enabled else None + blog_state = ( + BlogRSSState(persistence_enabled=persistence_enabled) if settings.blog.enabled else None + ) return cls( settings=settings, pronote_fetcher=PronoteFetcher( @@ -144,7 +147,9 @@ class PipelineRunner: PronoteClient( settings.pronote, auth_state=( - PronoteAuthState() if settings.pronote.auth_mode == "qr_token" else None + PronoteAuthState(persistence_enabled=persistence_enabled) + if settings.pronote.auth_mode == "qr_token" + else None ), ), ), diff --git a/tests/integration/test_pipeline_runner.py b/tests/integration/test_pipeline_runner.py index 85d77d2..1eb830b 100644 --- a/tests/integration/test_pipeline_runner.py +++ b/tests/integration/test_pipeline_runner.py @@ -8,7 +8,13 @@ from typing import Any, cast import pytest from pydantic import SecretStr -from pronote_sync.config.settings import AISettings, AppSettings, PronoteSettings, Settings +from pronote_sync.config.settings import ( + AISettings, + AppSettings, + BlogSettings, + 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 @@ -449,6 +455,64 @@ def test_from_settings_qr_token_mode_passes_auth_state_instance( assert isinstance(constructed[0][1], PronoteAuthState) +@pytest.mark.parametrize("dry_run", [False, True]) +def test_from_settings_configures_source_state_persistence_for_dry_run( + monkeypatch: pytest.MonkeyPatch, + dry_run: bool, +) -> None: + """Composition disables source-state persistence only in dry-run mode.""" + import pronote_sync.pipeline.run as run_module + + blog_persistence: list[bool] = [] + auth_persistence: list[bool] = [] + + class RecordingBlogState: + """Blog state factory recording its persistence configuration.""" + + def __init__(self, *, persistence_enabled: bool = True) -> None: + """Record the requested persistence setting. + + :param persistence_enabled: Whether disk writes are enabled. + """ + blog_persistence.append(persistence_enabled) + + class RecordingAuthState: + """Authentication state factory recording its persistence configuration.""" + + def __init__(self, *, persistence_enabled: bool = True) -> None: + """Record the requested persistence setting. + + :param persistence_enabled: Whether disk writes are enabled. + """ + auth_persistence.append(persistence_enabled) + + class RecordingClient: + """Pronote client constructor accepting the injected auth state.""" + + def __init__(self, settings: PronoteSettings, *, auth_state: object) -> None: + """Accept the composition-root dependencies. + + :param settings: Pronote settings. + :param auth_state: Injected authentication state. + """ + del settings, auth_state + + monkeypatch.setattr(run_module, "BlogRSSState", RecordingBlogState) + monkeypatch.setattr(run_module, "PronoteAuthState", RecordingAuthState) + monkeypatch.setattr(run_module, "PronoteClient", RecordingClient) + + PipelineRunner.from_settings( + Settings( + app=AppSettings(dry_run=dry_run), + blog=BlogSettings(enabled=True), + pronote=PronoteSettings(auth_mode="qr_token"), + ) + ) + + assert blog_persistence == [not dry_run] + assert auth_persistence == [not dry_run] + + def test_runner_reuses_ical_download_and_parse_within_one_run( monkeypatch: pytest.MonkeyPatch, pipeline_inputs: tuple[Lesson, Homework], From fde8fbe264bade09eee66b57e850445fcdf8b932 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 11 Sep 2026 00:13:11 +0200 Subject: [PATCH 3/4] fix: reject QR token dry-run --- pronote_sync/pipeline/run.py | 2 ++ tests/integration/test_pipeline_runner.py | 41 +++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/pronote_sync/pipeline/run.py b/pronote_sync/pipeline/run.py index 4c873e0..02fe79d 100644 --- a/pronote_sync/pipeline/run.py +++ b/pronote_sync/pipeline/run.py @@ -126,6 +126,8 @@ class PipelineRunner: :rtype: PipelineRunner """ effective_dry_run = settings.app.dry_run if dry_run is None else dry_run + if effective_dry_run and settings.pronote.auth_mode == "qr_token": + raise ValueError("Le mode qr_token n'est pas compatible avec le dry-run.") persistence_enabled = not effective_dry_run theoretical_provider = get_theoretical_provider( settings.app.theoretical_agenda_path, diff --git a/tests/integration/test_pipeline_runner.py b/tests/integration/test_pipeline_runner.py index 1eb830b..18a5e48 100644 --- a/tests/integration/test_pipeline_runner.py +++ b/tests/integration/test_pipeline_runner.py @@ -428,6 +428,47 @@ def test_from_settings_password_mode_passes_auth_state_none( assert constructed[0][1] is None +def test_from_settings_rejects_qr_token_dry_run_before_constructing_pronote_client( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """QR-token dry-run fails before authentication or data dependencies are created.""" + import pronote_sync.pipeline.run as run_module + + qr_pin_sentinel = "qr-pin-must-not-appear" + constructed: list[object] = [] + + class FailingClient: + """Pronote client sentinel that makes unexpected construction explicit.""" + + def __init__(self, settings: PronoteSettings, *, auth_state: object) -> None: + """Record and reject any unexpected client construction. + + :param settings: Pronote settings supplied by the composition root. + :param auth_state: Authentication state supplied by the composition root. + """ + del settings, auth_state + constructed.append(object()) + raise AssertionError("PronoteClient must not be constructed for QR-token dry-run") + + monkeypatch.setattr(run_module, "PronoteClient", FailingClient) + + with pytest.raises(ValueError) as exc_info: + PipelineRunner.from_settings( + Settings( + app=AppSettings(dry_run=True), + pronote=PronoteSettings( + auth_mode="qr_token", + qr_pin=SecretStr(qr_pin_sentinel), + ), + ) + ) + + assert "qr_token" in str(exc_info.value) + assert "dry-run" in str(exc_info.value) + assert qr_pin_sentinel not in str(exc_info.value) + assert constructed == [] + + def test_from_settings_qr_token_mode_passes_auth_state_instance( monkeypatch: pytest.MonkeyPatch, ) -> None: From 0dd4ee68c15cf572d2351efbcbd2c8bbc72ebcb3 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 11 Sep 2026 11:57:21 +0200 Subject: [PATCH 4/4] docs: define strict dry-run contract # Conflicts: # .secrets.baseline --- .secrets.baseline | 4 ++-- GUIDE_DEV_PYTHON.md | 11 ++++++----- README.md | 8 +++++++- TODO.md | 14 ++++++++++---- docs/exploitation.md | 7 +++++-- pronote_sync/cli/main.py | 5 ++++- tests/e2e/test_cli.py | 17 +++++++++++++++++ tests/integration/test_pipeline_runner.py | 14 -------------- 8 files changed, 51 insertions(+), 29 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index e860616..8934a65 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -140,7 +140,7 @@ "filename": "GUIDE_DEV_PYTHON.md", "hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa", "is_verified": true, - "line_number": 5084, + "line_number": 5085, "is_secret": false } ], @@ -177,5 +177,5 @@ } ] }, - "generated_at": "2026-09-10T19:26:08Z" + "generated_at": "2026-09-11T09:57:08Z" } diff --git a/GUIDE_DEV_PYTHON.md b/GUIDE_DEV_PYTHON.md index ad0e78b..f2a0ca8 100644 --- a/GUIDE_DEV_PYTHON.md +++ b/GUIDE_DEV_PYTHON.md @@ -310,7 +310,7 @@ d'un besoin réel et testé. | `AI_API_KEY` | Clé API pour l'API IA. | `None` | `SecretStr` | | `AI_MODEL` | Modèle IA à utiliser (exemple recommandé : `gpt-4o-mini`). | `None` | `str \| None`| | `AI_ALLOW_INSECURE_HTTP` | Autoriser HTTP (non sécurisé) pour `openai-compatible` uniquement. | `False` | `bool` | -| `DRY_RUN` | Mode dry-run (pas de modifications CalDAV/XMPP). | `False` | `bool` | +| `DRY_RUN` | Simulation sans sortie distante ni état local persistant ; incompatible avec `qr_token`. | `False` | `bool` | | `LOG_LEVEL` | Niveau de log (`DEBUG`, `INFO`, `WARNING`, `ERROR`). | `INFO` | `str` | > ⚠️ **Décision d'implémentation** : @@ -3241,7 +3241,8 @@ class CalDAVClient: ### 7.4 Points clés - **Différentielle** : La synchronisation compare les UID existants avec ceux à synchroniser. - **Idempotence** : Deux exécutions identiques ne modifient pas le calendrier. -- **Dry-run** : Mode obligatoire pour tester sans effet de bord. +- **Dry-run** : Les lectures sont autorisées, sans sortie distante ni état local persistant ; le + mode `qr_token` est refusé avant connexion car son authentification implique une rotation distante. - **Marquage** : Les événements gérés sont marqués avec `X-PRONOTE-SYNC-MANAGED: v1` pour éviter les conflits. - **Cours annulés** : Conservés avec `STATUS:CANCELLED` (ne pas supprimer). - **Plan explicite** : Le `CalDAVSyncPlan` est calculé avant l'exécution. @@ -5780,7 +5781,7 @@ repos: |----------------------------------------|-----------------------------------------------------------------------------------------------------|-----------------|------------| | Configuration des variables d'environnement | Vérifier que toutes les variables obligatoires sont définies (voir [Section 3.1](#31-variables-denvironnement)). | ✅ Oui | | | Vérification des secrets | Exécuter le script de vérification de sécurité (voir [Section 13.6](#136-exemple-de-script-de-vérification-de-sécurité)). | ✅ Oui | | -| Test en mode dry-run | Exécuter le pipeline avec `DRY_RUN=true` pour vérifier que tout fonctionne sans effet de bord. | ✅ Oui | | +| Test en mode dry-run | Exécuter le pipeline avec `DRY_RUN=true` sans sortie distante ni état local persistant (`qr_token` exclu). | ✅ Oui | | | Configuration des logs | Vérifier que les logs sont configurés avec masquage des secrets (voir [Section 4.2](#42-implémentation)). | ✅ Oui | | | Vérification des dépendances | Exécuter `pip check` pour vérifier que toutes les dépendances sont installées. | ✅ Oui | | | Configuration du cron (si planifié) | Configurer une tâche cron pour exécuter le script régulièrement (ex: tous les jours à 18h). | ⚠️ Non | | @@ -5960,7 +5961,7 @@ Exemple de ligne cron (exécution tous les jours à 18h) : | **CalDAV** | Protocole pour synchroniser des calendriers via HTTP. | | **XMPP** | Protocole de messagerie instantanée (anciennement Jabber). | | **UID** | Identifiant unique pour un événement iCal/CalDAV. | -| **Dry-run** | Mode de test où aucune modification n'est appliquée (lecture seule). | +| **Dry-run** | Simulation avec lectures autorisées, sans sortie distante ni état local persistant ; incompatible avec `qr_token`. | | **Idempotence** | Propriété d'une opération qui produit le même résultat si elle est exécutée plusieurs fois. | | **Reverse-engineering** | Technique consistant à analyser un logiciel pour en comprendre le fonctionnement interne. | @@ -6127,7 +6128,7 @@ Ce guide fournit une **base architecturale et technique solide** pour développe | **CalDAV** | Protocole pour synchroniser des calendriers via HTTP. | | **XMPP** | Protocole de messagerie instantanée (anciennement Jabber). | | **UID** | Identifiant unique pour un événement iCal/CalDAV. | -| **Dry-run** | Mode de test où aucune modification n'est appliquée (lecture seule). | +| **Dry-run** | Simulation avec lectures autorisées, sans sortie distante ni état local persistant ; incompatible avec `qr_token`. | | **Idempotence** | Propriété d'une opération qui produit le même résultat si elle est exécutée plusieurs fois. | | **Reverse-engineering** | Technique consistant à analyser un logiciel pour en comprendre le fonctionnement interne. | diff --git a/README.md b/README.md index 94c26de..1c93c94 100644 --- a/README.md +++ b/README.md @@ -36,10 +36,16 @@ pronote-sync --dry-run --log-level DEBUG ```bash pronote-sync # Exécute la synchronisation -pronote-sync --dry-run # Simulation sans écriture +pronote-sync --dry-run # Simulation : lectures autorisées, aucune écriture persistante ni sortie distante pronote-sync --log-level DEBUG # Verbosité des journaux ``` +En `--dry-run`, les données peuvent être lues pour construire la simulation, mais aucun état local +de source n'est enregistré : l'état RSS reste en mémoire pendant l'exécution. Les écritures CalDAV +et l'envoi XMPP sont également désactivés. Le mode +`PRONOTE_AUTH_MODE=qr_token` est refusé avant toute connexion, car la rotation de son token ne peut +pas garantir un état persistant cohérent pendant une simulation. + --- ## 🛠️ Déploiement diff --git a/TODO.md b/TODO.md index 240e4a5..c4db21d 100644 --- a/TODO.md +++ b/TODO.md @@ -147,7 +147,8 @@ Synchroniser différentiellement les événements Pronote vers le calendrier Cal - Le plan de sync est correctement calculé (données Pronote vs événements distants gérés). - Un changement de source iCal ↔ `pronotepy` ne crée ni doublon ni suppression/ajout artificiel pour un cours équivalent. -- Un run dry-run n'écrit rien ; deux runs identiques donnent un résultat identique. +- Un run dry-run autorise les lectures, mais ne produit aucune sortie distante ni écriture locale + persistante ; `qr_token` est refusé avant connexion. Deux runs identiques donnent le même résultat. - Les événements annulés restent (`STATUS:CANCELLED`) et sont marqués `MANAGED`. - Les événements non marqués ne sont jamais modifiés ni supprimés. @@ -223,7 +224,9 @@ Composer et orchestrer toutes les étapes avec gestion d'erreurs dégradée et m - [x] Créer les étapes `pipeline/steps/` : `fetch.py`, `normalize.py`, `compare.py`, `caldav_sync.py`, `synthesis.py`, `send.py`, `fetch_blog.py`. - [x] Créer `pipeline/run.py` : `PipelineRunner` (composition root) orchestrant fetch → normalize → fetch_blog → compare → caldav_sync → synthesis → send. - [x] Gérer les erreurs dégradées (continuer sauf critique) et renvoyer `(PronoteData, erreurs + warns)`. -- [x] Implémenter le mode `dry_run` (aucune écriture CalDAV/XMPP). +- [x] Implémenter le mode `dry_run` : lectures autorisées, aucune écriture CalDAV/XMPP ni écriture + persistante locale ; l'état RSS reste en mémoire pendant l'exécution, et + `PRONOTE_AUTH_MODE=qr_token` est refusé avant toute connexion. - [x] Câbler l'injection des dépendances (Protocol + composition root), sans singleton global. - [x] Réutiliser, dans une même exécution, un unique téléchargement/parsing iCal pour l'agenda et les devoirs lorsque les sources sélectionnées le permettent ; rester sur un cache local au run, sans cache global ni persistant. @@ -231,7 +234,9 @@ Composer et orchestrer toutes les étapes avec gestion d'erreurs dégradée et m - [x] Le pipeline complet s'exécute de bout en bout (mocks) dans le bon ordre. - [x] Une sélection iCal commune à l'agenda et aux devoirs ne déclenche qu'un téléchargement/parsing du flux par run. - [x] Une erreur non critique (ex : synthèse IA) n'empêche pas l'envoi XMPP. -- [x] `dry_run=True` n'effectue aucune écriture ; aucune source disponible → erreur critique explicite. +- [x] `dry_run=True` autorise les lectures mais n'effectue aucune écriture persistante locale ni + sortie distante ; aucune source disponible → erreur critique explicite. L'état RSS n'est pas + enregistré et `qr_token` est refusé avant toute connexion. - [x] Si `THEORETICAL_AGENDA_PATH` est absent, le pipeline produit un diff vide sans erreur et n'instancie pas `AgendaComparator` ; si présent, il instancie le comparateur et effectue la comparaison. - [x] Les erreurs critiques (`PipelineCriticalError`) propagées depuis une étape non-bloquante arrêtent le pipeline. @@ -247,7 +252,8 @@ Exposer le lancement du pipeline via une interface en ligne de commande. - [x] Gérer le code de retour et l'affichage des erreurs (redactées). ### Critères d'acceptation -- `pronote-sync --dry-run --log-level DEBUG` s'exécute sans effet de bord. +- `pronote-sync --dry-run --log-level DEBUG` s'exécute sans sortie distante ni état local persistant ; + le mode `qr_token`, qui implique une rotation distante, est refusé avant connexion. - Le script console est installable (`[project.scripts]` dans `pyproject.toml`). - Les erreurs affichées ne contiennent aucun secret, y compris avec l'affichage d'un traceback complet en mode debug. diff --git a/docs/exploitation.md b/docs/exploitation.md index 5c98729..dca4ebc 100644 --- a/docs/exploitation.md +++ b/docs/exploitation.md @@ -42,8 +42,11 @@ bloquent donc pas le déploiement. Il ne valide ni les valeurs ni les permission du fichier d'environnement. Pour analyser seulement le contenu indexé avant un commit, utilisez `scripts/check_secrets.py --staged`. -Le dry-run vérifie le pipeline sans appliquer les écritures de synchronisation ; -il ne remplace pas une vérification des paramètres réellement chargés. +Le dry-run autorise les lectures nécessaires à la simulation, mais n'applique aucune sortie +CalDAV/XMPP et ne modifie aucun état local persistant. L'état RSS reste limité à la mémoire du +processus. Le mode `PRONOTE_AUTH_MODE=qr_token` est incompatible avec cette garantie : la commande +le refuse avant toute connexion afin de ne pas désynchroniser le token local du token distant. +Le dry-run ne remplace pas une vérification des paramètres réellement chargés. ## Installation systemd diff --git a/pronote_sync/cli/main.py b/pronote_sync/cli/main.py index f91591b..e5b47f0 100644 --- a/pronote_sync/cli/main.py +++ b/pronote_sync/cli/main.py @@ -32,7 +32,10 @@ def _parse_arguments(arguments: Sequence[str] | None = None) -> argparse.Namespa "--dry-run", action="store_true", default=None, - help="Simule la synchronisation sans écrire vers CalDAV ni XMPP.", + help=( + "Simule la synchronisation sans sortie distante ni état local persistant " + "(incompatible avec PRONOTE_AUTH_MODE=qr_token)." + ), ) parser.add_argument( "--log-level", diff --git a/tests/e2e/test_cli.py b/tests/e2e/test_cli.py index 6428916..d298b1f 100644 --- a/tests/e2e/test_cli.py +++ b/tests/e2e/test_cli.py @@ -11,6 +11,23 @@ from pronote_sync.errors import PipelineCriticalError, PipelineWarning from pronote_sync.models.pronote import PronoteData +def test_help_documents_strict_dry_run_contract(capsys: pytest.CaptureFixture[str]) -> None: + """L'aide CLI expose le contrat strict et l'incompatibilité QR/token. + + :param capsys: Capture des sorties standard de pytest. + :return: None + """ + from pronote_sync.cli.main import main + + with pytest.raises(SystemExit) as exc_info: + main(["--help"]) + + output = " ".join(capsys.readouterr().out.split()) + assert exc_info.value.code == 0 + assert "sans sortie distante ni état local persistant" in output + assert "PRONOTE_AUTH_MODE=qr_token" in output + + def test_main_runs_composition_root_in_dry_run_with_requested_log_level( mocker: MockerFixture, ) -> None: diff --git a/tests/integration/test_pipeline_runner.py b/tests/integration/test_pipeline_runner.py index 18a5e48..562dad8 100644 --- a/tests/integration/test_pipeline_runner.py +++ b/tests/integration/test_pipeline_runner.py @@ -505,7 +505,6 @@ def test_from_settings_configures_source_state_persistence_for_dry_run( import pronote_sync.pipeline.run as run_module blog_persistence: list[bool] = [] - auth_persistence: list[bool] = [] class RecordingBlogState: """Blog state factory recording its persistence configuration.""" @@ -517,16 +516,6 @@ def test_from_settings_configures_source_state_persistence_for_dry_run( """ blog_persistence.append(persistence_enabled) - class RecordingAuthState: - """Authentication state factory recording its persistence configuration.""" - - def __init__(self, *, persistence_enabled: bool = True) -> None: - """Record the requested persistence setting. - - :param persistence_enabled: Whether disk writes are enabled. - """ - auth_persistence.append(persistence_enabled) - class RecordingClient: """Pronote client constructor accepting the injected auth state.""" @@ -539,19 +528,16 @@ def test_from_settings_configures_source_state_persistence_for_dry_run( del settings, auth_state monkeypatch.setattr(run_module, "BlogRSSState", RecordingBlogState) - monkeypatch.setattr(run_module, "PronoteAuthState", RecordingAuthState) monkeypatch.setattr(run_module, "PronoteClient", RecordingClient) PipelineRunner.from_settings( Settings( app=AppSettings(dry_run=dry_run), blog=BlogSettings(enabled=True), - pronote=PronoteSettings(auth_mode="qr_token"), ) ) assert blog_persistence == [not dry_run] - assert auth_persistence == [not dry_run] def test_runner_reuses_ical_download_and_parse_within_one_run(