Compare commits

..

3 Commits

Author SHA1 Message Date
384a730e73 fix: reject QR token dry-run 2026-09-11 00:13:11 +02:00
f4f71461ee fix: wire dry-run source state policy 2026-09-11 00:05:26 +02:00
5e69c4e049 fix: prevent source state writes in dry-run 2026-09-10 23:57:50 +02:00
6 changed files with 282 additions and 6 deletions

View File

@@ -126,6 +126,9 @@ 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,
settings.app.school_holidays_path,
@@ -136,7 +139,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 +149,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
),
),
),

View File

@@ -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),

View File

@@ -38,9 +38,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
@@ -48,8 +54,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.
@@ -65,6 +77,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.",
@@ -126,6 +142,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,
@@ -182,10 +202,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(

View File

@@ -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
@@ -422,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:
@@ -449,6 +496,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],

View File

@@ -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.

View File

@@ -35,6 +35,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.