Compare commits

..

4 Commits

Author SHA1 Message Date
292aef4f5b fix(M3): immuabilité et validation des modèles — correction des écarts FIXME_M3
Immuabilité :
- Collections des modèles frozen : list → tuple (Lesson, TheoreticalLesson,
  SchoolEvent, AgendaDiff, Homework, XmppMessage, ExternalInfo)
- Gel de HomeworkBlock et ExternalInfo (sous-modèles imbriqués dans contrats)
- Valeurs par défaut immuables : Field(default=(), ...)

Validation de domaine :
- TheoreticalLesson.day_of_week : Field(ge=0, le=6)
- Lesson/TheoreticalLesson : start < end (validateur de modèle)
- SchoolEvent : from_date <= to_date
- AgendaChange : cohérence type/payload (ADDED/MODIFIED → lesson,
  REMOVED → theoretical_lesson)
- CalDAVSyncResult : compteurs Field(ge=0) sur added/updated/removed

Tests : 66 tests (construction/sérialisation, immuabilité, invariants)
couvrant le gel, la mutabilité légitime, et les cas invalides par invariant.

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
2026-09-06 12:36:27 +02:00
2f6184df0b fix(M2): configuration, secrets et rédaction — correction des écarts FIXME_M2
Sécurité :
- ical_url : str → SecretStr | None (masquage dans str/repr/model_dump_json)
- En-têtes Authorization/Proxy-Authorization : masquage complet de la valeur
- _URL_PATTERN : insensible à la casse (HTTPS:// reconnu)
- redact_url() : masquage du nom d'utilisateur (userinfo complet)

Configuration :
- Sous-configs : Field(default_factory=...) pour rechargement à chaque appel
- Suppression du singleton settings (injection de dépendances)
- .env.example : ajout AI_PROVIDER et CALDAV_CALENDAR_PATH
- Guide : BLOG_RSS_ENABLED → BLOG_ENABLED, AI_MODEL=None par défaut
- .secrets.baseline : ligne décalée 5112 → 5117 (faux positif audité)
- pre-commit : ajout de pytest aux additional_dependencies du hook mypy

Tests : 15 tests (config + redaction) couvrant rechargement, masquage
SecretStr, en-têtes Authorization, URL auth intégrée et casse variable.

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
2026-09-06 12:35:55 +02:00
a0ac75cec6 fix(M1): correction des écarts d'audit FIXME_M1
- requires-python : >=3.13 → >=3.13.5 (alignement sur le contrat TODO.md)
- test de fumée : ajout de tests/unit/test_smoke.py (pytest sort code 0)
- .gitignore : ajout de la règle *.tmp.ics pour les artefacts iCal temporaires
- .secrets.baseline : audit du faux positif GUIDE_DEV_PYTHON.md:5112
  (is_secret: false, is_verified: true → VERIFIED_FALSE)

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/verifier <verifier@agents.invalid>
2026-09-06 11:59:50 +02:00
aaca78c55d docs+fix: renforcement de l'architecture agentique et corrections de sécurité
AGENTS.md :
- Rôles d'agents renforcés : @coder ne valide pas, @debugger ne code pas,
  @verifier ne modifie pas, etc.
- Table « Séparation des rôles » : tâche → agent responsable → ne pas confier à
- Workflow étape 5 : délégation explicite à @verifier pour la validation

GUIDE_DEV_PYTHON.md :
- 7 notes « Décision d'implémentation » ajoutées aux sections concernées :
  3.1.1 (XMPP_RECIPIENT→XMPP_TO, vars ajoutées), 3.1.2 (SYNC_PAST_DAYS→AppSettings),
  3.2 (Pydantic v2 style, defaults corrigés), 4.2.1 (redact_exception module function),
  4.2.2 (getLevelNamesMapping), 5.1.5 (normalize_pronote_uid, usedforsecurity=False),
  6 (ConfigDict, StrEnum, alias _date, external_info Optional)

Sécurité (audit @security-auditor, corrections @coder, validation @verifier) :
- RedactingFormatter : redaction APRÈS formatage (corrige TypeError %s + fuite traceback)
- redact_url : masquage des credentials dans userinfo URL (HTTP Basic Auth)
- redact_secrets : patterns étendus (api_key, access_token, authorization, auth)
- redact_secrets : support JSON-style « key: value » avec guillemets

Validations (@verifier) :
- ruff check : PASS | mypy strict : PASS | bandit : PASS (0 issue)
- %s formatting : OK (password=REDACTED, pas de TypeError)
- Traceback redaction : OK (icalsecurise=REDACTED)
- URL userinfo : OK (user:REDACTED@host)
- JSON-style redaction : OK ({"token": "REDACTED"})
- Régression red-to-green : OK (historical HEAD reproduction)

Co-authored-by: OpenCode/orchestrator <opencode-orchestrator@agents.invalid>
2026-09-05 23:52:02 +02:00
23 changed files with 2020 additions and 523 deletions

View File

@@ -13,6 +13,7 @@ PRONOTE_MESSAGES_SOURCE=pronotepy
CALDAV_URL=https://caldav.example.com/calendars/user/pronote/ CALDAV_URL=https://caldav.example.com/calendars/user/pronote/
CALDAV_USERNAME=user@example.com CALDAV_USERNAME=user@example.com
CALDAV_PASSWORD=your_caldav_password CALDAV_PASSWORD=your_caldav_password
CALDAV_CALENDAR_PATH=/pronote-sync/
# Fenêtre de synchronisation (jours) # Fenêtre de synchronisation (jours)
SYNC_PAST_DAYS=7 SYNC_PAST_DAYS=7
@@ -34,9 +35,10 @@ XMPP_TIMEOUT=30
# --- IA (optionnelle) --- # --- IA (optionnelle) ---
AI_ENABLED=true AI_ENABLED=true
AI_PROVIDER=openai
AI_BASE_URL=https://api.openai.com/v1 AI_BASE_URL=https://api.openai.com/v1
AI_API_KEY=your_ai_api_key AI_API_KEY=your_ai_api_key
AI_MODEL=gpt-4o-mini # AI_MODEL=gpt-4o-mini # exemple recommandé, non activé par défaut
# --- Blog --- # --- Blog ---
BLOG_ENABLED=false BLOG_ENABLED=false

5
.gitignore vendored
View File

@@ -51,3 +51,8 @@ Thumbs.db
# --- Logs --- # --- Logs ---
*.log *.log
# --- Artefacts iCal temporaires ---
# Ignorer les exports iCal temporaires (peuvent contenir des jetons ou données scolaires).
# Les fixtures iCal anonymisées (M13) restent suivies car elles ne correspondent pas au suffixe *.tmp.ics.
*.tmp.ics

View File

@@ -26,7 +26,7 @@ repos:
name: mypy name: mypy
entry: mypy entry: mypy
language: python language: python
additional_dependencies: ["mypy>=1.10.0", "pydantic>=2.0.0", "pydantic-settings>=2.0.0"] additional_dependencies: ["mypy>=1.10.0", "pydantic>=2.0.0", "pydantic-settings>=2.0.0", "pytest>=8.0.0"]
types: [python] types: [python]
pass_filenames: true pass_filenames: true

View File

@@ -90,6 +90,10 @@
{ {
"path": "detect_secrets.filters.allowlist.is_line_allowlisted" "path": "detect_secrets.filters.allowlist.is_line_allowlisted"
}, },
{
"path": "detect_secrets.filters.common.is_baseline_file",
"filename": ".secrets.baseline"
},
{ {
"path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies", "path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies",
"min_level": 2 "min_level": 2
@@ -135,10 +139,11 @@
"type": "Hex High Entropy String", "type": "Hex High Entropy String",
"filename": "GUIDE_DEV_PYTHON.md", "filename": "GUIDE_DEV_PYTHON.md",
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa", "hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
"is_verified": false, "is_secret": false,
"line_number": 5073 "is_verified": true,
"line_number": 5117
} }
] ]
}, },
"generated_at": "2026-09-05T17:53:24Z" "generated_at": "2026-09-05T21:51:55Z"
} }

View File

