From 642bbf16c11b1a7295972840bf78c58f43546d0d Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 10 Sep 2026 23:57:50 +0200 Subject: [PATCH] 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.