Compare commits

..

4 Commits

Author SHA1 Message Date
9e968dd522 docs: define strict dry-run contract 2026-09-11 07:19:36 +02:00
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
13 changed files with 319 additions and 21 deletions

View File

@@ -140,7 +140,7 @@
"filename": "GUIDE_DEV_PYTHON.md", "filename": "GUIDE_DEV_PYTHON.md",
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa", "hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
"is_verified": true, "is_verified": true,
"line_number": 5064, "line_number": 5065,
"is_secret": false "is_secret": false
} }
], ],
@@ -177,5 +177,5 @@
} }
] ]
}, },
"generated_at": "2026-09-08T10:45:46Z" "generated_at": "2026-09-11T05:17:54Z"
} }

View File

@@ -310,7 +310,7 @@ d'un besoin réel et testé.
| `AI_API_KEY` | Clé API pour l'API IA. | `None` | `SecretStr` | | `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_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` | | `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` | | `LOG_LEVEL` | Niveau de log (`DEBUG`, `INFO`, `WARNING`, `ERROR`). | `INFO` | `str` |
> ⚠️ **Décision d'implémentation** : > ⚠️ **Décision d'implémentation** :
@@ -3221,7 +3221,8 @@ class CalDAVClient:
### 7.4 Points clés ### 7.4 Points clés
- **Différentielle** : La synchronisation compare les UID existants avec ceux à synchroniser. - **Différentielle** : La synchronisation compare les UID existants avec ceux à synchroniser.
- **Idempotence** : Deux exécutions identiques ne modifient pas le calendrier. - **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. - **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). - **Cours annulés** : Conservés avec `STATUS:CANCELLED` (ne pas supprimer).
- **Plan explicite** : Le `CalDAVSyncPlan` est calculé avant l'exécution. - **Plan explicite** : Le `CalDAVSyncPlan` est calculé avant l'exécution.
@@ -5760,7 +5761,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 | | | 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 | | | 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 | | | 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 | | | 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 | | | Configuration du cron (si planifié) | Configurer une tâche cron pour exécuter le script régulièrement (ex: tous les jours à 18h). | ⚠️ Non | |
@@ -5940,7 +5941,7 @@ Exemple de ligne cron (exécution tous les jours à 18h) :
| **CalDAV** | Protocole pour synchroniser des calendriers via HTTP. | | **CalDAV** | Protocole pour synchroniser des calendriers via HTTP. |
| **XMPP** | Protocole de messagerie instantanée (anciennement Jabber). | | **XMPP** | Protocole de messagerie instantanée (anciennement Jabber). |
| **UID** | Identifiant unique pour un événement iCal/CalDAV. | | **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. | | **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. | | **Reverse-engineering** | Technique consistant à analyser un logiciel pour en comprendre le fonctionnement interne. |
@@ -6107,7 +6108,7 @@ Ce guide fournit une **base architecturale et technique solide** pour développe
| **CalDAV** | Protocole pour synchroniser des calendriers via HTTP. | | **CalDAV** | Protocole pour synchroniser des calendriers via HTTP. |
| **XMPP** | Protocole de messagerie instantanée (anciennement Jabber). | | **XMPP** | Protocole de messagerie instantanée (anciennement Jabber). |
| **UID** | Identifiant unique pour un événement iCal/CalDAV. | | **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. | | **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. | | **Reverse-engineering** | Technique consistant à analyser un logiciel pour en comprendre le fonctionnement interne. |

View File

@@ -36,10 +36,16 @@ pronote-sync --dry-run --log-level DEBUG
```bash ```bash
pronote-sync # Exécute la synchronisation 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 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 ## 🛠️ Déploiement

14
TODO.md
View File

@@ -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). - 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 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 annulés restent (`STATUS:CANCELLED`) et sont marqués `MANAGED`.
- Les événements non marqués ne sont jamais modifiés ni supprimés. - 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 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] 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] 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] 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. - [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] 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 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] 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] 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. - [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). - [x] Gérer le code de retour et l'affichage des erreurs (redactées).
### Critères d'acceptation ### 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`). - 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. - Les erreurs affichées ne contiennent aucun secret, y compris avec l'affichage d'un traceback complet en mode debug.

View File

@@ -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 du fichier d'environnement. Pour analyser seulement le contenu indexé avant un
commit, utilisez `scripts/check_secrets.py --staged`. commit, utilisez `scripts/check_secrets.py --staged`.
Le dry-run vérifie le pipeline sans appliquer les écritures de synchronisation ; Le dry-run autorise les lectures nécessaires à la simulation, mais n'applique aucune sortie
il ne remplace pas une vérification des paramètres réellement chargés. 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 ## Installation systemd

View File