@@ -189,22 +189,38 @@ Cette section s'applique uniquement lorsque le travail est exécuté avec le sys
Les rôles d'agents disponibles pour ce projet sont les suivants : Les rôles d'agents disponibles pour ce projet sont les suivants :
- `@architect` : Arbitrages d'architecture et choix techniques structurants pour le pipeline `pronote-sync`. - `@architect` : Arbitrages d'architecture et choix techniques structurants. **Ne produit pas de code.**
- `@coder` : Opérations de développement et changements de code dans le projet. - `@coder` : Écrit et modifie du code, de la configuration et des scripts. **Ne valide pas** (ruff, mypy, pytest) — c'est le rôle de `@verifier`. **Ne diagnostique pas** — c'est le rôle de `@debugger`.
- `@debugger` : Reproduction d'un symptôme et établissement de sa cause profonde (ex. : échec de synchronisation, repli iCal/pronotepy). - `@debugger` : Reproduit un symptôme et établit sa cause profonde. **Ne modifie pas le code.**
- `@explorer` : Exploration du dépôt en lecture seule et fourniture de contexte factuel. - `@explorer` : Explore le dépôt en lecture seule. **Ne modifie rien, n'exécute pas de commandes.**
- `@orchestrator` : Compréhension globale du projet, définition des jalons, coordination et garantie du résultat. - `@orchestrator` : Compréhension globale, définition des jalons, coordination et garantie du résultat. **N'écrit pas de code.**
- `@planner` : Transformation d'une demande complexe en unités exécutables avec frontières et dépendances claires. - `@planner` : Transforme une demande complexe en unités exécutables. **Ne dirige aucun technicien.**
- `@reviewer` : Revues indépendantes de correction, régression, contrats et maintenabilité. - `@reviewer` : Revues indépendantes de correction, régression, contrats et maintenabilité. **Ne modifie pas le code.**
- `@security-auditor` : Audit indépendant d'une surface de sécurité désignée (ex. : gestion des secrets, masquage des données). - `@security-auditor` : Audit indépendant d'une surface de sécurité. **Ne modifie pas le code.**
- `@tech-writer` : Rédaction et maintenance de documentation technique exacte et vérifiable. - `@tech-writer` : Rédige et maintient la documentation. **N'écrit pas de code applicatif.**
- `@test-engineer` : Conception, écriture et exécution de tests ciblés (unitaires, intégration, mocks). - `@test-engineer` : Conçoit, écrit et exécute des tests ciblés. **N'écrit pas de code de production.**
- `@ui-designer` : conception et implémentation d'interfaces Web et terminal. - `@ui-designer` : Conçoit et implémente les interfaces Web et terminal.
- `@verifier` : Vérification indépendante du comportement livré, des régressions et du respect des conventions (idempotence, mode dégradé). - `@verifier` : Vérifie indépendamment le comportement livré, les régressions et le respect des conventions (ruff, mypy, pytest, bandit, idempotence, mode dégradé). **Ne modifie pas le code.**
- `@web-explorer` : Recherche et extraction de sources Web vérifiables (ex. : documentation Pronote, CalDAV, XMPP). - `@web-explorer` : Recherche et extrait des sources Web vérifiables. **Ne modifie pas le dépôt.**
> **Note** : Ne pas utiliser `@coder` pour les tâches de documentation (`@tech-writer`) ni pour les tests (`@test-engineer`). > **Note** : Ne pas utiliser `@coder` pour les tâches de documentation (`@tech-writer`) ni pour les tests (`@test-engineer`).
### Séparation des rôles
| Type de tâche | Agent responsable | Ne pas confier à |
|---|---|---|
| Écrire/modifier du code | `@coder` | `@verifier`, `@explorer` |
| Valider (ruff, mypy, pytest, bandit) | `@verifier` | `@coder` |
| Diagnostiquer un bug | `@debugger` | `@coder` |
| Écrire un test | `@test-engineer` | `@coder` |
| Rédiger de la documentation | `@tech-writer` | `@coder` |
| Explorer le dépôt (lecture) | `@explorer` | `@coder`, `@verifier` |
| Arbitrage technique structurant | `@architect` | `@coder`, `@planner` |
| Revue de code | `@reviewer` | `@coder`, `@verifier` |
| Audit de sécurité | `@security-auditor` | `@coder`, `@verifier` |
| Recherche web | `@web-explorer` | `@explorer` |
| Découpage de travail complexe | `@planner` | `@coder` |
--- ---
## 10. Workflow de modification ## 10. Workflow de modification
@@ -213,7 +229,7 @@ Les rôles d'agents disponibles pour ce projet sont les suivants :
2. Préserver les changements existants de l'utilisateur. 2. Préserver les changements existants de l'utilisateur.
3. Pour une correction, reproduire d'abord le défaut avec un test automatisé lorsque c'est raisonnable. 3. Pour une correction, reproduire d'abord le défaut avec un test automatisé lorsque c'est raisonnable.
4. Faire une modification étroite et cohérente, en respectant les conventions du projet (idempotence, mode dégradé, repli iCal/pronotepy). 4. Faire une modification étroite et cohérente, en respectant les conventions du projet (idempotence, mode dégradé, repli iCal/pronotepy).
5. Vérifier le comportement nominal et les cas d'erreur, notamment : 5. Faire vérifier le comportement par `@verifier` (ruff, mypy, pytest, bandit) et les cas d'erreur, notamment :
- Succès de la synchronisation Pronote → CalDAV/XMPP. - Succès de la synchronisation Pronote → CalDAV/XMPP.
- Repli vers iCal en cas d'échec de `pronotepy`. - Repli vers iCal en cas d'échec de `pronotepy`.
- Gestion des erreurs explicites. - Gestion des erreurs explicites.

View File

@@ -267,10 +267,16 @@ Le projet utilise **`pydantic-settings`** pour valider et charger la configurati
| `CALDAV_URL` | URL du serveur CalDAV. | `https://caldav.example.com/calendars/...` | `str` | | `CALDAV_URL` | URL du serveur CalDAV. | `https://caldav.example.com/calendars/...` | `str` |
| `CALDAV_USERNAME` | Identifiant CalDAV. | `user@example.com` | `str` | | `CALDAV_USERNAME` | Identifiant CalDAV. | `user@example.com` | `str` |
| `CALDAV_PASSWORD` | Mot de passe CalDAV. | `SecretStr` (masqué) | `SecretStr` | | `CALDAV_PASSWORD` | Mot de passe CalDAV. | `SecretStr` (masqué) | `SecretStr` |
| `CALDAV_CALENDAR_PATH` | Chemin du calendrier CalDAV de destination. | `/pronote-sync/` | `str` |
| `CALDAV_CALENDAR_PATH` | Chemin du calendrier CalDAV de destination. | `/pronote-sync/` | `str` |
| `XMPP_JID` | Identifiant XMPP (ex: `user@example.com`). | `user@example.com` | `str` | | `XMPP_JID` | Identifiant XMPP (ex: `user@example.com`). | `user@example.com` | `str` |
| `XMPP_PASSWORD` | Mot de passe XMPP. | `SecretStr` (masqué) | `SecretStr` | | `XMPP_PASSWORD` | Mot de passe XMPP. | `SecretStr` (masqué) | `SecretStr` |
| `XMPP_RECIPIENT` | Destinataire XMPP (ex: `parent@example.com`). | `parent@example.com` | `str` | | `XMPP_RECIPIENT` | Destinataire XMPP (ex: `parent@example.com`). | `parent@example.com` | `str` |
> ⚠️ **Décision d'implémentation** :
> `XMPP_RECIPIENT` a été renommé en `XMPP_TO` dans l'implémentation (aligné avec §10.2.1).
> Des variables XMPP supplémentaires ont été ajoutées : `XMPP_ENABLED`, `XMPP_HOST`, `XMPP_PORT`, `XMPP_RESOURCE`, `XMPP_USE_TLS`, `XMPP_TIMEOUT`.
> Une section `BLOG_ENABLED` et `BLOG_RSS_URL` a été ajoutée dans `.env.example`.
#### 3.1.2 Variables optionnelles #### 3.1.2 Variables optionnelles
@@ -281,11 +287,16 @@ Le projet utilise **`pydantic-settings`** pour valider et charger la configurati
| `PRONOTE_MESSAGES_SOURCE` | Source pour les messages (`pronotepy` uniquement). | `pronotepy` | `Literal` | | `PRONOTE_MESSAGES_SOURCE` | Source pour les messages (`pronotepy` uniquement). | `pronotepy` | `Literal` |
| `SYNC_PAST_DAYS` | Nombre de jours dans le passé pour la sync CalDAV. | `7` | `int` | | `SYNC_PAST_DAYS` | Nombre de jours dans le passé pour la sync CalDAV. | `7` | `int` |
| `SYNC_FUTURE_DAYS` | Nombre de jours dans le futur pour la sync CalDAV. | `30` | `int` | | `SYNC_FUTURE_DAYS` | Nombre de jours dans le futur pour la sync CalDAV. | `30` | `int` |
> ⚠️ **Décision d'implémentation** :
> Ces variables sont désormais dans `AppSettings` (et non `CalDAVSettings`) car `CalDAVSettings` utilise `env_prefix="CALDAV_"`, ce qui nécessiterait `CALDAV_SYNC_PAST_DAYS`.
> Leur placement dans `AppSettings` (sans préfixe) garantit un mappage correct avec `SYNC_PAST_DAYS` / `SYNC_FUTURE_DAYS`.
| `THEORETICAL_AGENDA_PATH` | Chemin vers le fichier iCal/CSV de l'agenda théorique. | `None` | `str \| None`| | `THEORETICAL_AGENDA_PATH` | Chemin vers le fichier iCal/CSV de l'agenda théorique. | `None` | `str \| None`|
| `AI_ENABLED` | Activer la synthèse IA. | `False` | `bool` | | `AI_ENABLED` | Activer la synthèse IA. | `False` | `bool` |
| `AI_PROVIDER` | Fournisseur IA (`openai` ou `litellm`). | `openai` | `str` |
| `AI_BASE_URL` | URL de base pour l'API IA (ex: OpenAI compatible). | `None` | `str \| None`| | `AI_BASE_URL` | URL de base pour l'API IA (ex: OpenAI compatible). | `None` | `str \| None`|
| `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. | `gpt-4o-mini` | `str` | | `AI_MODEL` | Modèle IA à utiliser (exemple recommandé : `gpt-4o-mini`). | `None` | `str \| None`|
| `DRY_RUN` | Mode dry-run (pas de modifications CalDAV/XMPP). | `False` | `bool` | | `DRY_RUN` | Mode dry-run (pas de modifications CalDAV/XMPP). | `False` | `bool` |
| `LOG_LEVEL` | Niveau de log (`DEBUG`, `INFO`, `WARNING`, `ERROR`). | `INFO` | `str` | | `LOG_LEVEL` | Niveau de log (`DEBUG`, `INFO`, `WARNING`, `ERROR`). | `INFO` | `str` |
@@ -308,6 +319,7 @@ PRONOTE_MESSAGES_SOURCE=pronotepy
CALDAV_URL=https://caldav.example.com/calendars/user/pronote/ CALDAV_URL=https://caldav.example.com/calendars/user/pronote/
CALDAV_USERNAME=user@example.com CALDAV_USERNAME=user@example.com
CALDAV_PASSWORD=your_caldav_password CALDAV_PASSWORD=your_caldav_password
CALDAV_CALENDAR_PATH=/pronote-sync/
# Fenêtre de synchronisation (jours) # Fenêtre de synchronisation (jours)
SYNC_PAST_DAYS=7 SYNC_PAST_DAYS=7
@@ -323,9 +335,10 @@ XMPP_RECIPIENT=parent@example.com
# --- IA (optionnelle) --- # --- IA (optionnelle) ---
AI_ENABLED=true AI_ENABLED=true
AI_PROVIDER=openai
AI_BASE_URL=https://api.openai.com/v1 AI_BASE_URL=https://api.openai.com/v1
AI_API_KEY=your_ai_api_key AI_API_KEY=your_ai_api_key
AI_MODEL=gpt-4o-mini # AI_MODEL=gpt-4o-mini # exemple recommandé, non activé par défaut
# --- Divers --- # --- Divers ---
DRY_RUN=false DRY_RUN=false
@@ -335,6 +348,15 @@ LOG_LEVEL=INFO
### 3.2 Modèle Pydantic pour la configuration ### 3.2 Modèle Pydantic pour la configuration
> ⚠️ **Décision d'implémentation** :
> L'implémentation utilise le style moderne de Pydantic v2 : `model_config = ConfigDict(frozen=True)` au lieu de `class Config`, pas de `json_encoders` (la sérialisation ISO est native en v2), `str | None` au lieu de `Optional[str]`, `list[str]` au lieu de `List[str]`.
> `AISettings.enabled` a pour valeur par défaut `False` (et non `True` comme indiqué dans le bloc de code).
> `XmppSettings` est entièrement défini en §10.2.3 avec tous les champs optionnels (valeurs par défaut) pour que `Settings()` fonctionne sans `.env`.
> `BlogSettings` a été ajouté (§5 bis.9.2) avec `enabled=False` et `rss_url` par défaut.
> `sync_past_days` et `sync_future_days` sont dans `AppSettings`, et non `CalDAVSettings`.
> `CalDAVSettings.calendar_path` a pour valeur par défaut `"/pronote-sync/"` (et non `"/pronote-digest/"`).
> `XmppSettings.resource` a pour valeur par défaut `"pronote-sync"` (et non `"pronote-digest"`).
```python ```python
from typing import Literal, Optional from typing import Literal, Optional
from pydantic import SecretStr, Field from pydantic import SecretStr, Field
@@ -452,6 +474,11 @@ Toutes les exceptions externes (HTTP, Pronote, CalDAV, XMPP, IA) **doivent** êt
#### 4.2.1 Masquage des URLs (`redaction.py`) #### 4.2.1 Masquage des URLs (`redaction.py`)
> ⚠️ **Décision d'implémentation** :
> `redact_exception` est implémenté comme une **fonction au niveau du module** dans `utils/redaction.py`, et non comme une méthode de `RedactingFormatter` (contrairement à §4.2.2 où elle apparaît comme une méthode).
> `redact_url` utilise `urlsplit`/`urlunsplit`/`parse_qsl` au lieu de `urlparse`/`urlunparse`/`parse_qs`.
> La correspondance des clés sensibles est insensible à la casse.
```python ```python
import re import re
from urllib.parse import urlparse, urlunparse, parse_qs, urlencode from urllib.parse import urlparse, urlunparse, parse_qs, urlencode
@@ -506,6 +533,11 @@ def redact_secrets(text: str) -> str:
#### 4.2.2 Configuration des logs (`logging.py`) #### 4.2.2 Configuration des logs (`logging.py`)
> ⚠️ **Décision d'implémentation** :
> `redact_exception` est une fonction au niveau du module dans `redaction.py`, et non une méthode de `RedactingFormatter`.
> `setup_logging` utilise `logging.getLevelNamesMapping()` (Python 3.11+) au lieu de `getattr(logging, ...)`.
> `RedactingFormatter.format` gère à la fois les `record.args` de type tuple et dict.
```python ```python
import logging import logging
import sys import sys
@@ -1115,7 +1147,7 @@ Ajouter les variables suivantes dans la configuration :
| **Variable** | **Description** | **Valeur par défaut** | **Type** | | **Variable** | **Description** | **Valeur par défaut** | **Type** |
|----------------------------|-------------------------------------------------------------------------------|-----------------------|-------------------| |----------------------------|-------------------------------------------------------------------------------|-----------------------|-------------------|
| `BLOG_RSS_ENABLED` | Activer la récupération du blog. | `False` | `bool` | | `BLOG_ENABLED` | Activer la récupération du blog. | `False` | `bool` |
| `BLOG_RSS_URL` | URL du flux RSS du blog. | `https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2` | `str` | | `BLOG_RSS_URL` | URL du flux RSS du blog. | `https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2` | `str` |
#### 5 bis.9.2 Modèle Pydantic pour la configuration du blog #### 5 bis.9.2 Modèle Pydantic pour la configuration du blog
@@ -1150,7 +1182,7 @@ class Settings(BaseSettings):
```ini ```ini
# --- Blog du collège --- # --- Blog du collège ---
BLOG_RSS_ENABLED=true BLOG_ENABLED=true
BLOG_RSS_URL=https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2 BLOG_RSS_URL=https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2
``` ```
@@ -1684,6 +1716,10 @@ homeworks = collect_homeworks(lessons, target_date)
#### 5.1.5 Normalisation des UID #### 5.1.5 Normalisation des UID
> ⚠️ **Décision d'implémentation** :
> La fonction est nommée `normalize_pronote_uid` (et non `normalize_uid` comme référencé dans TODO.md).
> `generate_deterministic_uid` utilise `hashlib.sha1(payload, usedforsecurity=False)` pour satisfaire la règle bandit B324 (le hachage n'est pas utilisé pour la sécurité).
Les UID Pronote contiennent des **suffixes temporels** qui changent à chaque export. Exemple réel : Les UID Pronote contiennent des **suffixes temporels** qui changent à chaque export. Exemple réel :
``` ```
UID:Cours-16027-1-20260904T120218Z-Index-Education UID:Cours-16027-1-20260904T120218Z-Index-Education
@@ -2360,6 +2396,14 @@ class PronoteFetcher:
## 6. Modèle de données Pydantic ## 6. Modèle de données Pydantic
> ⚠️ **Décision d'implémentation (M3)** :
> Tous les modèles utilisent `model_config = ConfigDict(frozen=True)` (Pydantic v2), et non `class Config: frozen = True`.
> Pas de `json_encoders` : la sérialisation ISO pour `datetime`/`date` est native dans Pydantic v2.
> Les énumérations utilisent `enum.StrEnum` (Python 3.11+) au lieu de `(str, Enum)` (règle ruff UP042).
> Dans `HomeworkBlock`, le champ `date` utilise un alias d'import `_date` (`from datetime import date as _date`) pour éviter un conflit de nom/champ dans Pydantic v2. Même chose pour `time` → `_time` dans `TheoreticalLesson`.
> `XmppMessage.external_info` est de type `ExternalInfo | None` avec une valeur par défaut `None` (et non `default_factory=ExternalInfo`) : le pipeline passe `None` lorsqu'il n'y a pas d'informations externes.
> Les modèles sont répartis en 10 modules domaines (agenda, homework, message, blog, diff, pronote, sync, synthesis, xmpp) avec `__init__.py` réexportant les 22 noms via `__all__`.
### 6.1 Principes ### 6.1 Principes
- **Modèles distincts par domaine** : Ne pas créer un unique modèle fourre-tout. Chaque étape du pipeline utilise des modèles dédiés (décision architecturale). - **Modèles distincts par domaine** : Ne pas créer un unique modèle fourre-tout. Chaque étape du pipeline utilise des modèles dédiés (décision architecturale).
- **Validation stricte** : Utiliser Pydantic pour valider les données dès leur création. - **Validation stricte** : Utiliser Pydantic pour valider les données dès leur création.

View File

@@ -10,7 +10,7 @@ from __future__ import annotations
from typing import Literal from typing import Literal
from pydantic import SecretStr from pydantic import Field, SecretStr, field_serializer
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -23,7 +23,7 @@ class PronoteSettings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="PRONOTE_") model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="PRONOTE_")
ical_url: str | None = None ical_url: SecretStr | None = None
username: str | None = None username: str | None = None
password: SecretStr | None = None password: SecretStr | None = None
ent: str | None = None ent: str | None = None
@@ -31,6 +31,18 @@ class PronoteSettings(BaseSettings):
homework_source: Literal["auto", "ical", "pronotepy"] = "auto" homework_source: Literal["auto", "ical", "pronotepy"] = "auto"
messages_source: Literal["pronotepy"] = "pronotepy" messages_source: Literal["pronotepy"] = "pronotepy"
@field_serializer("ical_url")
def _serialize_ical_url(self, value: SecretStr | None) -> str | None:
"""Masque l'URL iCal lors de la sérialisation (repr, str, JSON).
:param value: Valeur du champ ``ical_url``.
:return: ``"**********"`` si la valeur est définie, ``None`` sinon.
:rtype: str | None
"""
if value is None:
return None
return "**********"
class CalDAVSettings(BaseSettings): class CalDAVSettings(BaseSettings):
"""Paramètres d'accès au serveur CalDAV de destination. """Paramètres d'accès au serveur CalDAV de destination.
@@ -121,12 +133,9 @@ class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore") model_config = SettingsConfigDict(env_file=".env", extra="ignore")
pronote: PronoteSettings = PronoteSettings() pronote: PronoteSettings = Field(default_factory=PronoteSettings)
caldav: CalDAVSettings = CalDAVSettings() caldav: CalDAVSettings = Field(default_factory=CalDAVSettings)
xmpp: XmppSettings = XmppSettings() xmpp: XmppSettings = Field(default_factory=XmppSettings)
ai: AISettings = AISettings() ai: AISettings = Field(default_factory=AISettings)
blog: BlogSettings = BlogSettings() blog: BlogSettings = Field(default_factory=BlogSettings)
app: AppSettings = AppSettings() app: AppSettings = Field(default_factory=AppSettings)
settings = Settings()

View File

@@ -8,7 +8,7 @@ from datetime import time as _time
from enum import StrEnum from enum import StrEnum
from typing import Literal from typing import Literal
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field, model_validator
class Status(StrEnum): class Status(StrEnum):
@@ -30,6 +30,8 @@ class LessonStatus(StrEnum):
class HomeworkBlock(BaseModel): class HomeworkBlock(BaseModel):
"""Représente un bloc de devoir extrait de la description d'un cours.""" """Représente un bloc de devoir extrait de la description d'un cours."""
model_config = ConfigDict(frozen=True)
kind: Literal["due", "assigned"] = Field( kind: Literal["due", "assigned"] = Field(
..., description="Type de bloc (échéance ou attribution)" ..., description="Type de bloc (échéance ou attribution)"
) )
@@ -47,16 +49,28 @@ class Lesson(BaseModel):
start: datetime = Field(..., description="Date/heure de début") start: datetime = Field(..., description="Date/heure de début")
end: datetime = Field(..., description="Date/heure de fin") end: datetime = Field(..., description="Date/heure de fin")
subject: str = Field(..., description="Matière") subject: str = Field(..., description="Matière")
teachers: list[str] = Field(default_factory=list, description="Liste des professeurs") teachers: tuple[str, ...] = Field(default=(), description="Liste des professeurs")
rooms: list[str] = Field(default_factory=list, description="Liste des salles") rooms: tuple[str, ...] = Field(default=(), description="Liste des salles")
group: str | None = Field(None, description="Groupe (ex: Classe entière)") group: str | None = Field(None, description="Groupe (ex: Classe entière)")
status: LessonStatus = Field(default=LessonStatus.NORMAL, description="Statut du cours") status: LessonStatus = Field(default=LessonStatus.NORMAL, description="Statut du cours")
content: str | None = Field(None, description="Contenu pédagogique") content: str | None = Field(None, description="Contenu pédagogique")
homework_blocks: list[HomeworkBlock] = Field( homework_blocks: tuple[HomeworkBlock, ...] = Field(
default_factory=list, default=(),
description="Blocs de devoirs extraits de la description", description="Blocs de devoirs extraits de la description",
) )
@model_validator(mode="after")
def _validate_time_order(self) -> Lesson:
"""Valide que l'heure de fin est postérieure à l'heure de début.
:return: L'instance validée.
:rtype: Lesson
:raises ValueError: Si ``end`` n'est pas postérieur à ``start``.
"""
if self.end <= self.start:
raise ValueError("end doit être postérieur à start")
return self
class SchoolEventKind(StrEnum): class SchoolEventKind(StrEnum):
"""Type d'événement scolaire.""" """Type d'événement scolaire."""
@@ -75,6 +89,18 @@ class SchoolEvent(BaseModel):
from_date: _date = Field(..., description="Date de début (inclusive)") from_date: _date = Field(..., description="Date de début (inclusive)")
to_date: _date = Field(..., description="Date de fin (exclusive)") to_date: _date = Field(..., description="Date de fin (exclusive)")
@model_validator(mode="after")
def _validate_date_order(self) -> SchoolEvent:
"""Valide que la date de fin est postérieure ou égale à la date de début.
:return: L'instance validée.
:rtype: SchoolEvent
:raises ValueError: Si ``to_date`` est antérieur à ``from_date``.
"""
if self.to_date < self.from_date:
raise ValueError("to_date doit être postérieur ou égal à from_date")
return self
class TheoreticalLesson(BaseModel): class TheoreticalLesson(BaseModel):
"""Représente un cours dans l'agenda théorique.""" """Représente un cours dans l'agenda théorique."""
@@ -82,9 +108,23 @@ class TheoreticalLesson(BaseModel):
model_config = ConfigDict(frozen=True) model_config = ConfigDict(frozen=True)
id: str = Field(..., description="Identifiant unique") id: str = Field(..., description="Identifiant unique")
day_of_week: int = Field(..., description="Jour de la semaine (0=lundi, 6=dimanche)") day_of_week: int = Field(
..., ge=0, le=6, description="Jour de la semaine (0=lundi, 6=dimanche)"
)
start_time: _time = Field(..., description="Heure de début") start_time: _time = Field(..., description="Heure de début")
end_time: _time = Field(..., description="Heure de fin") end_time: _time = Field(..., description="Heure de fin")
subject: str = Field(..., description="Matière") subject: str = Field(..., description="Matière")
teachers: list[str] = Field(default_factory=list, description="Liste des professeurs") teachers: tuple[str, ...] = Field(default=(), description="Liste des professeurs")
rooms: list[str] = Field(default_factory=list, description="Liste des salles") rooms: tuple[str, ...] = Field(default=(), description="Liste des salles")
@model_validator(mode="after")
def _validate_time_order(self) -> TheoreticalLesson:
"""Valide que l'heure de fin est postérieure à l'heure de début.
:return: L'instance validée.
:rtype: TheoreticalLesson
:raises ValueError: Si ``end_time`` n'est pas postérieur à ``start_time``.
"""
if self.end_time <= self.start_time:
raise ValueError("end_time doit être postérieur à start_time")
return self

View File

@@ -26,17 +26,22 @@ class BlogArticle(BaseModel):
class ExternalInfo(BaseModel): class ExternalInfo(BaseModel):
"""Agrège les informations externes (blog, messages Pronote) pour le message XMPP.""" """Agrège les informations externes (blog, messages Pronote) pour le message XMPP.
blog_articles: list[BlogArticle] = Field( Modèle figé (``frozen``) : les instances sont immuables après création.
default_factory=list, """
model_config = ConfigDict(frozen=True)
blog_articles: tuple[BlogArticle, ...] = Field(
default=(),
description="Liste des nouveaux articles du blog", description="Liste des nouveaux articles du blog",
) )
pronote_messages: list[Message] = Field( pronote_messages: tuple[Message, ...] = Field(
default_factory=list, default=(),
description="Liste des messages Pronote", description="Liste des messages Pronote",
) )
other_info: list[str] = Field( other_info: tuple[str, ...] = Field(
default_factory=list, default=(),
description="Autres informations (extensible)", description="Autres informations (extensible)",
) )

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from datetime import date from datetime import date
from enum import StrEnum from enum import StrEnum
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field, model_validator
from pronote_sync.models.agenda import Lesson, TheoreticalLesson from pronote_sync.models.agenda import Lesson, TheoreticalLesson
@@ -30,6 +30,22 @@ class AgendaChange(BaseModel):
) )
details: str = Field(default="", description="Détails du changement") details: str = Field(default="", description="Détails du changement")
@model_validator(mode="after")
def _validate_payload_consistency(self) -> AgendaChange:
"""Valide la cohérence entre le type de changement et le payload.
:return: L'instance validée.
:rtype: AgendaChange
:raises ValueError: Si le payload ne correspond pas au type de changement.
"""
if self.type in (AgendaChangeType.ADDED, AgendaChangeType.MODIFIED):
if self.lesson is None:
raise ValueError(f"lesson est requis pour le type {self.type!r}")
if self.type == AgendaChangeType.REMOVED:
if self.theoretical_lesson is None:
raise ValueError(f"theoretical_lesson est requis pour le type {self.type!r}")
return self
class AgendaDiff(BaseModel): class AgendaDiff(BaseModel):
"""Représente les différences entre l'agenda réel et l'agenda théorique.""" """Représente les différences entre l'agenda réel et l'agenda théorique."""
@@ -37,4 +53,4 @@ class AgendaDiff(BaseModel):
model_config = ConfigDict(frozen=True) model_config = ConfigDict(frozen=True)
target_date: date = Field(..., description="Date cible de la comparaison") target_date: date = Field(..., description="Date cible de la comparaison")
changes: list[AgendaChange] = Field(default_factory=list, description="Liste des changements") changes: tuple[AgendaChange, ...] = Field(default=(), description="Liste des changements")

View File

@@ -14,7 +14,7 @@ class Homework(BaseModel):
id: str = Field(..., description="ID stable (hachage)") id: str = Field(..., description="ID stable (hachage)")
subject: str = Field(..., description="Matière") subject: str = Field(..., description="Matière")
teachers: list[str] = Field(default_factory=list, description="Liste des professeurs") teachers: tuple[str, ...] = Field(default=(), description="Liste des professeurs")
assigned_on: date | None = Field(None, description="Date de distribution") assigned_on: date | None = Field(None, description="Date de distribution")
due_on: date = Field(..., description="Date d'échéance") due_on: date = Field(..., description="Date d'échéance")
text: str = Field(..., description="Texte du devoir (brut)") text: str = Field(..., description="Texte du devoir (brut)")

View File

@@ -36,7 +36,7 @@ class CalDAVSyncResult(BaseModel):
"""Résultat d'une synchronisation CalDAV (mutable).""" """Résultat d'une synchronisation CalDAV (mutable)."""
status: CalDAVSyncStatus = Field(..., description="Statut global") status: CalDAVSyncStatus = Field(..., description="Statut global")
added: int = Field(0, description="Nombre d'événements ajoutés") added: int = Field(0, ge=0, description="Nombre d'événements ajoutés")
updated: int = Field(0, description="Nombre d'événements mis à jour") updated: int = Field(0, ge=0, description="Nombre d'événements mis à jour")
removed: int = Field(0, description="Nombre d'événements supprimés") removed: int = Field(0, ge=0, description="Nombre d'événements supprimés")
errors: list[str] = Field(default_factory=list, description="Liste des erreurs") errors: list[str] = Field(default_factory=list, description="Liste des erreurs")

View File

@@ -19,13 +19,11 @@ class XmppMessage(BaseModel):
target_date: date = Field(..., description="Date cible") target_date: date = Field(..., description="Date cible")
synthesis: str | None = Field(None, description="Synthèse IA (optionnelle, 3-5 phrases)") synthesis: str | None = Field(None, description="Synthèse IA (optionnelle, 3-5 phrases)")
homeworks: list[Homework] = Field(default_factory=list, description="Liste brute des devoirs") homeworks: tuple[Homework, ...] = Field(default=(), description="Liste brute des devoirs")
changes: list[AgendaChange] = Field( changes: tuple[AgendaChange, ...] = Field(
default_factory=list, description="Liste des changements d'agenda" default=(), description="Liste des changements d'agenda"
)
messages: list[Message] = Field(
default_factory=list, description="Liste des messages/informations"
) )
messages: tuple[Message, ...] = Field(default=(), description="Liste des messages/informations")
external_info: ExternalInfo | None = Field( external_info: ExternalInfo | None = Field(
None, description="Informations externes (blog, messages Pronote)" None, description="Informations externes (blog, messages Pronote)"
) )

View File

@@ -22,26 +22,15 @@ class RedactingFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str: def format(self, record: logging.LogRecord) -> str:
"""Formate un enregistrement de log en masquant les secrets. """Formate un enregistrement de log en masquant les secrets.
Le message et chaque argument textuel de l'enregistrement sont rédigés La rédaction est appliquée à la chaîne finale (message, arguments et
avant le formatage final effectué par :class:`logging.Formatter`. traceback inclus) produite par :class:`logging.Formatter`.
:param record: Enregistrement de log à formater. :param record: Enregistrement de log à formater.
:return: Message formaté, avec les secrets remplacés par ``REDACTED``. :return: Message formaté, avec les secrets remplacés par ``REDACTED``.
:rtype: str :rtype: str
""" """
record.msg = redact_secrets(str(record.msg)) formatted = super().format(record)
args = record.args return redact_secrets(formatted)
if args:
if isinstance(args, tuple):
record.args = tuple(
redact_secrets(arg) if isinstance(arg, str) else arg for arg in args
)
else:
record.args = {
key: redact_secrets(value) if isinstance(value, str) else value
for key, value in args.items()
}
return super().format(record)
def setup_logging(level: str = "INFO") -> None: def setup_logging(level: str = "INFO") -> None:

View File

@@ -10,10 +10,30 @@ from __future__ import annotations
import re import re
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
_SENSITIVE_QUERY_KEYS = frozenset({"icalsecurise", "token", "key", "password", "secret"}) _SENSITIVE_QUERY_KEYS = frozenset(
_URL_PATTERN = re.compile(r"https?://[^\s]+") {
"icalsecurise",
"token",
"key",
"password",
"secret",
"api_key",
"apikey",
"access_token",
"auth",
"authorization",
}
)
_URL_PATTERN = re.compile(r"https?://[^\s]+", re.IGNORECASE)
_AUTH_HEADER_PATTERN = re.compile(
r"((?:Proxy-)?Authorization)\s*[:=]\s*\S[^\r\n]*",
re.IGNORECASE,
)
_ISOLATED_SECRET_PATTERN = re.compile( _ISOLATED_SECRET_PATTERN = re.compile(
r"\b(icalsecurise|token|password|secret|key)\s*=\s*[^\s&]+", r"\b(icalsecurise|access_token|api_key|apikey|authorization|token|password|secret|key|auth)"
r"(\s*['\"]?\s*[:=]\s*)"
r"(['\"]?)"
r"([^\s&'\"]+)",
re.IGNORECASE, re.IGNORECASE,
) )
_REDACTED = "REDACTED" _REDACTED = "REDACTED"
@@ -21,15 +41,28 @@ _REDACTED_URL = "REDACTED_URL"
def redact_url(url: str) -> str: def redact_url(url: str) -> str:
"""Masque les paramètres sensibles dans une URL. """Masque les identifiants et les paramètres sensibles d'une URL.
:param url: URL pouvant contenir des paramètres sensibles (ex: ``icalsecurise``). Les informations d'authentification du netloc (``utilisateur:motdepasse@hôte``)
:return: URL avec les paramètres sensibles remplacés par ``REDACTED``, sont entièrement masquées (utilisateur et mot de passe), ainsi que les
paramètres sensibles de la requête (ex: ``icalsecurise``).
:param url: URL pouvant contenir des informations sensibles (ex: ``icalsecurise``).
:return: URL avec les éléments sensibles remplacés par ``REDACTED``,
ou ``REDACTED_URL`` si le traitement échoue. ou ``REDACTED_URL`` si le traitement échoue.
:rtype: str :rtype: str
""" """
try: try:
parts = urlsplit(url) parts = urlsplit(url)
if parts.username is not None or parts.password is not None:
# Netloc sûr : REDACTED@hôte:port. L'utilisateur et le mot de
# passe sont entièrement masqués.
host = parts.hostname or ""
if parts.port is not None:
netloc = f"{_REDACTED}@{host}:{parts.port}"
else:
netloc = f"{_REDACTED}@{host}"
parts = parts._replace(netloc=netloc)
query: list[tuple[str, str]] = parse_qsl(parts.query, keep_blank_values=True) query: list[tuple[str, str]] = parse_qsl(parts.query, keep_blank_values=True)
redacted_query = [ redacted_query = [
(key, _REDACTED if key.lower() in _SENSITIVE_QUERY_KEYS else value) (key, _REDACTED if key.lower() in _SENSITIVE_QUERY_KEYS else value)
@@ -43,16 +76,19 @@ def redact_url(url: str) -> str:
def redact_secrets(text: str) -> str: def redact_secrets(text: str) -> str:
"""Masque les secrets présents dans un texte arbitraire. """Masque les secrets présents dans un texte arbitraire.
Les URLs sont d'abord traitées par :func:`redact_url`, puis les affectations Les URLs sont d'abord traitées par :func:`redact_url`, puis les en-têtes
isolées de type ``cle=valeur`` (ex: ``icalsecurise=XXX``) sont masquées, d'authentification (``Authorization``, ``Proxy-Authorization``) et les
sans distinction de casse. affectations isolées de type ``cle=valeur`` ou ``cle:valeur``
(ex: ``icalsecurise=XXX``, ``"token": "XXX"``) sont masquées, sans
distinction de casse.
:param text: Texte pouvant contenir des URLs ou des secrets en clair. :param text: Texte pouvant contenir des URLs ou des secrets en clair.
:return: Texte avec les secrets remplacés par ``REDACTED``. :return: Texte avec les secrets remplacés par ``REDACTED``.
:rtype: str :rtype: str
""" """
redacted = _URL_PATTERN.sub(lambda match: redact_url(match.group(0)), text) redacted = _URL_PATTERN.sub(lambda match: redact_url(match.group(0)), text)
return _ISOLATED_SECRET_PATTERN.sub(r"\1=REDACTED", redacted) redacted = _AUTH_HEADER_PATTERN.sub(r"\1: REDACTED", redacted)
return _ISOLATED_SECRET_PATTERN.sub(r"\1\2\3REDACTED", redacted)
def redact_exception(exc: Exception) -> str: def redact_exception(exc: Exception) -> str:

View File

@@ -7,7 +7,7 @@ name = "pronote-sync"
version = "0.1.0" version = "0.1.0"
description = "Synchronisation Pronote → CalDAV + XMPP" description = "Synchronisation Pronote → CalDAV + XMPP"
license = {text = "MIT"} license = {text = "MIT"}
requires-python = ">=3.13" requires-python = ">=3.13.5"
authors = [ authors = [
{name = "Votre Nom", email = "votre@email.com"} {name = "Votre Nom", email = "votre@email.com"}
] ]

0
tests/unit/__init__.py Normal file
View File

119
tests/unit/test_config.py Normal file
View File

@@ -0,0 +1,119 @@
"""Tests unitaires pour la configuration et le chargement des secrets.
Ce module vérifie que la configuration est chargée correctement depuis les
variables d'environnement, que les secrets sont masqués dans les représentations
textuelles et sérialisées, et que le rechargement fonctionne comme attendu.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from pydantic import SecretStr
from pronote_sync.config.env import load_settings
from pronote_sync.config.settings import PronoteSettings, Settings
if TYPE_CHECKING:
from _pytest.monkeypatch import MonkeyPatch
def test_env_prefix_loading(monkeypatch: MonkeyPatch) -> None:
"""Vérifie que les variables d'environnement avec préfixe sont chargées correctement.
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
:return: None
"""
monkeypatch.setenv("PRONOTE_USERNAME", "testuser")
settings = load_settings()
assert settings.pronote.username == "testuser"
def test_env_reload_via_load_settings(monkeypatch: MonkeyPatch) -> None:
"""Vérifie que le rechargement de la configuration lit les nouvelles valeurs.
Ce test garantit que les champs configurés avec ``default_factory`` créent
des instances fraîches à chaque appel de ``load_settings()``, permettant ainsi
un rechargement dynamique des variables d'environnement.
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
:return: None
"""
# Premier chargement (valeur par défaut None)
settings1 = load_settings()
assert settings1.pronote.username is None
# Modification de l'environnement
monkeypatch.setenv("PRONOTE_USERNAME", "newuser")
# Rechargement
settings2 = load_settings()
assert settings2.pronote.username == "newuser"
def test_secretstr_masking_ical_url() -> None:
"""Vérifie que l'URL iCal contenant un secret est masquée dans les représentations.
:return: None
"""
ical_url = "https://example.fr/ical?icalsecurise=SECRET_TOKEN" # pragma: allowlist secret
settings = Settings(pronote=PronoteSettings(ical_url=SecretStr(ical_url)))
# Vérification de la représentation string
str_repr = str(settings)
assert "SECRET_TOKEN" not in str_repr
assert "**********" in str_repr
# Vérification de la représentation repr
repr_repr = repr(settings)
assert "SECRET_TOKEN" not in repr_repr
assert "**********" in repr_repr
# Vérification de la sérialisation JSON
json_str = settings.model_dump_json()
assert "SECRET_TOKEN" not in json_str
assert "**********" in json_str
def test_secretstr_masking_password() -> None:
"""Vérifie que le mot de passe est masqué dans les représentations.
:return: None
"""
password = "mypass123" # pragma: allowlist secret
settings = Settings(pronote=PronoteSettings(password=SecretStr(password)))
# Vérification de la représentation string
str_repr = str(settings)
assert "mypass123" not in str_repr
assert "**********" in str_repr
# Vérification de la représentation repr
repr_repr = repr(settings)
assert "mypass123" not in repr_repr
assert "**********" in repr_repr
# Vérification de la sérialisation JSON
json_str = settings.model_dump_json()
assert "mypass123" not in json_str
assert "**********" in json_str
def test_no_singleton_import() -> None:
"""Vérifie que l'import du singleton settings lève une ImportError.
Ce test garantit que le pattern singleton a été supprimé et que la
configuration doit être chargée explicitement via ``load_settings()``.
:return: None
"""
with pytest.raises(ImportError):
# Attempt to import a non-existent attribute to trigger ImportError
# as required by the spec
from pronote_sync.config.settings import ( # type: ignore[attr-defined] # noqa: F401
settings,
)
# Ensure trailing newline

View File

@@ -0,0 +1,373 @@
"""Tests de construction et sérialisation JSON des modèles Pydantic.
Ce module vérifie que chaque modèle figé peut être sérialisé en JSON puis désérialisé
sans perte de données, garantissant la stabilité des contrats d'échange.
Les tests utilisent des données minimales valides extraites des définitions de modèles.
"""
from __future__ import annotations
from datetime import date, datetime, time
from typing import Any
import pytest
from pydantic import BaseModel
from pronote_sync.models.agenda import (
HomeworkBlock,
Lesson,
LessonStatus,
SchoolEvent,
SchoolEventKind,
TheoreticalLesson,
)
from pronote_sync.models.blog import BlogArticle, ExternalInfo
from pronote_sync.models.diff import AgendaChange, AgendaChangeType, AgendaDiff
from pronote_sync.models.homework import Homework
from pronote_sync.models.message import Message, MessageType
from pronote_sync.models.xmpp import XmppMessage
@pytest.mark.parametrize(
"model_class, kwargs",
[
(
Lesson,
{
"id": "lesson-001",
"start": datetime(2024, 9, 6, 8, 0, 0),
"end": datetime(2024, 9, 6, 9, 30, 0),
"subject": "Mathématiques",
"teachers": ("M. Dupont",),
"rooms": ("Salle 101",),
"group": "Classe A",
"status": LessonStatus.NORMAL,
"content": "Algèbre linéaire",
"homework_blocks": (
HomeworkBlock(kind="due", date=date(2024, 9, 13), text="Exercice 42"),
),
},
),
(
TheoreticalLesson,
{
"id": "theo-lesson-001",
"day_of_week": 0, # Lundi
"start_time": time(8, 0, 0),
"end_time": time(9, 30, 0),
"subject": "Mathématiques",
"teachers": ("M. Dupont",),
"rooms": ("Salle 101",),
},
),
(
SchoolEvent,
{
"kind": SchoolEventKind.HOLIDAY,
"label": "Vacances de la Toussaint",
"from_date": date(2024, 10, 19),
"to_date": date(2024, 11, 3),
},
),
(
Homework,
{
"id": "hw-001",
"subject": "Mathématiques",
"teachers": ("M. Dupont",),
"assigned_on": date(2024, 9, 1),
"due_on": date(2024, 9, 15),
"text": "Exercice 42 à rendre",
"html": "<p>Exercice 42 à rendre</p>",
},
),
(
AgendaChange,
{
"type": AgendaChangeType.ADDED,
"lesson": Lesson(
id="lesson-002",
start=datetime(2024, 9, 7, 10, 0, 0),
end=datetime(2024, 9, 7, 11, 30, 0),
subject="Physique",
teachers=("M. Martin",),
rooms=("Labo 205",),
group=None,
content=None,
),
"theoretical_lesson": None,
"details": "Cours ajouté exceptionnellement",
},
),
(
AgendaDiff,
{
"target_date": date(2024, 9, 6),
"changes": (
AgendaChange(
type=AgendaChangeType.ADDED,
lesson=Lesson(
id="lesson-003",
start=datetime(2024, 9, 8, 14, 0, 0),
end=datetime(2024, 9, 8, 15, 30, 0),
subject="Histoire",
teachers=("Mme Bernard",),
rooms=("Salle 201",),
group=None,
content=None,
),
theoretical_lesson=None,
),
),
},
),
(
XmppMessage,
{
"target_date": date(2024, 9, 6),
"synthesis": "Aucun changement majeur aujourd'hui.",
"homeworks": (
Homework(
id="hw-002",
subject="Physique",
teachers=("M. Martin",),
assigned_on=None,
due_on=date(2024, 9, 7),
text="TP à préparer",
),
),
"changes": (
AgendaChange(
type=AgendaChangeType.MODIFIED,
lesson=Lesson(
id="lesson-004",
start=datetime(2024, 9, 6, 16, 0, 0),
end=datetime(2024, 9, 6, 17, 30, 0),
subject="SVT",
teachers=("M. Lefèvre",),
rooms=("Salle 302",),
group=None,
content=None,
),
theoretical_lesson=None,
),
),
"messages": (
Message(
id="msg-001",
type=MessageType.INFORMATION,
title="Rappel",
content="Réunion parents-professeurs demain",
author="Secrétariat",
date=datetime(2024, 9, 5, 10, 0, 0),
read=False,
),
),
"external_info": ExternalInfo(blog_articles=(), pronote_messages=(), other_info=()),
},
),
(
BlogArticle,
{
"id": "article-001",
"title": "Sortie scolaire à Paris",
"url": "https://college.fr/blog/sortie-paris",
"published_at": datetime(2024, 9, 5, 14, 30, 0),
"updated_at": datetime(2024, 9, 5, 16, 45, 0),
"category": "Actualités",
"author": "M. Dubois",
"content_html": "<p>Sortie prévue le 15 octobre</p>",
"content_text": "Sortie prévue le 15 octobre",
},
),
(
ExternalInfo,
{
"blog_articles": (
BlogArticle(
id="article-002",
title="Nouveau règlement intérieur",
url="https://college.fr/blog/reglement",
published_at=datetime(2024, 9, 1, 9, 0, 0),
updated_at=None,
category=None,
author=None,
content_html="<p>Mise à jour du règlement</p>",
content_text="Mise à jour du règlement",
),
),
"pronote_messages": (
Message(
id="msg-002",
type=MessageType.DISCUSSION,
title="Devoirs de maths",
content="Voir Pronote pour les détails",
author="Prof Maths",
date=datetime(2024, 9, 4, 8, 0, 0),
read=False,
),
),
"other_info": ("Info complémentaire 1", "Info complémentaire 2"),
},
),
(
Message,
{
"id": "msg-003",
"type": MessageType.SURVEY,
"title": "Enquête de satisfaction",
"content": "Merci de répondre au questionnaire",
"author": "Direction",
"date": datetime(2024, 9, 5, 11, 0, 0),
"read": False,
},
),
],
)
def test_model_json_roundtrip(model_class: type[BaseModel], kwargs: dict[str, Any]) -> None:
"""Vérifie qu'un modèle peut être sérialisé en JSON puis désérialisé sans perte.
:param model_class: Classe du modèle à tester.
:type model_class: type[BaseModel]
:param kwargs: Arguments de constructeur valides.
:type kwargs: dict[str, Any]
"""
# Construction de l'instance
instance = model_class(**kwargs)
# Sérialisation JSON
json_str = instance.model_dump_json()
# Désérialisation depuis JSON
roundtrip_instance = model_class.model_validate_json(json_str)
# Vérification des champs clés (comparaison par valeur)
for field_name in kwargs:
if field_name in (
"start",
"end",
"start_time",
"end_time",
"published_at",
"updated_at",
"date",
"due_on",
"assigned_on",
"generated_at",
):
# Pour les dates/heures, comparer les objets directement
assert getattr(instance, field_name) == getattr(roundtrip_instance, field_name), (
f"Le champ {field_name} ne correspond pas après round-trip"
)
else:
# Pour les autres champs, comparer directement
assert getattr(instance, field_name) == getattr(roundtrip_instance, field_name), (
f"Le champ {field_name} ne correspond pas après round-trip"
)
def test_lesson_status_enum_values() -> None:
"""Vérifie que les valeurs de l'enum LessonStatus sont correctes.
:raises ValidationError: Si une valeur invalide est utilisée.
"""
# Test des valeurs valides
for status in [LessonStatus.NORMAL, LessonStatus.CANCELLED, LessonStatus.MOVED]:
assert isinstance(status.value, str)
Lesson(
id="test",
start=datetime(2024, 9, 6, 8, 0),
end=datetime(2024, 9, 6, 9, 0),
subject="Test",
status=status,
group=None,
content=None,
)
def test_school_event_kind_enum_values() -> None:
"""Vérifie que les valeurs de l'enum SchoolEventKind sont correctes.
:raises ValidationError: Si une valeur invalide est utilisée.
"""
# Test des valeurs valides
for kind in [SchoolEventKind.HOLIDAY, SchoolEventKind.PUBLIC_HOLIDAY]:
assert isinstance(kind.value, str)
SchoolEvent(
kind=kind,
label="Test",
from_date=date(2024, 9, 1),
to_date=date(2024, 9, 2),
)
def test_message_type_enum_values() -> None:
"""Vérifie que les valeurs de l'enum MessageType sont correctes.
:raises ValidationError: Si une valeur invalide est utilisée.
"""
# Test des valeurs valides
for msg_type in [MessageType.DISCUSSION, MessageType.INFORMATION, MessageType.SURVEY]:
assert isinstance(msg_type.value, str)
Message(
id="test",
type=msg_type,
title="Test",
content="Test",
author="Test",
date=datetime(2024, 9, 1, 8, 0),
read=False,
)
def test_agenda_change_type_enum_values() -> None:
"""Vérifie que les valeurs de l'enum AgendaChangeType sont correctes.
:raises ValidationError: Si une valeur invalide est utilisée.
"""
# Test des valeurs valides
for change_type in [
AgendaChangeType.ADDED,
AgendaChangeType.REMOVED,
AgendaChangeType.MODIFIED,
]:
assert isinstance(change_type.value, str)
if change_type == AgendaChangeType.ADDED:
AgendaChange(
type=change_type,
lesson=Lesson(
id="test",
start=datetime(2024, 9, 6, 8, 0),
end=datetime(2024, 9, 6, 9, 0),
subject="Test",
group=None,
content=None,
),
theoretical_lesson=None,
)
elif change_type == AgendaChangeType.REMOVED:
AgendaChange(
type=change_type,
lesson=None,
theoretical_lesson=TheoreticalLesson(
id="test",
day_of_week=0,
start_time=time(8, 0, 0),
end_time=time(9, 0, 0),
subject="Test",
),
)
else:
AgendaChange(
type=change_type,
lesson=Lesson(
id="test",
start=datetime(2024, 9, 6, 8, 0),
end=datetime(2024, 9, 6, 9, 0),
subject="Test",
group=None,
content=None,
),
theoretical_lesson=None,
)

View File

@@ -0,0 +1,409 @@
"""Tests d'immuabilité des modèles Pydantic figés.
Ce module vérifie que les modèles marqués comme figés (frozen=True) ne peuvent pas
être modifiés après construction, et que leurs collections sont bien des tuples.
"""
from __future__ import annotations
from datetime import date, datetime
from typing import Any
import pytest
from pydantic import BaseModel, ValidationError
from pronote_sync.models.agenda import HomeworkBlock, Lesson, LessonStatus
from pronote_sync.models.blog import BlogArticle, ExternalInfo
from pronote_sync.models.diff import AgendaChange, AgendaChangeType
from pronote_sync.models.homework import Homework
from pronote_sync.models.message import Message, MessageType
from pronote_sync.models.pronote import PronoteData
from pronote_sync.models.sync import CalDAVSyncResult, CalDAVSyncStatus
from pronote_sync.models.xmpp import XmppMessage
@pytest.mark.parametrize(
"model_class, kwargs",
[
(
Lesson,
{
"id": "lesson-immutability",
"start": datetime(2024, 9, 6, 8, 0, 0),
"end": datetime(2024, 9, 6, 9, 30, 0),
"subject": "Mathématiques",
"teachers": ("M. Dupont", "Mme Martin"),
"rooms": ("Salle 101",),
"status": LessonStatus.NORMAL,
},
),
(
Homework,
{
"id": "hw-immutability",
"subject": "Physique",
"teachers": ("M. Martin",),
"due_on": date(2024, 9, 15),
"text": "Exercice à rendre",
},
),
(
AgendaChange,
{
"type": AgendaChangeType.ADDED,
"lesson": Lesson(
id="lesson-ref",
start=datetime(2024, 9, 6, 10, 0, 0),
end=datetime(2024, 9, 6, 11, 30, 0),
subject="Histoire",
teachers=("Mme Bernard",),
rooms=("Salle 201",),
group=None,
content=None,
),
},
),
(
XmppMessage,
{
"target_date": date(2024, 9, 6),
"synthesis": "Test immuabilité",
},
),
(
HomeworkBlock,
{
"kind": "due",
"date": date(2024, 9, 15),
"text": "Devoir à faire",
},
),
(
ExternalInfo,
{
"blog_articles": (
BlogArticle(
id="article-1",
title="Article 1",
url="https://example.com/1",
published_at=datetime(2024, 9, 1, 8, 0, 0),
updated_at=None,
category=None,
author=None,
content_html="<p>Contenu</p>",
content_text="Contenu",
),
),
"pronote_messages": (
Message(
id="msg-1",
type=MessageType.INFORMATION,
title="Message 1",
content="Message",
author="Auteur",
date=datetime(2024, 9, 1, 8, 0, 0),
read=False,
),
),
},
),
],
)
def test_frozen_model_rejects_field_assignment(
model_class: type[BaseModel], kwargs: dict[str, Any]
) -> None:
"""Vérifie qu'un modèle figé rejette toute tentative d'assignation de champ.
:param model_class: Classe du modèle figé.
:type model_class: type[BaseModel]
:param kwargs: Arguments de constructeur valides.
:type kwargs: dict[str, Any]
"""
instance: Any = model_class(**kwargs)
# Tentative d'assignation directe sur un champ
with pytest.raises(ValidationError) as exc_info:
if model_class == Lesson:
instance.subject = "Nouveau sujet"
elif model_class == Homework:
instance.subject = "Nouveau sujet"
elif model_class == AgendaChange:
instance.details = "Nouveaux détails"
elif model_class == XmppMessage:
instance.synthesis = "Nouvelle synthèse"
elif model_class == HomeworkBlock:
instance.text = "Nouveau texte"
elif model_class == ExternalInfo:
instance.other_info = ("Nouvelle info",)
error_msg = str(exc_info.value).lower()
assert "instance is frozen" in error_msg or "immutable" in error_msg
@pytest.mark.parametrize(
"model_class, kwargs, field_name, expected_type",
[
(
Lesson,
{
"id": "lesson-tuple",
"start": datetime(2024, 9, 6, 8, 0),
"end": datetime(2024, 9, 6, 9, 0),
"subject": "Test",
},
"teachers",
tuple,
),
(
Lesson,
{
"id": "lesson-tuple2",
"start": datetime(2024, 9, 6, 8, 0),
"end": datetime(2024, 9, 6, 9, 0),
"subject": "Test",
},
"rooms",
tuple,
),
(
Lesson,
{
"id": "lesson-tuple3",
"start": datetime(2024, 9, 6, 8, 0),
"end": datetime(2024, 9, 6, 9, 0),
"subject": "Test",
"homework_blocks": (),
},
"homework_blocks",
tuple,
),
(
Homework,
{
"id": "hw-tuple",
"subject": "Test",
"teachers": ("Prof 1",),
"due_on": date(2024, 9, 15),
"text": "Devoir",
},
"teachers",
tuple,
),
(
AgendaChange,
{
"type": AgendaChangeType.ADDED,
"lesson": Lesson(
id="ref",
start=datetime(2024, 9, 6, 8, 0),
end=datetime(2024, 9, 6, 9, 0),
subject="Test",
group=None,
content=None,
),
},
"details",
str,
),
(XmppMessage, {"target_date": date(2024, 9, 6)}, "homeworks", tuple),
(XmppMessage, {"target_date": date(2024, 9, 6)}, "changes", tuple),
(XmppMessage, {"target_date": date(2024, 9, 6)}, "messages", tuple),
(
ExternalInfo,
{"blog_articles": (), "pronote_messages": (), "other_info": ()},
"blog_articles",
tuple,
),
(
ExternalInfo,
{"blog_articles": (), "pronote_messages": (), "other_info": ()},
"pronote_messages",
tuple,
),
(
ExternalInfo,
{"blog_articles": (), "pronote_messages": (), "other_info": ()},
"other_info",
tuple,
),
],
)
def test_collection_fields_are_tuples(
model_class: type[BaseModel],
kwargs: dict[str, Any],
field_name: str,
expected_type: type[Any],
) -> None:
"""Vérifie que les champs de collection sont bien des tuples et non des listes.
:param model_class: Classe du modèle.
:type model_class: type[BaseModel]
:param kwargs: Arguments de constructeur valides.
:type kwargs: dict[str, Any]
:param field_name: Nom du champ de collection à vérifier.
:type field_name: str
:param expected_type: Type attendu (tuple).
:type expected_type: type
"""
instance = model_class(**kwargs)
field_value = getattr(instance, field_name)
assert isinstance(field_value, expected_type), (
f"Le champ {field_name} devrait être de type {expected_type.__name__}, "
f"got {type(field_value).__name__}"
)
@pytest.mark.parametrize(
"model_class, kwargs, field_name",
[
(
Lesson,
{
"id": "lesson-no-append",
"start": datetime(2024, 9, 6, 8, 0),
"end": datetime(2024, 9, 6, 9, 0),
"subject": "Test",
},
"teachers",
),
(
Lesson,
{
"id": "lesson-no-append2",
"start": datetime(2024, 9, 6, 8, 0),
"end": datetime(2024, 9, 6, 9, 0),
"subject": "Test",
},
"rooms",
),
(
Homework,
{
"id": "hw-no-append",
"subject": "Test",
"teachers": ("Prof 1",),
"due_on": date(2024, 9, 15),
"text": "Devoir",
},
"teachers",
),
(
ExternalInfo,
{"blog_articles": (), "pronote_messages": (), "other_info": ()},
"blog_articles",
),
(
ExternalInfo,
{"blog_articles": (), "pronote_messages": (), "other_info": ()},
"pronote_messages",
),
(
ExternalInfo,
{"blog_articles": (), "pronote_messages": (), "other_info": ()},
"other_info",
),
],
)
def test_collection_fields_reject_append(
model_class: type[BaseModel], kwargs: dict[str, Any], field_name: str
) -> None:
"""Vérifie que les champs de collection (tuples) rejettent la méthode append.
:param model_class: Classe du modèle.
:type model_class: type[BaseModel]
:param kwargs: Arguments de constructeur valides.
:type kwargs: dict[str, Any]
:param field_name: Nom du champ de collection.
:type field_name: str
"""
instance = model_class(**kwargs)
with pytest.raises(AttributeError) as exc_info:
collection = getattr(instance, field_name)
collection.append("new_item")
assert "'tuple' object has no attribute 'append'" in str(exc_info.value)
@pytest.mark.parametrize(
"model_class, kwargs, field_name, new_value",
[
(CalDAVSyncResult, {"status": CalDAVSyncStatus.SUCCESS}, "added", 5),
(CalDAVSyncResult, {"status": CalDAVSyncStatus.SUCCESS}, "updated", 3),
(CalDAVSyncResult, {"status": CalDAVSyncStatus.SUCCESS}, "removed", 2),
(
CalDAVSyncResult,
{"status": CalDAVSyncStatus.FAILED},
"errors",
["Erreur 1", "Erreur 2"],
),
],
)
def test_mutable_models_accept_field_assignment(
model_class: type[BaseModel],
kwargs: dict[str, Any],
field_name: str,
new_value: Any,
) -> None:
"""Vérifie que les modèles mutables (non figés) acceptent l'assignation de champs.
:param model_class: Classe du modèle mutable.
:type model_class: type[BaseModel]
:param kwargs: Arguments de constructeur valides.
:type kwargs: dict[str, Any]
:param field_name: Nom du champ à assigner.
:type field_name: str
:param new_value: Nouvelle valeur à assigner.
:type new_value: Any
"""
instance = model_class(**kwargs)
# Assignation du champ
setattr(instance, field_name, new_value)
# Vérification que la valeur a bien été assignée
assert getattr(instance, field_name) == new_value, (
f"Le champ {field_name} n'a pas été correctement assigné"
)
@pytest.mark.parametrize(
"model_class, kwargs",
[
(
PronoteData,
{
"target_date": date(2024, 9, 6),
"generated_at": datetime(2024, 9, 6, 8, 0, 0),
},
),
(CalDAVSyncResult, {"status": CalDAVSyncStatus.SUCCESS}),
],
)
def test_mutable_models_reject_frozen_assignment(
model_class: type[BaseModel], kwargs: dict[str, Any]
) -> None:
"""Vérifie que les modèles mutables peuvent être modifiés mais pas rendus figés.
Note: Les modèles mutables comme PronoteData et CalDAVSyncResult n'ont pas
ConfigDict(frozen=True), donc ils peuvent être modifiés.
:param model_class: Classe du modèle mutable.
:type model_class: type[BaseModel]
:param kwargs: Arguments de constructeur valides.
:type kwargs: dict[str, Any]
"""
from pronote_sync.models.pronote import PronoteData
instance: Any = model_class(**kwargs)
# Assignation normale doit fonctionner
if model_class == PronoteData:
instance.lessons.append("dummy")
else:
instance.added = 10
# Vérification que l'instance n'est pas figée
assert not hasattr(instance.model_config, "frozen") or not instance.model_config.get(
"frozen", False
)

View File

@@ -0,0 +1,287 @@
"""Tests d'invariants et de validation des modèles Pydantic.
Ce module vérifie que les validateurs de modèle rejettent les données invalides
conformément aux spécifications (ex: ordre des dates, plages de valeurs).
"""
from __future__ import annotations
from datetime import date, datetime, time
import pytest
from pydantic import ValidationError
from pronote_sync.models.agenda import (
Lesson,
SchoolEvent,
SchoolEventKind,
TheoreticalLesson,
)
from pronote_sync.models.diff import AgendaChange, AgendaChangeType
from pronote_sync.models.sync import CalDAVSyncResult, CalDAVSyncStatus
class TestTheoreticalLessonDayOfWeek:
"""Tests de validation du champ day_of_week pour TheoreticalLesson."""
def test_day_of_week_7_invalid(self) -> None:
"""Vérifie que day_of_week=7 lève une ValidationError."""
with pytest.raises(ValidationError):
TheoreticalLesson(
id="test",
day_of_week=7,
start_time=time(8, 0, 0),
end_time=time(9, 30, 0),
subject="Mathématiques",
)
def test_day_of_week_minus_1_invalid(self) -> None:
"""Vérifie que day_of_week=-1 lève une ValidationError."""
with pytest.raises(ValidationError) as exc_info:
TheoreticalLesson(
id="test",
day_of_week=-1,
start_time=time(8, 0, 0),
end_time=time(9, 30, 0),
subject="Mathématiques",
)
assert any(
"greater than or equal to 0" in str(error).lower() or "le 6" in str(error).lower()
for error in exc_info.value.errors()
)
def test_day_of_week_0_valid(self) -> None:
"""Vérifie que day_of_week=0 (lundi) est valide."""
instance = TheoreticalLesson(
id="test",
day_of_week=0,
start_time=time(8, 0, 0),
end_time=time(9, 30, 0),
subject="Mathématiques",
)
assert instance.day_of_week == 0
def test_day_of_week_6_valid(self) -> None:
"""Vérifie que day_of_week=6 (dimanche) est valide."""
instance = TheoreticalLesson(
id="test",
day_of_week=6,
start_time=time(8, 0, 0),
end_time=time(9, 30, 0),
subject="Mathématiques",
)
assert instance.day_of_week == 6
class TestLessonTimeOrder:
"""Tests de validation de l'ordre temporel pour Lesson."""
def test_lesson_end_before_start_invalid(self) -> None:
"""Vérifie que end <= start lève une ValidationError."""
with pytest.raises(ValidationError) as exc_info:
Lesson(
id="test",
start=datetime(2024, 9, 6, 10, 0, 0),
end=datetime(2024, 9, 6, 9, 0, 0), # end avant start
subject="Mathématiques",
group=None,
content=None,
)
assert any(
"end doit être postérieur à start" in str(error) for error in exc_info.value.errors()
)
def test_lesson_end_after_start_valid(self) -> None:
"""Vérifie que end > start est valide."""
instance = Lesson(
id="test",
start=datetime(2024, 9, 6, 8, 0, 0),
end=datetime(2024, 9, 6, 9, 30, 0),
subject="Mathématiques",
group=None,
content=None,
)
assert instance.end > instance.start
class TestTheoreticalLessonTimeOrder:
"""Tests de validation de l'ordre temporel pour TheoreticalLesson."""
def test_theoretical_lesson_end_time_before_start_time_invalid(self) -> None:
"""Vérifie que end_time <= start_time lève une ValidationError."""
with pytest.raises(ValidationError) as exc_info:
TheoreticalLesson(
id="test",
day_of_week=0,
start_time=time(9, 0, 0),
end_time=time(8, 0, 0), # end_time avant start_time
subject="Mathématiques",
)
assert any(
"end_time doit être postérieur à start_time" in str(error)
for error in exc_info.value.errors()
)
def test_theoretical_lesson_end_time_after_start_time_valid(self) -> None:
"""Vérifie que end_time > start_time est valide."""
instance = TheoreticalLesson(
id="test",
day_of_week=0,
start_time=time(8, 0, 0),
end_time=time(9, 30, 0),
subject="Mathématiques",
)
assert instance.end_time > instance.start_time
class TestSchoolEventDateOrder:
"""Tests de validation de l'ordre des dates pour SchoolEvent."""
def test_school_event_to_date_before_from_date_invalid(self) -> None:
"""Vérifie que to_date < from_date lève une ValidationError."""
with pytest.raises(ValidationError) as exc_info:
SchoolEvent(
kind=SchoolEventKind.HOLIDAY,
label="Test",
from_date=date(2024, 10, 20),
to_date=date(2024, 10, 19), # to_date avant from_date
)
assert any(
"to_date doit être postérieur ou égal à from_date" in str(error)
for error in exc_info.value.errors()
)
def test_school_event_to_date_equal_from_date_valid(self) -> None:
"""Vérifie que to_date == from_date est valide."""
instance = SchoolEvent(
kind=SchoolEventKind.HOLIDAY,
label="Test",
from_date=date(2024, 10, 20),
to_date=date(2024, 10, 20), # égal
)
assert instance.to_date >= instance.from_date
def test_school_event_to_date_after_from_date_valid(self) -> None:
"""Vérifie que to_date > from_date est valide."""
instance = SchoolEvent(
kind=SchoolEventKind.HOLIDAY,
label="Test",
from_date=date(2024, 10, 20),
to_date=date(2024, 10, 25),
)
assert instance.to_date >= instance.from_date
class TestAgendaChangeConsistency:
"""Tests de cohérence des changements d'agenda."""
def test_agenda_change_added_without_lesson_invalid(self) -> None:
"""Vérifie que type=ADDED avec lesson=None lève une ValidationError."""
with pytest.raises(ValidationError) as exc_info:
AgendaChange(type=AgendaChangeType.ADDED, lesson=None, theoretical_lesson=None)
assert any(
"lesson est requis pour le type" in str(error) for error in exc_info.value.errors()
)
def test_agenda_change_removed_without_theoretical_lesson_invalid(self) -> None:
"""Vérifie que type=REMOVED avec theoretical_lesson=None lève une ValidationError."""
with pytest.raises(ValidationError) as exc_info:
AgendaChange(
type=AgendaChangeType.REMOVED,
lesson=None,
theoretical_lesson=None,
)
assert any(
"theoretical_lesson est requis pour le type" in str(error)
for error in exc_info.value.errors()
)
def test_agenda_change_modified_without_lesson_invalid(self) -> None:
"""Vérifie que type=MODIFIED avec lesson=None lève une ValidationError."""
with pytest.raises(ValidationError) as exc_info:
AgendaChange(type=AgendaChangeType.MODIFIED, lesson=None, theoretical_lesson=None)
assert any(
"lesson est requis pour le type" in str(error) for error in exc_info.value.errors()
)
def test_agenda_change_added_with_lesson_valid(self) -> None:
"""Vérifie que type=ADDED avec lesson=<valide> est valide."""
lesson = Lesson(
id="lesson-valid",
start=datetime(2024, 9, 6, 8, 0, 0),
end=datetime(2024, 9, 6, 9, 30, 0),
subject="Mathématiques",
group=None,
content=None,
)
instance = AgendaChange(
type=AgendaChangeType.ADDED,
lesson=lesson,
theoretical_lesson=None,
)
assert instance.type == AgendaChangeType.ADDED
assert instance.lesson is not None
def test_agenda_change_removed_with_theoretical_lesson_valid(self) -> None:
"""Vérifie que type=REMOVED avec theoretical_lesson=<valide> est valide."""
theoretical_lesson = TheoreticalLesson(
id="theo-lesson-valid",
day_of_week=0,
start_time=time(8, 0, 0),
end_time=time(9, 30, 0),
subject="Mathématiques",
)
instance = AgendaChange(
type=AgendaChangeType.REMOVED,
lesson=None,
theoretical_lesson=theoretical_lesson,
)
assert instance.type == AgendaChangeType.REMOVED
assert instance.theoretical_lesson is not None
def test_agenda_change_modified_with_lesson_valid(self) -> None:
"""Vérifie que type=MODIFIED avec lesson=<valide> est valide."""
lesson = Lesson(
id="lesson-valid-mod",
start=datetime(2024, 9, 6, 10, 0, 0),
end=datetime(2024, 9, 6, 11, 30, 0),
subject="Physique",
group=None,
content=None,
)
instance = AgendaChange(
type=AgendaChangeType.MODIFIED,
lesson=lesson,
theoretical_lesson=None,
)
assert instance.type == AgendaChangeType.MODIFIED
assert instance.lesson is not None
class TestCalDAVSyncResultInvariants:
"""Tests de validation des champs numériques de CalDAVSyncResult."""
def test_caldav_sync_result_added_negative_invalid(self) -> None:
"""Vérifie que added=-1 lève une ValidationError."""
with pytest.raises(ValidationError) as exc_info:
CalDAVSyncResult(status=CalDAVSyncStatus.SUCCESS, added=-1, updated=0, removed=0)
assert any("greater than or equal to 0" in str(error) for error in exc_info.value.errors())
def test_caldav_sync_result_updated_negative_invalid(self) -> None:
"""Vérifie que updated=-1 lève une ValidationError."""
with pytest.raises(ValidationError) as exc_info:
CalDAVSyncResult(status=CalDAVSyncStatus.SUCCESS, added=0, updated=-1, removed=0)
assert any("greater than or equal to 0" in str(error) for error in exc_info.value.errors())
def test_caldav_sync_result_removed_negative_invalid(self) -> None:
"""Vérifie que removed=-1 lève une ValidationError."""
with pytest.raises(ValidationError) as exc_info:
CalDAVSyncResult(status=CalDAVSyncStatus.SUCCESS, added=0, updated=0, removed=-1)
assert any("greater than or equal to 0" in str(error) for error in exc_info.value.errors())
def test_caldav_sync_result_zero_values_valid(self) -> None:
"""Vérifie que les valeurs à zéro sont valides."""
instance = CalDAVSyncResult(status=CalDAVSyncStatus.SUCCESS, added=0, updated=0, removed=0)
assert instance.added == 0
assert instance.updated == 0
assert instance.removed == 0

View File

@@ -0,0 +1,128 @@
"""Tests unitaires pour les utilitaires de masquage des secrets.
Ce module vérifie que les fonctions de masquage des URLs, des en-têtes
et des secrets dans les textes fonctionnent correctement, sans fuite
d'informations sensibles.
"""
from __future__ import annotations
from pronote_sync.utils.redaction import redact_exception, redact_secrets, redact_url
def test_redact_url_with_sensitive_query_param() -> None:
"""Vérifie que les paramètres sensibles dans une URL sont masqués.
:return: None
"""
url = "https://example.fr/ical?icalsecurise=TOKEN123" # pragma: allowlist secret
redacted = redact_url(url)
assert "TOKEN123" not in redacted
assert "REDACTED" in redacted
def test_redact_url_with_embedded_auth() -> None:
"""Vérifie que les informations d'authentification intégrées dans une URL sont masquées.
:return: None
"""
url = "https://user:pass@host/path" # pragma: allowlist secret
redacted = redact_url(url)
assert "user" not in redacted
assert "pass" not in redacted
assert "REDACTED" in redacted
def test_redact_url_with_uppercase_scheme() -> None:
"""Vérifie que les URLs avec un schéma en majuscules sont traitées correctement.
:return: None
"""
url = "HTTPS://user:pass@host/path" # pragma: allowlist secret
redacted = redact_url(url)
assert "user" not in redacted
assert "pass" not in redacted
assert "REDACTED" in redacted
def test_redact_secrets_authorization_bearer() -> None:
"""Vérifie que les en-têtes Authorization: Bearer sont masqués.
:return: None
"""
text = "Authorization: Bearer mytoken123" # pragma: allowlist secret
redacted = redact_secrets(text)
assert "mytoken123" not in redacted
assert "REDACTED" in redacted
def test_redact_secrets_proxy_authorization_basic() -> None:
"""Vérifie que les en-têtes Proxy-Authorization: Basic sont masqués.
:return: None
"""
text = "Proxy-Authorization: Basic dXNlcjpwYXNz" # pragma: allowlist secret
redacted = redact_secrets(text)
assert "dXNlcjpwYXNz" not in redacted
assert "REDACTED" in redacted
def test_redact_secrets_lowercase_authorization() -> None:
"""Vérifie que les en-têtes authorization en minuscules sont masqués.
:return: None
"""
text = "authorization: Bearer tok" # pragma: allowlist secret
redacted = redact_secrets(text)
assert "tok" not in redacted
assert "REDACTED" in redacted
def test_redact_secrets_text_key_value() -> None:
"""Vérifie que les affectations clé=valeur dans du texte sont masquées.
:return: None
"""
text = "password=mysecret" # pragma: allowlist secret
redacted = redact_secrets(text)
assert "mysecret" not in redacted
assert "REDACTED" in redacted
def test_redact_secrets_json_key_value() -> None:
"""Vérifie que les secrets dans du JSON sont masqués.
:return: None
"""
text = '{"api_key": "myapikey"}' # pragma: allowlist secret
redacted = redact_secrets(text)
assert "myapikey" not in redacted
assert "REDACTED" in redacted
def test_redact_exception() -> None:
"""Vérifie que les exceptions contenant des secrets sont masquées.
:return: None
"""
exc = Exception("Authorization: Bearer leaked_token") # pragma: allowlist secret
redacted_msg = redact_exception(exc)
assert "leaked_token" not in redacted_msg
assert "REDACTED" in redacted_msg
def test_redact_url_preserves_host_and_path() -> None:
"""Vérifie que l'hôte et le chemin d'une URL sont préservés après masquage.
:return: None
"""
url = "https://user:pass@example.fr/path?icalsecurise=tok" # pragma: allowlist secret
redacted = redact_url(url)
assert "example.fr" in redacted
assert "/path" in redacted
assert "user" not in redacted
assert "pass" not in redacted
assert "tok" not in redacted
# Ensure trailing newline

16
tests/unit/test_smoke.py Normal file
View File

@@ -0,0 +1,16 @@
"""Test de fumée : vérifie que le package est importable.
Ce test garantit que ``pytest`` collecte au moins un test et retourne 0,
satisfaisant le critère d'acceptation de M1. Les tests détaillés relèvent
de M13.
"""
import pronote_sync
def test_package_importable() -> None:
"""Vérifie que le package ``pronote_sync`` est importable sans erreur.
:return: None
"""
assert pronote_sync is not None