@@ -32,7 +32,10 @@ def _parse_arguments(arguments: Sequence[str] | None = None) -> argparse.Namespa
"--dry-run", "--dry-run",
action="store_true", action="store_true",
default=None, 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( parser.add_argument(
"--log-level", "--log-level",

View File

@@ -126,6 +126,9 @@ class PipelineRunner:
:rtype: PipelineRunner :rtype: PipelineRunner
""" """
effective_dry_run = settings.app.dry_run if dry_run is None else dry_run 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( theoretical_provider = get_theoretical_provider(
settings.app.theoretical_agenda_path, settings.app.theoretical_agenda_path,
settings.app.school_holidays_path, settings.app.school_holidays_path,
@@ -136,7 +139,9 @@ class PipelineRunner:
AgendaComparator(theoretical_provider) if theoretical_provider is not None else None 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_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( return cls(
settings=settings, settings=settings,
pronote_fetcher=PronoteFetcher( pronote_fetcher=PronoteFetcher(
@@ -144,7 +149,9 @@ class PipelineRunner:
PronoteClient( PronoteClient(
settings.pronote, settings.pronote,
auth_state=( 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 :param state_file: Chemin du fichier d'état JSON (``str`` ou
:class:`~pathlib.Path`). ``".blog_rss_state.json"`` par défaut. :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. """Initialise le gestionnaire d'état depuis le fichier JSON.
:param state_file: Chemin du fichier d'état JSON (``str`` ou :param state_file: Chemin du fichier d'état JSON (``str`` ou
:class:`~pathlib.Path`). ``".blog_rss_state.json"`` par défaut. :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._state_file = Path(state_file)
self._persistence_enabled = persistence_enabled
self._known_guids: set[str] = set() self._known_guids: set[str] = set()
self._etag: str | None = None self._etag: str | None = None
self._last_modified: 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 d'erreur d'écriture, une erreur est journalisée sans être
propagée et le fichier temporaire est supprimé. propagée et le fichier temporaire est supprimé.
""" """
if not self._persistence_enabled:
return
payload = { payload = {
"version": _STATE_VERSION, "version": _STATE_VERSION,
"known_guids": sorted(self._known_guids), "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 :param state_file: Chemin du fichier d'état JSON (``str`` ou
:class:`~pathlib.Path`). ``".pronote_auth_state.json"`` par défaut. :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. """Initialise le gestionnaire d'état d'authentification Pronote.
Le fichier d'état n'est pas créé à l'initialisation : il n'est écrit 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 :param state_file: Chemin du fichier d'état JSON (``str`` ou
:class:`~pathlib.Path`). ``".pronote_auth_state.json"`` par défaut. :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._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: def load(self) -> dict[str, str] | None:
"""Charge les credentials d'authentification depuis le fichier d'état. """Charge les credentials d'authentification depuis le fichier d'état.
@@ -65,6 +77,10 @@ class PronoteAuthState:
aucun état valide n'est disponible. aucun état valide n'est disponible.
:rtype: dict[str, str] | None :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(): if not self._state_file.exists():
logger.debug( logger.debug(
"Fichier d'état d'authentification Pronote %s absent, aucun token à charger.", "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 :raises PronoteSyncError: Si l'écriture ou le remplacement du fichier
échoue. échoue.
""" """
if not self._persistence_enabled:
self._in_memory_credentials = credentials.copy()
self._in_memory_state_cleared = False
return
payload: dict[str, Any] = { payload: dict[str, Any] = {
"version": _STATE_VERSION, "version": _STATE_VERSION,
"credentials": credentials, "credentials": credentials,
@@ -182,10 +202,16 @@ class PronoteAuthState:
"""Supprime le fichier d'état d'authentification. """Supprime le fichier d'état d'authentification.
Si le fichier n'existe pas, la méthode ne fait rien et aucune erreur 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. :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(): if not self._state_file.exists():
return return
logger.debug( logger.debug(

View File

@@ -11,6 +11,23 @@ from pronote_sync.errors import PipelineCriticalError, PipelineWarning
from pronote_sync.models.pronote import PronoteData 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( def test_main_runs_composition_root_in_dry_run_with_requested_log_level(
mocker: MockerFixture, mocker: MockerFixture,
) -> None: ) -> None:

View File

@@ -8,7 +8,13 @@ from typing import Any, cast
import pytest import pytest
from pydantic import SecretStr 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.errors import PipelineCriticalError, PipelineWarning, PronoteAuthRotationError
from pronote_sync.models.agenda import Lesson, LessonStatus, SchoolEvent from pronote_sync.models.agenda import Lesson, LessonStatus, SchoolEvent
from pronote_sync.models.blog import BlogArticle 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 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( def test_from_settings_qr_token_mode_passes_auth_state_instance(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
@@ -449,6 +496,50 @@ def test_from_settings_qr_token_mode_passes_auth_state_instance(
assert isinstance(constructed[0][1], PronoteAuthState) 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] = []
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 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, "PronoteClient", RecordingClient)
PipelineRunner.from_settings(
Settings(
app=AppSettings(dry_run=dry_run),
blog=BlogSettings(enabled=True),
)
)
assert blog_persistence == [not dry_run]
def test_runner_reuses_ical_download_and_parse_within_one_run( def test_runner_reuses_ical_download_and_parse_within_one_run(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
pipeline_inputs: tuple[Lesson, Homework], 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) 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: def test_add_guids_persists(tmp_path: Path) -> None:
"""Vérifie que l'ajout de GUID persiste dans le fichier JSON. """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 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: def test_save_then_load_roundtrip(tmp_path: Path) -> None:
"""Vérifie que des credentials sauvegardés sont rechargés à l'identique. """Vérifie que des credentials sauvegardés sont rechargés à l'identique.