Merge pull request 'refactor(config): migrer RSS et Pronote vers l'endpoint commun et durcir le contrat' (#64) from refactor/issue-16-endpoints-rss-pronote into main

This commit was merged in pull request #64.
This commit is contained in:
2026-09-13 16:40:14 +02:00
15 changed files with 771 additions and 191 deletions
+9 -4
View File
@@ -1,6 +1,8 @@
# --- Pronote ---
PRONOTE_ICAL_URL=https://college.ent/pronote/ical/Edt_Jean.ics?icalsecurise=REPLACE_ME&version=2024
PRONOTE_URL=https://college.ent/pronote/parent.html
# Endpoints externes : HTTPS uniquement (pas de fichier local file://).
PRONOTE_ICAL_ENDPOINT__URL=https://college.ent/pronote/ical/Edt_Jean.ics?icalsecurise=REPLACE_ME&version=2024
PRONOTE_ENDPOINT__URL=https://college.ent/pronote/parent.html
# Anciens noms (alias obsolètes encore supportés avec un DeprecationWarning) : PRONOTE_ICAL_URL, PRONOTE_URL
PRONOTE_ACCOUNT_TYPE=parent
PRONOTE_USERNAME=parent.dupont
PRONOTE_PASSWORD=your_secure_password
@@ -29,8 +31,9 @@ PRONOTE_AUTH_MODE=password
# --- CalDAV ---
# Endpoint commun (URL potentiellement sensible, masquée dans les journaux)
# HTTPS uniquement ; HTTP n'est toléré que pour localhost avec CALDAV_ALLOW_INSECURE_HTTP=true.
CALDAV_ENDPOINT__URL=https://caldav.example.com/calendars/user/pronote/
# Ancien nom temporairement supporté avec un avertissement de dépréciation : CALDAV_URL
# Ancien nom (alias obsolète encore supporté avec un DeprecationWarning) : CALDAV_URL
CALDAV_USERNAME=user@example.com
CALDAV_PASSWORD=your_caldav_password
CALDAV_CALENDAR_PATH=/pronote-sync/
@@ -93,8 +96,10 @@ AI_BASE_URL=https://api.openai.com/v1
# AI_ALLOW_INSECURE_HTTP=true
# --- Blog ---
# Endpoint externe : HTTPS uniquement (pas de fichier local file://).
BLOG_ENABLED=false
BLOG_RSS_URL=https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2
BLOG_ENDPOINT__URL=https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2
# Ancien nom (alias obsolète encore supporté avec un DeprecationWarning) : BLOG_RSS_URL
# --- Divers ---
DRY_RUN=false
+3 -3
View File
@@ -140,7 +140,7 @@
"filename": "GUIDE_DEV_PYTHON.md",
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
"is_verified": false,
"line_number": 5186
"line_number": 5205
}
],
"tests/unit/test_caldav_gateway.py": [
@@ -172,7 +172,7 @@
"filename": "tests/unit/test_caldav_security.py",
"hashed_secret": "6b554cd7b7e0115065fb4907307a74f1902154d4",
"is_verified": false,
"line_number": 28
"line_number": 29
}
],
"tests/unit/test_xmpp_channel.py": [
@@ -185,5 +185,5 @@
}
]
},
"generated_at": "2026-09-12T22:12:56Z"
"generated_at": "2026-09-13T13:46:01Z"
}
+77 -46
View File
@@ -259,8 +259,8 @@ Le projet utilise **`pydantic-settings`** pour valider et charger la configurati
| Variable | Description | Exemple (anonymisé) | Type |
|------------------------------|-----------------------------------------------------------------------------|---------------------------------------------|---------------|
| `PRONOTE_URL` | URL de la page Pronote utilisée par `pronotepy` (page parent). | `https://college.ent/pronote/parent.html` | `str` |
| `PRONOTE_ICAL_URL` | URL du flux iCal Pronote (contient `icalsecurise`). | `https://college.ent/pronote/ical/...` | `SecretStr` |
| `PRONOTE_ENDPOINT__URL` | URL de la page Pronote via l'endpoint commun. | `https://college.ent/pronote/parent.html` | `ExternalEndpoint` |
| `PRONOTE_ICAL_ENDPOINT__URL` | URL du flux iCal Pronote via l'endpoint commun (contient `icalsecurise`). | `https://college.ent/pronote/ical/...` | `ExternalEndpoint` |
| `PRONOTE_USERNAME` | Identifiant Pronote (si `pronotepy` utilisé). | `parent.dupont` | `str` |
| `PRONOTE_PASSWORD` | Mot de passe Pronote (si `pronotepy` utilisé). | `SecretStr` (masqué) | `SecretStr` |
| `PRONOTE_ENT` | Slug ENT supporté, résolu vers une fonction de `pronotepy.ent`. | `monbureaunumerique` | `str` |
@@ -277,17 +277,28 @@ Le projet utilise **`pydantic-settings`** pour valider et charger la configurati
> `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_TLS_MODE`, `XMPP_TIMEOUT`, `XMPP_CONNECT_TIMEOUT`, `XMPP_CLEANUP_TIMEOUT`.
> `XMPP_USE_TLS` reste supporté mais est **obsolète** (remplacé par `XMPP_TLS_MODE`).
> Une section `BLOG_ENABLED` et `BLOG_RSS_URL` a été ajoutée dans `.env.example`.
> Une section `BLOG_ENABLED` et `BLOG_ENDPOINT__URL` a été ajoutée dans `.env.example`.
> `BLOG_RSS_URL` reste temporairement accepté avec un avertissement de dépréciation.
> `CALDAV_URL` reste temporairement accepté avec un avertissement de dépréciation ; utiliser
> `CALDAV_ENDPOINT__URL` pour la nouvelle configuration.
> `PRONOTE_URL` et `PRONOTE_ICAL_URL` restent temporairement acceptés avec un avertissement de
> dépréciation ; utiliser respectivement `PRONOTE_ENDPOINT__URL` et `PRONOTE_ICAL_ENDPOINT__URL`.
>
> **Contrat des endpoints externes** : le schéma `file://` n'est **pas** supporté en production.
> Seul HTTPS est accepté (plus HTTP uniquement pour un hôte loopback CalDAV lorsque
> `CALDAV_ALLOW_INSECURE_HTTP=true`). Les credentials embarqués (`user:pass@hôte`) sont refusés
> par les endpoints canoniques `*_ENDPOINT__URL`. Les fixtures locales ne doivent pas passer par
> ces variables.
Les variables Pronote sont obligatoires selon les sources activées :
- la source iCal exige `PRONOTE_ICAL_URL` ;
- la source `pronotepy` exige `PRONOTE_URL`, `PRONOTE_USERNAME` et
- la source iCal exige `PRONOTE_ICAL_ENDPOINT__URL` ;
- la source `pronotepy` exige `PRONOTE_ENDPOINT__URL`, `PRONOTE_USERNAME` et
`PRONOTE_PASSWORD` ;
- `PRONOTE_ENT` reste optionnel pour une connexion directe, mais, s'il est fourni, son slug doit
appartenir à une liste fermée et être résolu vers la fonction correspondante de `pronotepy.ent`.
`PRONOTE_URL` et `PRONOTE_ICAL_URL` sont deux contrats distincts : l'un ne doit jamais être déduit
`PRONOTE_ENDPOINT__URL` et `PRONOTE_ICAL_ENDPOINT__URL` sont deux contrats distincts : l'un ne doit jamais être déduit
de l'autre. Le cas d'usage actuel est un compte parent ; le client à construire est donc
`pronotepy.ParentClient`. Une généralisation à plusieurs profils ne sera ajoutée qu'en présence
d'un besoin réel et testé.
@@ -323,8 +334,8 @@ d'un besoin réel et testé.
```ini
# --- Pronote ---
PRONOTE_URL=https://college.ent/pronote/parent.html
PRONOTE_ICAL_URL=https://college.ent/pronote/ical/Edt_Jean.ics?icalsecurise=REPLACE_ME&version=2024
PRONOTE_ENDPOINT__URL=https://college.ent/pronote/parent.html
PRONOTE_ICAL_ENDPOINT__URL=https://college.ent/pronote/ical/Edt_Jean.ics?icalsecurise=REPLACE_ME&version=2024
PRONOTE_USERNAME=parent.dupont
PRONOTE_PASSWORD=your_secure_password
PRONOTE_ENT=monbureaunumerique
@@ -335,7 +346,7 @@ PRONOTE_HOMEWORK_SOURCE=auto
PRONOTE_MESSAGES_SOURCE=pronotepy
# --- CalDAV ---
CALDAV_URL=https://caldav.example.com/calendars/user/pronote/
CALDAV_ENDPOINT__URL=https://caldav.example.com/calendars/user/pronote/
CALDAV_ALLOW_INSECURE_HTTP=false
CALDAV_USERNAME=user@example.com
CALDAV_PASSWORD=your_caldav_password
@@ -389,7 +400,7 @@ LOG_LEVEL=INFO
> 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`.
> `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.
> `BlogSettings` a été ajouté (§5 bis.9.2) avec `enabled=False` et un endpoint RSS 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/"`.
> `XmppSettings.resource` a pour valeur par défaut `"pronote-sync"`.
@@ -403,9 +414,9 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class PronoteSettings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="PRONOTE_", env_file=".env", extra="ignore")
url: str | None = None
ical_url: SecretStr | None = None
model_config = SettingsConfigDict(env_prefix="PRONOTE_", env_file=".env", env_nested_delimiter="__", extra="ignore")
endpoint: ExternalEndpoint | None = None
ical_endpoint: ExternalEndpoint | None = None
username: str | None = None
password: SecretStr | None = None
ent: str | None = None
@@ -1113,7 +1124,7 @@ La déduplication des articles du blog repose sur leur **GUID** (ou leur URL si
Aucun fichier d'état local n'est utilisé : l'état est géré en mémoire par run.
# Initialisation
rss_client = BlogRSSClient(rss_url=settings.blog.rss_url)
rss_client = BlogRSSClient(rss_url=settings.blog.endpoint.url.get_secret_value())
blog_state = ## (section obsolète supprimée)()
# Récupération des nouveaux articles
@@ -1218,7 +1229,7 @@ Ajouter les variables suivantes dans la configuration :
| **Variable** | **Description** | **Valeur par défaut** | **Type** |
|----------------------------|-------------------------------------------------------------------------------|-----------------------|-------------------|
| `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_ENDPOINT__URL` | URL du flux RSS via l'endpoint commun (masquée en `SecretStr`). | `https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2` | `ExternalEndpoint` |
#### 5 bis.9.2 Modèle Pydantic pour la configuration du blog
@@ -1230,10 +1241,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class BlogSettings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="BLOG_", env_file=".env", extra="ignore")
enabled: bool = Field(False, description="Activer la récupération du blog")
rss_url: str = Field(
"https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2",
description="URL du flux RSS du blog",
)
endpoint: ExternalEndpoint
```
**Intégration dans `Settings`** :
@@ -1253,7 +1261,7 @@ class Settings(BaseSettings):
```ini
# --- Blog du collège ---
BLOG_ENABLED=true
BLOG_RSS_URL=https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2
BLOG_ENDPOINT__URL=https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2
```
---
@@ -1312,8 +1320,12 @@ from pronote_sync.models.blog import BlogArticle
@pytest.fixture
def mock_blog_rss_client():
"""Retourne un client RSS mocké pour les tests."""
client = BlogRSSClient(rss_url="file://tests/fixtures/blog_rss.xml")
"""Retourne un client RSS mocké pour les tests.
``requests.get`` est mocké : aucune requête réseau réelle n'est émise.
Le schéma ``file://`` n'est pas supporté par ``BlogRSSClient``.
"""
client = BlogRSSClient(rss_url="https://example.com/blog/feed")
return client
@@ -1360,7 +1372,7 @@ def test_blog_deduplication(tmp_path):
assert "https://blogpeda.ac-bordeaux.fr/cjeliote/?p=1625" in state.get_known_guids()
# Simuler une nouvelle récupération : seul le nouvel article doit être retourné
client = BlogRSSClient(rss_url="file://tests/fixtures/blog_rss.xml")
client = BlogRSSClient(rss_url="https://example.com/blog/feed") # requête mockée
result = client.fetch_and_parse(known_guids=state.get_known_guids())
# Seul l'article avec p=1626 doit être retourné (car p=1625 est déjà connu)
@@ -1620,11 +1632,14 @@ from ..models.agenda import RawCalendarData
def fetch_ical(url: str, timeout: int = 20) -> str:
"""
Récupère le flux iCal depuis une URL Pronote.
Récupère le flux iCal depuis une URL Pronote (HTTPS uniquement).
Inspiré de src/sources/pronote/fetch.ts.
Le schéma ``file://`` n'est **pas** supporté : les fixtures locales sont
injectées via un mock HTTP (``responses``) dans les tests.
Args:
url: URL du flux iCal (peut être file:// pour les tests).
url: URL HTTPS du flux iCal (avec token ``icalsecurise``).
timeout: Timeout en secondes (défaut: 20s).
Returns:
@@ -1639,16 +1654,7 @@ def fetch_ical(url: str, timeout: int = 20) -> str:
"user-agent": "pronote-sync",
}
# Gestion des URLs file:// pour les tests
if url.startswith("file://"):
import pathlib
file_path = pathlib.Path(url.replace("file://", ""))
content = file_path.read_text(encoding="utf-8")
if "BEGIN:VCALENDAR" not in content:
raise ValueError(f"Fichier iCal invalide: {redact_url(url)}")
return content
# Récupération HTTP
# Récupération HTTP (aucun accès fichier local)
try:
response = requests.get(
url,
@@ -1674,6 +1680,19 @@ def fetch_ical(url: str, timeout: int = 20) -> str:
return content
# Test unitaire réseau mocké (aucun accès fichier)
@responses.activate
def test_fetch_ical_https() -> None:
responses.add(
responses.GET,
"https://pronote.example.test/ical.ics",
body="BEGIN:VCALENDAR\nVERSION:2.0\nEND:VCALENDAR",
status=200,
)
content = fetch_ical("https://pronote.example.test/ical.ics")
assert content.startswith("BEGIN:VCALENDAR")
def get_calendar_name(raw_ical: str) -> str | None:
"""
Extrait le nom du calendrier depuis X-WR-CALNAME.
@@ -2705,7 +2724,7 @@ Utilisation de la bibliothèque [`caldav`](https://pypi.org/project/caldav/) (Py
#### API réelle (`caldav>=1.3.0`)
- **Connexion** : `caldav.DAVClient(url, username, password)` — les paramètres proviennent
de `CalDAVSettings` (`CALDAV_URL`, `CALDAV_USERNAME`, `CALDAV_PASSWORD`).
de `CalDAVSettings` (`CALDAV_ENDPOINT__URL`, `CALDAV_USERNAME`, `CALDAV_PASSWORD`).
- **Résolution du calendrier** : `DAVClient.principal()` puis `principal.calendars()` ;
sélectionner le calendrier dont l'URL correspond à **`CalDAVSettings.calendar_path`**
(ex: `/pronote-sync/`). La résolution ne se fait **pas** par nom de calendrier :
@@ -4610,7 +4629,7 @@ def get_channel(settings: XmppSettings, dry_run: bool = False) -> Channel | None
- **XMPP** : Si l'envoi échoue → **logger l'erreur** mais continuer le pipeline.
- **Erreurs critiques** :
- **Aucune source disponible** (iCal + pronotepy échouent) → **échec explicite** avec message clair.
- **Configuration invalide** (ex: `PRONOTE_ICAL_URL` manquant) → **échec explicite**.
- **Configuration invalide** (ex: `PRONOTE_ICAL_ENDPOINT__URL` manquant) → **échec explicite**.
### 11.2 Hiérarchie des erreurs
@@ -5341,12 +5360,14 @@ def mock_xmpp_channel():
def sample_settings():
"""Retourne une configuration de test."""
from pydantic import SecretStr
from pronote_sync.config.settings import Settings, PronoteSettings, CalDAVSettings, XmppSettings, AISettings, AppSettings
from pronote_sync.config.settings import (
AISettings, AppSettings, CalDAVSettings, ExternalEndpoint, PronoteSettings, Settings, XmppSettings,
)
return Settings(
pronote=PronoteSettings(
url="https://test.ent/pronote/parent.html",
ical_url=SecretStr("https://test.ent/pronote/ical/test.ics"),
endpoint=ExternalEndpoint(url=SecretStr("https://test.ent/pronote/parent.html")),
ical_endpoint=ExternalEndpoint(url=SecretStr("https://test.ent/pronote/ical/test.ics")),
username="test_user",
password=SecretStr("test_password"),
ent="monbureaunumerique",
@@ -5434,7 +5455,7 @@ def test_pipeline_full(mock_requests_get, mock_caldav_client, mock_ai_provider,
# Configurer le client CalDAV
caldav_client = CalDAVClient(
url=sample_settings.caldav.url,
url=sample_settings.caldav.endpoint.url if sample_settings.caldav.endpoint else None,
username=sample_settings.caldav.username,
password=sample_settings.caldav.password,
allow_insecure_http=sample_settings.caldav.allow_insecure_http,
@@ -5629,7 +5650,7 @@ TOTAL 1000 10 99%
| **Risque** | **Mesure de mitigation** | **Vérification** | **Statut** |
|-------------------------------------|----------------------------------------------------------------------------------------|-------------------------------------------|------------|
| Tokens dans le code | Utiliser `pydantic-settings` + `SecretStr` pour les variables d'environnement. | `grep -r "icalsecurise\|password\|api_key" src/` | ❌ Interdit |
| Tokens dans les logs | Masquage systématique via `RedactingFormatter` (voir [Section 4.2](#42-implémentation)). | Tests avec `PRONOTE_ICAL_URL` contenant un token. | ✅ Obligatoire |
| Tokens dans les logs | Masquage systématique via `RedactingFormatter` (voir [Section 4.2](#42-implémentation)). | Tests avec `PRONOTE_ICAL_ENDPOINT__URL` contenant un token. | ✅ Obligatoire |
| Tokens dans les erreurs | Masquage dans les messages d'erreur (voir `redact_url` et `redact_secrets`). | Tests avec URLs contenant des tokens. | ✅ Obligatoire |
| Tokens dans les fixtures | **Anonymiser** toutes les fixtures (pas de tokens réels). | Vérification manuelle des fixtures. | ✅ Obligatoire |
| Tokens dans les commits Git | Utiliser `.gitignore` pour `.env` et `pre-commit` pour bloquer les secrets. | `git grep "icalsecurise\|password" -- .` (contenu suivi courant) | ❌ Interdit |
@@ -5724,7 +5745,17 @@ def check_secrets_in_code():
(["grep", "-r", "icalsecurise=", "src/", "tests/", "--include=*.py"], "Tokens iCal dans le code"),
(["grep", "-r", "password\s*=", "src/", "tests/", "--include=*.py"], "Mots de passe en clair"),
(["grep", "-r", "api_key\s*=", "src/", "tests/", "--include=*.py"], "Clés API en clair"),
(["grep", "-r", "PRONOTE_ICAL_URL.*=", "src/", "tests/", "--include=*.py"], "URLs iCal en clair"),
(
[
"grep",
"-r",
"PRONOTE_ICAL_ENDPOINT__URL.*=",
"src/",
"tests/",
"--include=*.py",
],
"URLs iCal en clair",
),
]
all_ok = True
@@ -5948,10 +5979,10 @@ Exemple de ligne cron (exécution tous les jours à 18h) :
|---------------------------------------|------------------------------------------------------------------------------------|------------------------------------------------------------------------------|
| Échec de la récupération iCal | Token `icalsecurise` expiré ou invalide. | Régénérer le token depuis Pronote. |
| Échec de la connexion Pronote (`pronotepy`) | Identifiants incorrects ou ENT non supporté. | Vérifier `PRONOTE_USERNAME`, `PRONOTE_PASSWORD`, `PRONOTE_ENT`. |
| Échec de la connexion CalDAV | URL, identifiant ou mot de passe CalDAV incorrect, ou HTTP non autorisé pour l'hôte. | Vérifier `CALDAV_URL`, `CALDAV_USERNAME`, `CALDAV_PASSWORD`, `CALDAV_ALLOW_INSECURE_HTTP`. |
| Échec de la connexion CalDAV | URL, identifiant ou mot de passe CalDAV incorrect, ou HTTP non autorisé pour l'hôte. | Vérifier `CALDAV_ENDPOINT__URL`, `CALDAV_USERNAME`, `CALDAV_PASSWORD`, `CALDAV_ALLOW_INSECURE_HTTP`. |
| Échec de la connexion XMPP | Identifiant ou mot de passe XMPP incorrect. | Vérifier `XMPP_JID`, `XMPP_PASSWORD`. |
| Échec de la synthèse IA | Clé API IA invalide ou modèle non disponible. | Vérifier `AI_API_KEY`, `AI_BASE_URL`, `AI_MODEL`. |
| Aucun cours récupéré | Flux iCal vide ou `pronotepy` non configuré. | Vérifier `PRONOTE_ICAL_URL` ou les identifiants `pronotepy`. |
| Aucun cours récupéré | Flux iCal vide ou `pronotepy` non configuré. | Vérifier `PRONOTE_ICAL_ENDPOINT__URL` ou les identifiants `pronotepy`. |
| Doublons dans les devoirs | Problème de déduplication. | Vérifier la logique de déduplication (voir [Section 5.1.4](#514-déduplication-des-devoirs)). |
| Synchronisation CalDAV lente | Trop d'événements à synchroniser. | Réduire `SYNC_PAST_DAYS` ou `SYNC_FUTURE_DAYS`. |
@@ -5960,7 +5991,7 @@ Exemple de ligne cron (exécution tous les jours à 18h) :
| **Commande** | **Description** |
|---------------------------------------|-----------------------------------------------------------------------------------------------------|
| `python -m pronote_sync.cli.main --dry-run --log-level DEBUG` | Exécute le pipeline en mode dry-run avec des logs détaillés. |
| `python -c "from pronote_sync.sources.pronote.ical import fetch_ical; print(fetch_ical('file://tests/fixtures/pronote-4e.ics'))"` | Teste le parsing d'un fichier iCal local. |
| `python -c "from pronote_sync.sources.pronote.ical import fetch_ical; print(fetch_ical('https://college.ent/pronote/ical/Edt.ics?icalsecurise=TOKEN'))"` | Teste la récupération d'un flux iCal HTTPS (remplacer `TOKEN`). |
| `python -c "from pronote_sync.config.settings import settings; print(settings)"` | Affiche la configuration chargée. |
| `python -c "import caldav; print(caldav.__version__)"` | Vérifie la version de la bibliothèque CalDAV. |
| `python -c "import slixmpp; print(slixmpp.__version__)"` | Vérifie la version de la bibliothèque XMPP. |
+6 -3
View File
@@ -58,6 +58,7 @@ The following variables can be safely pre-configured in `/etc/pronote-sync/prono
- `PRONOTE_ACCOUNT_TYPE` (default: `parent`)
- `PRONOTE_ENT` (ENT slug, e.g., `lyceeconnecte`)
- `PRONOTE_AGENDA_SOURCE`, `PRONOTE_HOMEWORK_SOURCE`, `PRONOTE_MESSAGES_SOURCE` (`auto`, `ical`, or `pronotepy`)
- `PRONOTE_ENDPOINT__URL` (API Pronote endpoint) and `PRONOTE_ICAL_ENDPOINT__URL` (iCal feed endpoint). The old names `PRONOTE_URL` and `PRONOTE_ICAL_URL` are deprecated aliases that still work with a `DeprecationWarning`.
- **CalDAV:**
- `CALDAV_CALENDAR_PATH` (e.g., `/pronote-sync/`)
@@ -78,7 +79,7 @@ The following variables can be safely pre-configured in `/etc/pronote-sync/prono
- `AI_ENABLED`, `AI_PROVIDER`, `AI_BASE_URL`, `AI_MODEL`, `AI_ALLOW_INSECURE_HTTP` — la validation structurelle de `AI_BASE_URL` s'applique à tous les providers (HTTPS sauf `AI_ALLOW_INSECURE_HTTP=true`, pas de credentials ni de paramètres sensibles dans l'URL, pas de `/v1` automatique) ; seul `openai-compatible` exige `AI_BASE_URL` et `AI_MODEL`.
- **Blog:**
- `BLOG_ENABLED`, `BLOG_RSS_URL`
- `BLOG_ENABLED`, `BLOG_ENDPOINT__URL`. The old name `BLOG_RSS_URL` is a deprecated alias that still works with a `DeprecationWarning`.
- **General:**
- `DRY_RUN`, `LOG_LEVEL`
@@ -88,10 +89,12 @@ The following variables can be safely pre-configured in `/etc/pronote-sync/prono
**Do NOT set these variables automatically.** The operator must manually provide the following secrets:
- **Pronote:**
- `PRONOTE_ICAL_URL`, `PRONOTE_URL`, `PRONOTE_USERNAME`, `PRONOTE_PASSWORD`
- `PRONOTE_ICAL_ENDPOINT__URL`, `PRONOTE_ENDPOINT__URL`, `PRONOTE_USERNAME`, `PRONOTE_PASSWORD`
- The old names `PRONOTE_ICAL_URL` and `PRONOTE_URL` are deprecated aliases that still work with a `DeprecationWarning`.
- **CalDAV:**
- `CALDAV_URL`, `CALDAV_USERNAME`, `CALDAV_PASSWORD`
- `CALDAV_ENDPOINT__URL`, `CALDAV_USERNAME`, `CALDAV_PASSWORD`
- The old name `CALDAV_URL` is a deprecated alias that still works with a `DeprecationWarning`.
- **XMPP:**
- `XMPP_JID`, `XMPP_PASSWORD`, `XMPP_TO`
+8 -4
View File
@@ -94,7 +94,9 @@ https://{etablissement}.index-education.net/pronote/ical/Edt_{prenom}.ics?icalse
🔹 **Source** : ⚠️ Recommandation du projet (inspirée des bonnes pratiques générales de sécurité).
### Intégration dans `pronote-sync`
- **Paramètre** : `PRONOTE_ICAL_URL` (ex. `.env.example` ligne 2).
- **Paramètre** : `PRONOTE_ICAL_ENDPOINT__URL` (ex. `.env.example` ligne 2).
L'ancien nom `PRONOTE_ICAL_URL` reste accepté comme **alias obsolète** (émission d'un
`DeprecationWarning`). Seul HTTPS est accepté ; `file://` est refusé.
- **Comportement** :
- Prioritaire en mode `PRONOTE_AGENDA_SOURCE=auto`.
- Si l'URL est invalide ou expire, repli automatique vers `pronotepy` (si `PRONOTE_AGENDA_SOURCE=auto`).
@@ -126,7 +128,9 @@ Connexion via le protocole propriétaire de Pronote (JSON sur HTTPS), avec **chi
### Intégration dans `pronote-sync`
- **Paramètres** :
- `PRONOTE_URL` (ex. `.env.example` ligne 3).
- `PRONOTE_ENDPOINT__URL` (ex. `.env.example` ligne 3). L'ancien nom `PRONOTE_URL` reste
accepté comme **alias obsolète** (émission d'un `DeprecationWarning`). Seul HTTPS est
accepté ; `file://` est refusé.
- `PRONOTE_USERNAME`, `PRONOTE_PASSWORD`.
- `PRONOTE_ENT` (slug dans `_ENT_NAMES`).
- `PRONOTE_ACCOUNT_TYPE` (ex. `parent`).
@@ -288,8 +292,8 @@ Mécanisme d'appairage par QR code pour les appareils mobiles, **contournant l'a
### Alignement avec `.env.example`
| Paramètre | Document | Code | Statut |
|-----------|----------|------|--------|
| `PRONOTE_ICAL_URL` | ✅ Lignes 2, 4250 | ✅ `sources/ical.py` | **Cohérent** |
| `PRONOTE_URL` | ✅ Ligne 3 | ✅ `client.py` (ligne 294) | **Cohérent** |
| `PRONOTE_ICAL_ENDPOINT__URL` | ✅ Lignes 2, 4250 | ✅ `sources/ical.py` | **Cohérent** |
| `PRONOTE_ENDPOINT__URL` | ✅ Ligne 3 | ✅ `client.py` (ligne 294) | **Cohérent** |
| `PRONOTE_ENT` | ✅ Ligne 7 | ✅ `client.py` (ligne 297, `_ENT_NAMES`) | **Cohérent** |
| `PRONOTE_AUTH_MODE=qr_token` | ✅ Ligne 23 | ✅ `client.py` (ligne 334) | **Cohérent** |
| `PRONOTE_QR_CODE_FILE` | ✅ Ligne 24 | ✅ `client.py` (ligne 387) | **Cohérent** |
+151 -24
View File
@@ -26,17 +26,23 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
from pronote_sync.utils.redaction import redact_url
_EXTERNAL_ENDPOINT_SCHEMES: frozenset[str] = frozenset({"file", "http", "https"})
_EXTERNAL_ENDPOINT_SCHEMES: frozenset[str] = frozenset({"http", "https"})
_LOOPBACK_HOSTS: frozenset[str] = frozenset({"localhost", "127.0.0.1", "::1"})
def _validate_external_endpoint_url(value: SecretStr) -> SecretStr:
"""Valide la structure et le schéma d'une URL d'endpoint externe.
"""Valide la structure et le schéma réseau d'une URL d'endpoint externe.
Le socle commun accepte uniquement les schémas ``http`` et ``https``, avec
un hôte obligatoire. Les credentials embarqués (``user:pass@host``) sont
refusés afin qu'aucun secret ne soit transporté dans l'URL. La restriction
``https``/HTTP loopback est ensuite affinée par chaque connecteur.
:param value: URL potentiellement sensible à valider.
:return: URL validée, toujours encapsulée dans ``SecretStr``.
:rtype: SecretStr
:raises ValueError: Si l'URL est malformée ou utilise un schéma inconnu.
:raises ValueError: Si l'URL est malformée, sans hôte, utilise un schéma
non réseau ou contient des credentials.
"""
is_valid = False
try:
@@ -44,8 +50,9 @@ def _validate_external_endpoint_url(value: SecretStr) -> SecretStr:
_ = parsed.port
is_valid = (
parsed.scheme in _EXTERNAL_ENDPOINT_SCHEMES
and (parsed.scheme not in {"http", "https"} or parsed.hostname is not None)
and (parsed.scheme != "file" or bool(parsed.path))
and parsed.hostname is not None
and parsed.username is None
and parsed.password is None
)
except ValueError:
pass
@@ -60,10 +67,11 @@ EndpointUrl = Annotated[SecretStr, AfterValidator(_validate_external_endpoint_ur
class ExternalEndpoint(BaseModel):
"""Représente un endpoint externe potentiellement sensible.
Le socle accepte les transports ``https``, ``http`` et ``file``. Chaque
connecteur restreint ensuite cette liste selon sa propre politique de
sécurité. L'URL reste encapsulée dans :class:`pydantic.SecretStr` et sa
sérialisation conserve uniquement une représentation expurgée.
Le socle accepte uniquement les transports réseau ``https`` et ``http``
(hôte obligatoire, sans credentials embarqués). Chaque connecteur restreint
ensuite cette liste selon sa propre politique de sécurité. L'URL reste
encapsulée dans :class:`pydantic.SecretStr` et sa sérialisation conserve
uniquement une représentation expurgée.
"""
model_config = ConfigDict(extra="forbid", frozen=True, hide_input_in_errors=True)
@@ -88,13 +96,29 @@ class PronoteSettings(BaseSettings):
``PRONOTE_``.
"""
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="PRONOTE_")
model_config = SettingsConfigDict(
env_file=".env",
env_nested_delimiter="__",
extra="ignore",
env_prefix="PRONOTE_",
hide_input_in_errors=True,
)
ical_url: SecretStr | None = None
endpoint: ExternalEndpoint | None = None
ical_endpoint: ExternalEndpoint | None = None
ical_url: SecretStr | None = Field(
default=None,
exclude=True,
deprecated="Utiliser ical_endpoint.url à la place (PRONOTE_ICAL_URL obsolète).",
)
username: str | None = None
password: SecretStr | None = None
ent: str | None = None
url: str | None = None
url: str | None = Field(
default=None,
exclude=True,
deprecated="Utiliser endpoint.url à la place (PRONOTE_URL obsolète).",
)
account_type: Literal["student", "parent"] = "parent"
agenda_source: Literal["auto", "ical", "pronotepy"] = "auto"
homework_source: Literal["auto", "ical", "pronotepy"] = "auto"
@@ -104,17 +128,58 @@ class PronoteSettings(BaseSettings):
qr_pin: SecretStr | None = None
account_pin: SecretStr | None = None
@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).
@model_validator(mode="before")
@classmethod
def _migrate_legacy_endpoints(cls, data: object) -> object:
"""Migre les URL Pronote historiques vers les endpoints communs.
:param value: Valeur du champ ``ical_url``.
:return: ``"**********"`` si la valeur est définie, ``None`` sinon.
:rtype: str | None
:param data: Données brutes du modèle.
:return: Données complétées avec les endpoints si nécessaire.
:rtype: object
"""
if value is None:
return None
return "**********"
if not isinstance(data, dict):
return data
migrated_data = data.copy()
if migrated_data.get("url") is not None:
warnings.warn(
"PRONOTE_URL est obsolète : utiliser PRONOTE_ENDPOINT__URL.",
DeprecationWarning,
stacklevel=2,
)
if migrated_data.get("endpoint") is None:
migrated_data["endpoint"] = {"url": migrated_data["url"]}
if migrated_data.get("ical_url") is not None:
warnings.warn(
"PRONOTE_ICAL_URL est obsolète : utiliser PRONOTE_ICAL_ENDPOINT__URL.",
DeprecationWarning,
stacklevel=2,
)
if migrated_data.get("ical_endpoint") is None:
migrated_data["ical_endpoint"] = {"url": migrated_data["ical_url"]}
return migrated_data
@model_validator(mode="after")
def _validate_endpoint_policies(self) -> PronoteSettings:
"""Applique les transports autorisés aux deux endpoints Pronote.
L'API Pronote et le flux iCal exigent tous deux HTTPS : aucun fichier
local n'est accepté.
:return: Instance validée inchangée.
:rtype: PronoteSettings
:raises ValueError: Si un endpoint n'utilise pas HTTPS.
"""
if (
self.endpoint is not None
and urlparse(self.endpoint.url.get_secret_value()).scheme != "https"
):
raise ValueError("URL Pronote invalide : HTTPS requis") from None
if (
self.ical_endpoint is not None
and urlparse(self.ical_endpoint.url.get_secret_value()).scheme != "https"
):
raise ValueError("URL iCal Pronote invalide : HTTPS requis") from None
return self
@field_serializer("qr_pin")
def _serialize_qr_pin(self, value: SecretStr | None) -> str | None:
@@ -160,6 +225,7 @@ class CalDAVSettings(BaseSettings):
env_nested_delimiter="__",
extra="ignore",
env_prefix="CALDAV_",
hide_input_in_errors=True,
)
allow_insecure_http: bool = False
@@ -178,6 +244,13 @@ class CalDAVSettings(BaseSettings):
def _migrate_legacy_url(cls, data: object) -> object:
"""Migre ``url`` vers l'endpoint commun avec un avertissement.
L'alias historique ``CALDAV_URL`` est migré via le chemin de validation
canonique : il est donc soumis exactement aux mêmes règles que
``CALDAV_ENDPOINT__URL`` (schémas réseau uniquement, hôte obligatoire,
port valide, credentials embarqués refusés). La politique de transport
(HTTPS, ou HTTP loopback uniquement avec ``allow_insecure_http``) reste
appliquée ensuite.
:param data: Données brutes du modèle.
:return: Données complétées avec ``endpoint`` si nécessaire.
:rtype: object
@@ -352,10 +425,62 @@ class BlogSettings(BaseSettings):
``BLOG_``.
"""
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="BLOG_")
model_config = SettingsConfigDict(
env_file=".env",
env_nested_delimiter="__",
extra="ignore",
env_prefix="BLOG_",
hide_input_in_errors=True,
)
enabled: bool = False
rss_url: str = "https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2"
endpoint: ExternalEndpoint = Field(
default_factory=lambda: ExternalEndpoint(
url=SecretStr("https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2")
)
)
rss_url: str | None = Field(
default=None,
exclude=True,
deprecated="Utiliser endpoint.url à la place (BLOG_RSS_URL obsolète).",
)
@model_validator(mode="before")
@classmethod
def _migrate_legacy_rss_url(cls, data: object) -> object:
"""Migre ``rss_url`` vers l'endpoint commun avec un avertissement.
:param data: Données brutes du modèle.
:return: Données complétées avec ``endpoint`` si nécessaire.
:rtype: object
"""
if not isinstance(data, dict) or data.get("rss_url") is None:
return data
migrated_data = data.copy()
warnings.warn(
"BLOG_RSS_URL est obsolète : utiliser BLOG_ENDPOINT__URL.",
DeprecationWarning,
stacklevel=2,
)
if migrated_data.get("endpoint") is None:
migrated_data["endpoint"] = {"url": migrated_data["rss_url"]}
return migrated_data
@model_validator(mode="after")
def _validate_endpoint_policy(self) -> BlogSettings:
"""Refuse les transports non sûrs pour le flux RSS.
Seul HTTPS est accepté : aucun fichier local n'est lu depuis un
endpoint externe.
:return: Instance validée inchangée.
:rtype: BlogSettings
:raises ValueError: Si le schéma n'est pas ``https``.
"""
scheme = urlparse(self.endpoint.url.get_secret_value()).scheme
if scheme != "https":
raise ValueError("URL RSS invalide : HTTPS requis") from None
return self
class AppSettings(BaseSettings):
@@ -408,7 +533,8 @@ class Settings(BaseSettings):
:rtype: tuple[SecretStr, ...]
"""
secrets = [
self.pronote.ical_url,
self.pronote.endpoint.url if self.pronote.endpoint is not None else None,
self.pronote.ical_endpoint.url if self.pronote.ical_endpoint is not None else None,
self.pronote.password,
self.pronote.qr_pin,
self.pronote.account_pin,
@@ -416,5 +542,6 @@ class Settings(BaseSettings):
self.caldav.password,
self.xmpp.password,
self.ai.api_key,
self.blog.endpoint.url,
]
return tuple(dict.fromkeys(secret for secret in secrets if secret is not None))
+5 -1
View File
@@ -138,7 +138,11 @@ class PipelineRunner:
comparator = (
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.endpoint.url.get_secret_value())
if settings.blog.enabled
else None
)
blog_state = (
BlogRSSState(persistence_enabled=persistence_enabled) if settings.blog.enabled else None
)
+3 -3
View File
@@ -294,18 +294,18 @@ class PronoteClient:
est manquant, ou si l'ENT fourni est inconnu.
:raises pronotepy.PronoteAPIError: Si la connexion à Pronote échoue.
"""
url = self._settings.url
endpoint = self._settings.endpoint
username = self._settings.username
password = self._settings.password
ent = self._settings.ent
if url is None or username is None or password is None:
if endpoint is None or username is None or password is None:
raise ValueError("url, username et password sont requis pour pronotepy")
resolver = _resolve_ent(ent) if ent is not None else None
client_class: type[pronotepy.Client] = (
pronotepy.ParentClient if self._settings.account_type == "parent" else pronotepy.Client
)
self._client = client_class(
pronote_url=url,
pronote_url=endpoint.url.get_secret_value(),
username=username,
password=password.get_secret_value(),
ent=resolver,
+13 -13
View File
@@ -141,10 +141,10 @@ class PronoteFetcher:
def _is_ical_configured(self) -> bool:
"""Vérifie que la source iCal est configurée.
:return: ``True`` si ``ical_url`` est défini, ``False`` sinon.
:return: ``True`` si ``ical_endpoint`` est défini, ``False`` sinon.
:rtype: bool
"""
return self._settings.pronote.ical_url is not None
return self._settings.pronote.ical_endpoint is not None
def _is_pronotepy_configured(self) -> bool:
"""Vérifie si la source pronotepy est utilisable selon le mode d'authentification.
@@ -155,13 +155,13 @@ class PronoteFetcher:
"""
pronote = self._settings.pronote
if pronote.auth_mode == "qr_token":
# En mode qr_token, seul PRONOTE_URL est requis.
# En mode qr_token, seul PRONOTE_ENDPOINT__URL est requis.
# Le QR code et le PIN ne sont nécessaires que pour l'enrôlement initial.
# Les exécutions suivantes utilisent le token persisté.
return pronote.url is not None
return pronote.endpoint is not None
# En mode password, URL + identifiant + mot de passe sont requis.
return (
pronote.url is not None
pronote.endpoint is not None
and pronote.username is not None
and pronote.password is not None
)
@@ -171,16 +171,16 @@ class PronoteFetcher:
:return: Tuple ``(cours, événements scolaires)``.
:rtype: tuple[list[Lesson], list[SchoolEvent]]
:raises ValueError: Si ``ical_url`` n'est pas configuré ou si le flux est invalide.
:raises ValueError: Si ``ical_endpoint`` n'est pas configuré ou si le flux est invalide.
:raises OSError: Si le fichier iCal local est illisible.
:raises requests.RequestException: Si la récupération HTTP échoue.
"""
if self._cache_ical_for_run and self._run_ical_agenda is not None:
return self._run_ical_agenda
ical_url = self._settings.pronote.ical_url
if ical_url is None:
raise ValueError("PRONOTE_ICAL_URL est requis pour la source iCal")
raw_ical = fetch_ical(ical_url.get_secret_value())
ical_endpoint = self._settings.pronote.ical_endpoint
if ical_endpoint is None:
raise ValueError("PRONOTE_ICAL_ENDPOINT__URL est requis pour la source iCal")
raw_ical = fetch_ical(ical_endpoint.url.get_secret_value())
lessons, _, school_events = parse_ical(raw_ical)
result = (lessons, school_events)
if self._cache_ical_for_run:
@@ -210,7 +210,7 @@ class PronoteFetcher:
Les modes explicites ``ICAL`` et ``PRONOTEPY`` désignent la seule
source utilisée, sans aucun repli. En mode ``AUTO``, iCal est
primaire si ``ical_url`` est configuré (repli pronotepy si la
primaire si ``ical_endpoint`` est configuré (repli pronotepy si la
configuration pronotepy est complète), sinon pronotepy sans repli.
:return: Tuple ``(source primaire, source de repli ou ``None``)``.
@@ -306,7 +306,7 @@ class PronoteFetcher:
:param target_date: Date cible pour laquelle collecter les devoirs.
:return: Liste des devoirs.
:rtype: list[Homework]
:raises ValueError: Si ``ical_url`` n'est pas configuré ou si le flux est invalide.
:raises ValueError: Si ``ical_endpoint`` n'est pas configuré ou si le flux est invalide.
:raises OSError: Si le fichier iCal local est illisible.
:raises requests.RequestException: Si la récupération HTTP échoue.
"""
@@ -337,7 +337,7 @@ class PronoteFetcher:
Les modes explicites ``ICAL`` et ``PRONOTEPY`` désignent la seule
source utilisée, sans aucun repli. En mode ``AUTO``, iCal est
primaire si ``ical_url`` est configuré (repli pronotepy si la
primaire si ``ical_endpoint`` est configuré (repli pronotepy si la
configuration pronotepy est complète), sinon pronotepy sans repli.
:return: Tuple ``(source primaire, source de repli ou ``None``)``.
+9 -25
View File
@@ -1,7 +1,7 @@
"""Récupération et parsing du flux iCal Pronote.
Ce module fournit le téléchargement du flux iCal Pronote (via HTTP ou
``file://`` pour les tests) ainsi que son parsing en modèles : cours
Ce module fournit le téléchargement réseau du flux iCal Pronote ainsi que
son parsing en modèles : cours
(:class:`~pronote_sync.models.agenda.Lesson`), événements scolaires
(:class:`~pronote_sync.models.agenda.SchoolEvent`) et devoirs
(:class:`~pronote_sync.models.homework.Homework`).
@@ -15,10 +15,8 @@ from __future__ import annotations
import hashlib
import re
import unicodedata
import urllib.parse
from datetime import date, datetime
from html import unescape
from pathlib import Path
from typing import NamedTuple, TypedDict
import requests
@@ -70,35 +68,21 @@ class ParsedHomeworkBlock(NamedTuple):
def fetch_ical(url: str, timeout: int = 20) -> str:
"""Récupère le contenu brut d'un flux iCal Pronote.
"""Récupère le contenu brut d'un flux iCal Pronote par HTTPS.
Gère les URLs ``file://`` pour les tests locaux (le chemin est
décodé de l'échappement URI, ex. ``%20`` → espace) et valide que
le flux commence bien par ``BEGIN:VCALENDAR``. Toutes les erreurs
sont relancées avec un message dont les secrets (token
``icalsecurise``) sont masqués.
Le schéma ``file://`` n'est pas supporté : les fixtures locales doivent
être injectées via un mock HTTP (``responses``) dans les tests. Valide que
le flux commence bien par ``BEGIN:VCALENDAR``. Toutes les erreurs sont
relancées avec un message dont les secrets (token ``icalsecurise``) sont
masqués.
:param url: URL du flux iCal (avec token ``icalsecurise``) ou chemin ``file://``.
:param url: URL HTTPS du flux iCal (avec token ``icalsecurise``).
:param timeout: Timeout HTTP en secondes (défaut : 20).
:return: Contenu brut du flux iCal.
:rtype: str
:raises OSError: Si le fichier local ``file://`` est illisible.
:raises requests.RequestException: Si la récupération HTTP échoue.
:raises ValueError: Si le flux ne commence pas par ``BEGIN:VCALENDAR``.
"""
if url.startswith("file://"):
parsed_url = urllib.parse.urlparse(url)
path = Path(urllib.parse.unquote(parsed_url.path))
try:
content = path.read_text(encoding="utf-8")
except OSError as exc:
raise OSError(
f"Impossible de lire le fichier iCal {redact_url(url)} : {redact_exception(exc)}"
) from exc
if not content.lstrip().startswith("BEGIN:VCALENDAR"):
raise ValueError(f"Fichier iCal invalide (pas de BEGIN:VCALENDAR) : {redact_url(url)}")
return content
try:
response = requests.get(url, headers=_HEADERS, timeout=timeout)
response.raise_for_status()
+5 -4
View File
@@ -55,14 +55,15 @@ def real_parsed_feed(blog_rss_fixture_path: Path) -> feedparser.FeedParserDict:
@pytest.fixture
def blog_client(blog_rss_fixture_path: Path) -> BlogRSSClient:
"""Instance de BlogRSSClient pointant vers le fixture local.
def blog_client() -> BlogRSSClient:
"""Instance de BlogRSSClient pointant vers une URL HTTPS non-réseau.
Les tests mockent ``requests.get`` : aucune requête réelle n'est émise.
:param blog_rss_fixture_path: Chemin vers le fichier fixture.
:return: Instance de BlogRSSClient.
:rtype: BlogRSSClient
"""
return BlogRSSClient(rss_url=f"file://{blog_rss_fixture_path}")
return BlogRSSClient(rss_url="https://example.com/blog/feed")
# --- Helper functions for mocking ---
+25 -15
View File
@@ -15,7 +15,7 @@ from unittest.mock import MagicMock
import pytest
from pydantic import SecretStr, ValidationError
from pronote_sync.config.settings import CalDAVSettings
from pronote_sync.config.settings import CalDAVSettings, ExternalEndpoint
from pronote_sync.errors import PronoteSyncError
from pronote_sync.sync.caldav import CalDAVGateway
@@ -25,6 +25,7 @@ if TYPE_CHECKING:
# Sentinelles pour détecter les fuites de secrets dans les tests
SENTINEL_URL = "https://user:pass-super-secret-12345@caldav.example.com/secret-path/"
SENTINEL_ENDPOINT_URL = "https://caldav.example.com/secret-path/?token=pass-super-secret-12345"
SENTINEL_PASSWORD = "super-secret-password-67890"
SENTINEL_HTTP_URL = "http://caldav.example.com/"
SENTINEL_HTTP_LOCALHOST = "http://localhost:5232/caldav/"
@@ -37,13 +38,13 @@ class TestCalDAVSettingsSecurity:
def test_url_redaction_in_repr(self) -> None:
"""Vérifie que l'URL brute n'apparaît pas dans repr(settings)."""
settings = CalDAVSettings(
url=SecretStr(SENTINEL_URL),
endpoint=ExternalEndpoint(url=SecretStr(SENTINEL_ENDPOINT_URL)),
username="test-user",
password=SecretStr(SENTINEL_PASSWORD),
calendar_path="/cal/",
)
repr_str = repr(settings)
assert SENTINEL_URL not in repr_str
assert SENTINEL_ENDPOINT_URL not in repr_str
assert "pass-super-secret-12345" not in repr_str
# L'URL est masquée par redact_url qui retourne REDACTED_URL ou une URL avec REDACTED
assert "REDACTED" in repr_str or "**********" in repr_str
@@ -51,13 +52,13 @@ class TestCalDAVSettingsSecurity:
def test_url_redaction_in_str(self) -> None:
"""Vérifie que l'URL brute n'apparaît pas dans str(settings)."""
settings = CalDAVSettings(
url=SecretStr(SENTINEL_URL),
endpoint=ExternalEndpoint(url=SecretStr(SENTINEL_ENDPOINT_URL)),
username="test-user",
password=SecretStr(SENTINEL_PASSWORD),
calendar_path="/cal/",
)
str_str = str(settings)
assert SENTINEL_URL not in str_str
assert SENTINEL_ENDPOINT_URL not in str_str
assert "pass-super-secret-12345" not in str_str
# L'URL est masquée par redact_url
assert "REDACTED" in str_str or "**********" in str_str
@@ -65,7 +66,7 @@ class TestCalDAVSettingsSecurity:
def test_url_redaction_in_model_dump(self) -> None:
"""Vérifie que l'URL brute n'apparaît pas dans model_dump()."""
settings = CalDAVSettings(
url=SecretStr(SENTINEL_URL),
endpoint=ExternalEndpoint(url=SecretStr(SENTINEL_ENDPOINT_URL)),
username="test-user",
password=SecretStr(SENTINEL_PASSWORD),
calendar_path="/cal/",
@@ -74,7 +75,7 @@ class TestCalDAVSettingsSecurity:
# Vérifie que l'URL n'est pas dans les valeurs du dict
for value in dumped.values():
if isinstance(value, str):
assert SENTINEL_URL not in value
assert SENTINEL_ENDPOINT_URL not in value
assert "pass-super-secret-12345" not in value
# Vérifie que la version rédigée est présente
assert "REDACTED" in str(dumped)
@@ -201,7 +202,7 @@ class TestCalDAVGatewaySecurity:
raise Exception(f"Connection failed to {SENTINEL_URL}?token={SENTINEL_PASSWORD}")
settings = CalDAVSettings(
url=SecretStr(SENTINEL_URL),
endpoint=ExternalEndpoint(url=SecretStr(SENTINEL_ENDPOINT_URL)),
username="test-user",
password=SecretStr(SENTINEL_PASSWORD),
calendar_path="/cal/",
@@ -215,12 +216,14 @@ class TestCalDAVGatewaySecurity:
# Vérifie que le message d'erreur ne contient pas les sentinelles
error_msg = str(exc_info.value)
assert SENTINEL_URL not in error_msg
assert SENTINEL_ENDPOINT_URL not in error_msg
assert SENTINEL_PASSWORD not in error_msg
# Vérifie que les logs ne contiennent pas les sentinelles
for record in caplog.records:
log_msg = record.getMessage()
assert SENTINEL_URL not in log_msg
assert SENTINEL_ENDPOINT_URL not in log_msg
assert SENTINEL_PASSWORD not in log_msg
def test_exception_cause_and_context_is_none(self) -> None:
@@ -230,7 +233,7 @@ class TestCalDAVGatewaySecurity:
raise Exception(f"Connection failed to {SENTINEL_URL}?token={SENTINEL_PASSWORD}")
settings = CalDAVSettings(
url=SecretStr(SENTINEL_URL),
endpoint=ExternalEndpoint(url=SecretStr(SENTINEL_ENDPOINT_URL)),
username="test-user",
password=SecretStr(SENTINEL_PASSWORD),
calendar_path="/cal/",
@@ -246,7 +249,7 @@ class TestCalDAVGatewaySecurity:
def test_logs_redacted_on_list_managed_events_error(self, caplog: LogCaptureFixture) -> None:
"""Vérifie que les logs sont expurgés lors d'une erreur dans list_managed_events."""
settings = CalDAVSettings(
url=SecretStr(SENTINEL_URL),
endpoint=ExternalEndpoint(url=SecretStr(SENTINEL_ENDPOINT_URL)),
username="test-user",
password=SecretStr(SENTINEL_PASSWORD),
calendar_path="/cal/",
@@ -274,12 +277,14 @@ class TestCalDAVGatewaySecurity:
# Vérifie que le message d'erreur ne contient pas les sentinelles
error_msg = str(exc_info.value)
assert SENTINEL_URL not in error_msg
assert SENTINEL_ENDPOINT_URL not in error_msg
assert SENTINEL_PASSWORD not in error_msg
# Vérifie que les logs ne contiennent pas les sentinelles
for record in caplog.records:
log_msg = record.getMessage()
assert SENTINEL_URL not in log_msg
assert SENTINEL_ENDPOINT_URL not in log_msg
assert SENTINEL_PASSWORD not in log_msg
# Vérifie que l'exception n'est chaînée à aucune exception brute
@@ -289,7 +294,7 @@ class TestCalDAVGatewaySecurity:
def test_logs_redacted_on_upsert_event_error(self, caplog: LogCaptureFixture) -> None:
"""Vérifie que les logs sont expurgés lors d'une erreur dans upsert_event."""
settings = CalDAVSettings(
url=SecretStr(SENTINEL_URL),
endpoint=ExternalEndpoint(url=SecretStr(SENTINEL_ENDPOINT_URL)),
username="test-user",
password=SecretStr(SENTINEL_PASSWORD),
calendar_path="/cal/",
@@ -312,12 +317,14 @@ class TestCalDAVGatewaySecurity:
# Vérifie que le message d'erreur ne contient pas les sentinelles
error_msg = str(exc_info.value)
assert SENTINEL_URL not in error_msg
assert SENTINEL_ENDPOINT_URL not in error_msg
assert SENTINEL_PASSWORD not in error_msg
# Vérifie que les logs ne contiennent pas les sentinelles
for record in caplog.records:
log_msg = record.getMessage()
assert SENTINEL_URL not in log_msg
assert SENTINEL_ENDPOINT_URL not in log_msg
assert SENTINEL_PASSWORD not in log_msg
# Vérifie que l'exception n'est chaînée à aucune exception brute
@@ -327,7 +334,7 @@ class TestCalDAVGatewaySecurity:
def test_logs_redacted_on_delete_event_error(self, caplog: LogCaptureFixture) -> None:
"""Vérifie que les logs sont expurgés lors d'une erreur dans delete_event."""
settings = CalDAVSettings(
url=SecretStr(SENTINEL_URL),
endpoint=ExternalEndpoint(url=SecretStr(SENTINEL_ENDPOINT_URL)),
username="test-user",
password=SecretStr(SENTINEL_PASSWORD),
calendar_path="/cal/",
@@ -349,12 +356,14 @@ class TestCalDAVGatewaySecurity:
# Vérifie que le message d'erreur ne contient pas les sentinelles
error_msg = str(exc_info.value)
assert SENTINEL_URL not in error_msg
assert SENTINEL_ENDPOINT_URL not in error_msg
assert SENTINEL_PASSWORD not in error_msg
# Vérifie que les logs ne contiennent pas les sentinelles
for record in caplog.records:
log_msg = record.getMessage()
assert SENTINEL_URL not in log_msg
assert SENTINEL_ENDPOINT_URL not in log_msg
assert SENTINEL_PASSWORD not in log_msg
# Vérifie que l'exception n'est chaînée à aucune exception brute
@@ -364,7 +373,7 @@ class TestCalDAVGatewaySecurity:
def test_redacted_url_stored_in_gateway(self) -> None:
"""Vérifie que l'URL rédigée est stockée sur l'instance de la passerelle."""
settings = CalDAVSettings(
url=SecretStr(SENTINEL_URL),
endpoint=ExternalEndpoint(url=SecretStr(SENTINEL_ENDPOINT_URL)),
username="test-user",
password=SecretStr(SENTINEL_PASSWORD),
calendar_path="/cal/",
@@ -372,8 +381,9 @@ class TestCalDAVGatewaySecurity:
gateway = CalDAVGateway(settings)
# Vérifie que l'URL rédigée est stockée
assert gateway._redacted_url is not None
assert SENTINEL_URL not in gateway._redacted_url
assert SENTINEL_ENDPOINT_URL not in gateway._redacted_url
assert "pass-super-secret-12345" not in gateway._redacted_url
assert "REDACTED" in gateway._redacted_url
# Vérifie que l'URL brute n'est pas stockée en clair
assert gateway._url_secret is not None
assert SENTINEL_URL not in str(gateway._url_secret)
assert SENTINEL_ENDPOINT_URL not in str(gateway._url_secret)
+412 -3
View File
@@ -7,13 +7,21 @@ textuelles et sérialisées, et que le rechargement fonctionne comme attendu.
from __future__ import annotations
from collections.abc import Callable
from typing import TYPE_CHECKING
import pytest
from pydantic import SecretStr, ValidationError
from pronote_sync.config.env import load_settings
from pronote_sync.config.settings import AppSettings, PronoteSettings, Settings
from pronote_sync.config.settings import (
AppSettings,
BlogSettings,
CalDAVSettings,
ExternalEndpoint,
PronoteSettings,
Settings,
)
if TYPE_CHECKING:
from _pytest.monkeypatch import MonkeyPatch
@@ -73,7 +81,7 @@ def test_secretstr_masking_ical_url() -> None:
# Vérification de la sérialisation JSON
json_str = settings.model_dump_json()
assert "SECRET_TOKEN" not in json_str
assert "**********" in json_str
assert "REDACTED" in json_str
def test_secretstr_masking_password() -> None:
@@ -128,7 +136,183 @@ def test_url_from_pronote_url_env_var(monkeypatch: MonkeyPatch) -> None:
test_url = "https://example.index-education.net/pronote/parent.html"
monkeypatch.setenv("PRONOTE_URL", test_url)
settings = load_settings()
assert settings.pronote.url == test_url
assert settings.pronote.endpoint is not None
assert settings.pronote.endpoint.url.get_secret_value() == test_url
@pytest.mark.parametrize(
"url",
[
"https://endpoint.example.test/api",
"https://endpoint.example.test:8443/ical?icalsecurise=TOKEN", # pragma: allowlist secret
],
)
def test_external_endpoint_accepts_supported_schemes(url: str) -> None:
"""Vérifie le socle commun des URL d'endpoints autorisés.
:param url: URL représentative du schéma à valider.
:return: None
"""
endpoint = ExternalEndpoint(url=SecretStr(url))
assert endpoint.url.get_secret_value() == url
@pytest.mark.parametrize(
"url",
["ftp://endpoint.example.test", "https:///missing-host", "https://host:bad", "file://"],
)
def test_external_endpoint_rejects_invalid_urls_without_leak(url: str) -> None:
"""Vérifie que le socle rejette les URL invalides sans les afficher.
:param url: URL invalide à refuser.
:return: None
"""
with pytest.raises(ValidationError) as exc_info:
ExternalEndpoint(url=SecretStr(url))
assert url not in str(exc_info.value)
def test_external_endpoint_is_immutable_and_redacted() -> None:
"""Vérifie le contrat immuable et expurgé du value object.
:return: None
"""
secret = "SECRET_TOKEN_XYZ" # pragma: allowlist secret
endpoint = ExternalEndpoint(
url=SecretStr(f"https://example.test/calendar?icalsecurise={secret}")
)
with pytest.raises(ValidationError):
endpoint.url = SecretStr("https://other.example.test")
assert secret not in endpoint.model_dump_json()
assert "REDACTED" in endpoint.model_dump_json()
with pytest.raises(ValidationError):
ExternalEndpoint(url=SecretStr("https://example.test/calendar"), unknown_field="x") # type: ignore[call-arg]
def test_caldav_endpoint_loads_from_nested_environment(monkeypatch: MonkeyPatch) -> None:
"""Vérifie le chargement du nouvel endpoint CalDAV depuis l'environnement.
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
:return: None
"""
monkeypatch.setenv("CALDAV_ENDPOINT__URL", "https://caldav.example.test/dav")
settings = load_settings()
assert settings.caldav.endpoint is not None
assert settings.caldav.endpoint.url.get_secret_value() == "https://caldav.example.test/dav"
def test_caldav_legacy_url_migrates_with_warning() -> None:
"""Vérifie la migration temporaire du réglage CalDAV historique.
:return: None
"""
with pytest.warns(DeprecationWarning, match="CALDAV_URL"):
settings = CalDAVSettings(url=SecretStr("https://caldav.example.test/dav"))
assert settings.endpoint is not None
assert settings.endpoint.url.get_secret_value() == "https://caldav.example.test/dav"
@pytest.mark.parametrize(
"url",
[
"https://user:pass@caldav.example.test/dav", # pragma: allowlist secret
"https:///missing-host",
"https://host:bad",
"file:///tmp/fixture.ics",
],
)
def test_caldav_legacy_url_alias_rejects_invalid_urls(url: str) -> None:
"""L'alias obsolète ``CALDAV_URL`` ne contourne pas la validation canonique.
Contrat corrigé : ``ExternalEndpoint.model_construct`` n'est plus utilisé
pour grandfathered les identifiants embarqués. L'alias est migré via le
chemin de validation normal, donc les credentials embarqués, l'hôte
manquant, le port invalide et le schéma ``file://`` sont refusés,
exactement comme pour ``CALDAV_ENDPOINT__URL``.
:param url: URL invalide fournie via l'alias historique.
:return: None
"""
with pytest.raises(ValidationError):
with pytest.warns(DeprecationWarning, match="CALDAV_URL"):
CalDAVSettings(url=SecretStr(url))
def test_pronote_endpoints_load_from_nested_environment(monkeypatch: MonkeyPatch) -> None:
"""Vérifie le chargement des deux endpoints Pronote depuis l'environnement.
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
:return: None
"""
monkeypatch.setenv("PRONOTE_ENDPOINT__URL", "https://pronote.example.test/parent.html")
monkeypatch.setenv("PRONOTE_ICAL_ENDPOINT__URL", "https://ical.example.test/pronote.ics")
settings = load_settings()
assert settings.pronote.endpoint is not None
assert settings.pronote.ical_endpoint is not None
assert (
settings.pronote.endpoint.url.get_secret_value()
== "https://pronote.example.test/parent.html"
)
assert (
settings.pronote.ical_endpoint.url.get_secret_value()
== "https://ical.example.test/pronote.ics"
)
@pytest.mark.parametrize(
("field", "url", "message"),
[
("endpoint", "http://pronote.example.test", "HTTPS requis"),
("ical_endpoint", "http://pronote.example.test/calendar", "URL iCal Pronote invalide"),
],
)
def test_pronote_endpoint_policy_rejects_insecure_url(field: str, url: str, message: str) -> None:
"""Vérifie la politique de transport des endpoints Pronote.
:param field: Nom du champ endpoint à alimenter.
:param url: URL non sûre à refuser.
:param message: Fragment attendu du message sûr.
:return: None
"""
endpoint = ExternalEndpoint(url=SecretStr(url))
with pytest.raises(ValidationError, match=message) as exc_info:
if field == "endpoint":
PronoteSettings(endpoint=endpoint)
else:
PronoteSettings(ical_endpoint=endpoint)
assert url not in str(exc_info.value)
def test_blog_endpoint_loads_from_nested_environment(monkeypatch: MonkeyPatch) -> None:
"""Vérifie le chargement du nouvel endpoint RSS depuis l'environnement.
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
:return: None
"""
monkeypatch.setenv("BLOG_ENDPOINT__URL", "https://blog.example.test/feed")
settings = load_settings()
assert settings.blog.endpoint.url.get_secret_value() == "https://blog.example.test/feed"
def test_blog_legacy_rss_url_migrates_with_warning() -> None:
"""Vérifie la migration temporaire du réglage RSS historique.
:return: None
"""
with pytest.warns(DeprecationWarning, match="BLOG_RSS_URL"):
settings = BlogSettings(rss_url="https://blog.example.test/feed")
assert settings.endpoint.url.get_secret_value() == "https://blog.example.test/feed"
def test_blog_endpoint_rejects_insecure_url_without_leak() -> None:
"""Vérifie que l'URL RSS HTTP est refusée sans être exposée.
:return: None
"""
url = "http://user:secret@blog.example.test/feed" # pragma: allowlist secret
with pytest.raises(ValidationError) as exc_info:
BlogSettings(endpoint=ExternalEndpoint(url=SecretStr(url)))
assert url not in str(exc_info.value)
def test_auth_mode_default_password() -> None:
@@ -342,3 +526,228 @@ def test_sync_future_days_positive_env_loading(monkeypatch: MonkeyPatch) -> None
monkeypatch.setenv("SYNC_FUTURE_DAYS", "30")
settings = load_settings()
assert settings.app.sync_future_days == 30
# --- Contrat des endpoints externes (matrice partagée) ---
_ConnectorSettings = CalDAVSettings | PronoteSettings | BlogSettings
_CONNECTOR_BUILDERS: dict[str, Callable[[str], _ConnectorSettings]] = {
"caldav": lambda url: CalDAVSettings(endpoint=ExternalEndpoint(url=SecretStr(url))),
"pronote_ical": lambda url: PronoteSettings(ical_endpoint=ExternalEndpoint(url=SecretStr(url))),
"blog": lambda url: BlogSettings(endpoint=ExternalEndpoint(url=SecretStr(url))),
}
def test_external_endpoint_matrix_accepts_https() -> None:
"""HTTPS est accepté par les trois connecteurs (CalDAV, Pronote iCal, Blog RSS).
:return: None
"""
url = "https://endpoint.example.test/feed"
for name, build in _CONNECTOR_BUILDERS.items():
settings = build(url)
endpoint = getattr(settings, "endpoint", None) or settings.ical_endpoint # type: ignore[union-attr]
assert endpoint is not None
assert endpoint.url.get_secret_value() == url, name
@pytest.mark.parametrize("connector", ["caldav", "pronote_ical", "blog"])
def test_external_endpoint_matrix_rejects_file_scheme(connector: str) -> None:
"""Le schéma ``file://`` est refusé par les trois connecteurs.
Contrat corrigé : les endpoints externes doivent être réseau (HTTPS) ;
aucun connecteur n'accepte un fichier local.
:param connector: Nom du connecteur testé.
:return: None
"""
with pytest.raises(ValidationError):
_CONNECTOR_BUILDERS[connector]("file:///tmp/fixture.ics")
@pytest.mark.parametrize(
("connector", "url"),
[
("caldav", "http://endpoint.example.test/feed"),
("pronote_ical", "http://pronote.example.test/ical.ics"),
("blog", "http://blog.example.test/feed"),
("caldav", "ftp://endpoint.example.test/feed"),
("pronote_ical", "gopher://pronote.example.test/ical.ics"),
("blog", "ftp://blog.example.test/feed"),
],
)
def test_external_endpoint_matrix_rejects_insecure_and_other_schemes(
connector: str, url: str
) -> None:
"""HTTP non-loopback et les schémas non HTTP(S) sont refusés par les trois connecteurs.
:param connector: Nom du connecteur testé.
:param url: URL à refuser.
:return: None
"""
with pytest.raises(ValidationError):
_CONNECTOR_BUILDERS[connector](url)
@pytest.mark.parametrize(
("connector", "url"),
[
("caldav", "http://localhost:8080/dav"),
("caldav", "http://127.0.0.1:8080/dav"),
("caldav", "http://[::1]:8080/dav"),
("pronote_ical", "http://localhost/ical.ics"),
("blog", "http://localhost/feed"),
],
)
def test_external_endpoint_matrix_rejects_loopback_http_by_default(
connector: str, url: str
) -> None:
"""HTTP loopback est refusé sans autorisation explicite du mode non sécurisé.
:param connector: Nom du connecteur testé.
:param url: URL loopback HTTP à refuser.
:return: None
"""
with pytest.raises(ValidationError):
_CONNECTOR_BUILDERS[connector](url)
@pytest.mark.parametrize(
"url",
["http://localhost:8080/dav", "http://127.0.0.1:8080/dav", "http://[::1]:8080/dav"],
)
def test_caldav_endpoint_accepts_loopback_http_only_with_insecure_flag(url: str) -> None:
"""CalDAV accepte HTTP loopback uniquement avec ``allow_insecure_http=True``.
:param url: URL loopback HTTP à accepter.
:return: None
"""
settings = CalDAVSettings(
endpoint=ExternalEndpoint(url=SecretStr(url)), allow_insecure_http=True
)
assert settings.endpoint is not None
assert settings.endpoint.url.get_secret_value() == url
@pytest.mark.parametrize(
("connector", "url"),
[
("caldav", "https:///missing-host"),
("pronote_ical", "https:///missing-host"),
("blog", "https:///missing-host"),
("caldav", "https://host:bad"),
("pronote_ical", "https://host:bad"),
("blog", "https://host:bad"),
],
)
def test_external_endpoint_matrix_rejects_malformed_urls(connector: str, url: str) -> None:
"""Les URL malformées (hôte manquant, port invalide) sont refusées.
:param connector: Nom du connecteur testé.
:param url: URL malformée à refuser.
:return: None
"""
with pytest.raises(ValidationError):
_CONNECTOR_BUILDERS[connector](url)
@pytest.mark.parametrize(
"connector",
["caldav", "pronote_ical", "blog"],
)
def test_external_endpoint_matrix_rejects_userinfo(connector: str) -> None:
"""Les credentials dans l'URL (``user:pass@host``) sont refusés.
:param connector: Nom du connecteur testé.
:return: None
"""
url = "https://user:pass@endpoint.example.test/feed" # pragma: allowlist secret
with pytest.raises(ValidationError):
_CONNECTOR_BUILDERS[connector](url)
@pytest.mark.parametrize(
"connector",
["caldav", "pronote_ical", "blog"],
)
def test_external_endpoint_matrix_redacts_sensitive_query_params(connector: str) -> None:
"""Les paramètres sensibles de la query sont masqués à la sérialisation.
:param connector: Nom du connecteur testé.
:return: None
"""
secret = "SECRET_QUERY_TOKEN" # pragma: allowlist secret
url = f"https://endpoint.example.test/feed?icalsecurise={secret}"
settings = _CONNECTOR_BUILDERS[connector](url)
endpoint = getattr(settings, "endpoint", None) or settings.ical_endpoint # type: ignore[union-attr]
assert endpoint is not None
dumped = endpoint.model_dump_json()
assert secret not in dumped
assert "REDACTED" in dumped
assert secret not in repr(endpoint)
@pytest.mark.parametrize(
("connector", "legacy_field", "canonical_url", "legacy_url"),
[
("caldav", "url", "https://canonical.example.test/dav", "https://legacy.example.test/dav"),
(
"blog",
"rss_url",
"https://canonical.example.test/feed",
"https://legacy.example.test/feed",
),
(
"pronote",
"url",
"https://canonical.example.test/parent.html",
"https://legacy.example.test/parent.html",
),
(
"pronote",
"ical_url",
"https://canonical.example.test/ical.ics",
"https://legacy.example.test/ical.ics",
),
],
)
def test_deprecated_aliases_warn_and_canonical_wins(
connector: str, legacy_field: str, canonical_url: str, legacy_url: str
) -> None:
"""Les alias obsolètes émettent un DeprecationWarning et l'URL canonique gagne.
:param connector: Connecteur testé (``caldav``, ``blog`` ou ``pronote``).
:param legacy_field: Nom du champ obsolète.
:param canonical_url: URL canonique (doit gagner).
:param legacy_url: URL fournie via l'alias obsolète.
:return: None
"""
if connector == "caldav":
caldav_kwargs: dict[str, object] = {
legacy_field: SecretStr(legacy_url),
"endpoint": {"url": canonical_url},
}
with pytest.warns(DeprecationWarning, match="CALDAV_URL"):
caldav_settings = CalDAVSettings(**caldav_kwargs) # type: ignore[arg-type]
assert caldav_settings.endpoint is not None
assert caldav_settings.endpoint.url.get_secret_value() == canonical_url
elif connector == "blog":
blog_kwargs: dict[str, object] = {
legacy_field: legacy_url,
"endpoint": {"url": canonical_url},
}
with pytest.warns(DeprecationWarning, match="BLOG_RSS_URL"):
blog_settings = BlogSettings(**blog_kwargs) # type: ignore[arg-type]
assert blog_settings.endpoint.url.get_secret_value() == canonical_url
else:
canonical_field = "endpoint" if legacy_field == "url" else "ical_endpoint"
legacy_value: object = SecretStr(legacy_url) if legacy_field == "ical_url" else legacy_url
pronote_kwargs: dict[str, object] = {
legacy_field: legacy_value,
canonical_field: {"url": canonical_url},
}
with pytest.warns(DeprecationWarning, match="PRONOTE_"):
pronote_settings = PronoteSettings(**pronote_kwargs) # type: ignore[arg-type]
endpoint = getattr(pronote_settings, canonical_field)
assert endpoint is not None
assert endpoint.url.get_secret_value() == canonical_url
+23 -20
View File
@@ -54,7 +54,7 @@ def fixture_mock_settings() -> Settings:
return Settings(
pronote=PronoteSettings(
url="https://pronote.example.com",
ical_url=SecretStr("file:///fake/ical.ics"),
ical_url=SecretStr("https://ical.example.test/ical.ics"),
agenda_source="auto",
homework_source="auto",
username="testuser",
@@ -251,7 +251,7 @@ def test_fetch_agenda_auto_both_fail(mock_fetcher: PronoteFetcher) -> None:
:rtype: None
"""
# Disable pronotepy so fallback is None
mock_fetcher._settings.pronote.url = None
mock_fetcher._settings.pronote.endpoint = None
with (
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
@@ -280,14 +280,15 @@ def test_fetch_agenda_ical_mode_failure(mock_fetcher: PronoteFetcher) -> None:
"""
# Override settings to use ical mode explicitly and disable fallback
mock_fetcher._settings.pronote.agenda_source = "ical"
mock_fetcher._settings.pronote.url = None
mock_fetcher._settings.pronote.endpoint = None
with (
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
patch("pronote_sync.sources.pronote.fallback.parse_ical") as m_parse_ical,
):
m_fetch_ical.side_effect = OSError(
"Impossible de lire le fichier iCal file:///fake/ical.ics : iCal unreachable"
"Échec de la récupération du flux iCal https://ical.example.test/ical.ics : "
"iCal unreachable"
)
m_parse_ical.side_effect = OSError("iCal parse error")
@@ -296,7 +297,7 @@ def test_fetch_agenda_ical_mode_failure(mock_fetcher: PronoteFetcher) -> None:
assert "Impossible de récupérer l'agenda : la source ical a échoué" in str(exc_info.value)
# Vérifie que le message ne contient pas de secret
assert "file:///fake/ical.ics" not in str(exc_info.value)
assert "https://ical.example.test/ical.ics" not in str(exc_info.value)
def test_fetch_agenda_pronotepy_mode_failure(mock_fetcher: PronoteFetcher) -> None:
@@ -311,7 +312,7 @@ def test_fetch_agenda_pronotepy_mode_failure(mock_fetcher: PronoteFetcher) -> No
"""
# Override settings to use pronotepy mode explicitly and disable fallback
mock_fetcher._settings.pronote.agenda_source = "pronotepy"
mock_fetcher._settings.pronote.ical_url = None
mock_fetcher._settings.pronote.ical_endpoint = None
client = MagicMock()
client.get_lessons.side_effect = OSError("Pronote API error")
@@ -438,7 +439,7 @@ def test_fetch_homework_auto_both_fail(mock_fetcher: PronoteFetcher) -> None:
target_date = date(2025, 9, 10)
# Disable pronotepy so fallback is None
mock_fetcher._settings.pronote.url = None
mock_fetcher._settings.pronote.endpoint = None
with (
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
@@ -531,15 +532,15 @@ def test_no_secrets_in_error_messages(
:rtype: None
"""
# Disable pronotepy so fallback is None to trigger PipelineCriticalError
mock_fetcher._settings.pronote.url = None
mock_fetcher._settings.pronote.endpoint = None
with (
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
patch("pronote_sync.sources.pronote.fallback.parse_ical") as m_parse_ical,
):
error_msg = (
"Impossible de lire le fichier iCal file:///ical?icalsecurise=SECRET_TOKEN_123 : "
"[Errno 2] No such file or directory"
"Échec de la récupération du flux iCal https://ical.example.test/ical.ics"
"?icalsecurise=SECRET_TOKEN_123 : [Errno 2] No such file or directory"
)
m_fetch_ical.side_effect = OSError(error_msg)
m_parse_ical.side_effect = OSError("parse error")
@@ -628,8 +629,8 @@ def test_fetch_agenda_no_source_configured_raises(mock_fetcher: PronoteFetcher)
:rtype: None
"""
# Disable both sources
mock_fetcher._settings.pronote.ical_url = None
mock_fetcher._settings.pronote.url = None
mock_fetcher._settings.pronote.ical_endpoint = None
mock_fetcher._settings.pronote.endpoint = None
with pytest.raises(PipelineCriticalError) as exc_info:
mock_fetcher.fetch_agenda()
@@ -912,8 +913,10 @@ def test_fetch_agenda_ical_url_none_raises_value_error(mock_fetcher: PronoteFetc
:return: None
:rtype: None
"""
mock_fetcher._settings.pronote.ical_url = None
with pytest.raises(ValueError, match="PRONOTE_ICAL_URL est requis pour la source iCal"):
mock_fetcher._settings.pronote.ical_endpoint = None
with pytest.raises(
ValueError, match="PRONOTE_ICAL_ENDPOINT__URL est requis pour la source iCal"
):
mock_fetcher._fetch_agenda_ical()
@@ -928,7 +931,7 @@ def test_agenda_sources_auto_only_pronotepy_configured(mock_fetcher: PronoteFetc
:rtype: None
"""
mock_fetcher._settings.pronote.agenda_source = "auto"
mock_fetcher._settings.pronote.ical_url = None
mock_fetcher._settings.pronote.ical_endpoint = None
primary, fallback = mock_fetcher._agenda_sources()
assert primary == "pronotepy"
assert fallback is None
@@ -1020,7 +1023,7 @@ def test_homework_sources_explicit_ical_mode_strict(mock_fetcher: PronoteFetcher
assert fallback is None
# Without pronotepy configured
mock_fetcher._settings.pronote.url = None
mock_fetcher._settings.pronote.endpoint = None
primary, fallback = mock_fetcher._homework_sources()
assert primary == "ical"
assert fallback is None
@@ -1046,7 +1049,7 @@ def test_homework_sources_explicit_pronotepy_mode_strict(
assert fallback is None
# Without ical configured
mock_fetcher._settings.pronote.ical_url = None
mock_fetcher._settings.pronote.ical_endpoint = None
primary, fallback = mock_fetcher._homework_sources()
assert primary == "pronotepy"
assert fallback is None
@@ -1063,7 +1066,7 @@ def test_homework_sources_auto_only_pronotepy_configured(mock_fetcher: PronoteFe
:rtype: None
"""
mock_fetcher._settings.pronote.homework_source = "auto"
mock_fetcher._settings.pronote.ical_url = None
mock_fetcher._settings.pronote.ical_endpoint = None
primary, fallback = mock_fetcher._homework_sources()
assert primary == "pronotepy"
assert fallback is None
@@ -1079,8 +1082,8 @@ def test_homework_sources_auto_no_source_configured_raises(mock_fetcher: Pronote
:rtype: None
"""
mock_fetcher._settings.pronote.homework_source = "auto"
mock_fetcher._settings.pronote.ical_url = None
mock_fetcher._settings.pronote.url = None
mock_fetcher._settings.pronote.ical_endpoint = None
mock_fetcher._settings.pronote.endpoint = None
with pytest.raises(PipelineCriticalError) as exc_info:
mock_fetcher._homework_sources()
+20 -21
View File
@@ -1,7 +1,7 @@
"""Tests unitaires pour le module iCal : téléchargement et parsing.
Ce module teste :
- La récupération du flux iCal (file://, HTTP)
- La récupération du flux iCal (HTTP)
- Le parsing des événements en modèles Lesson, SchoolEvent
- L'extraction et normalisation des devoirs
- La collecte et déduplication des devoirs par date cible
@@ -11,8 +11,6 @@ Les tests utilisent des mocks pour éviter tout appel réseau réel.
from __future__ import annotations
import tempfile
import urllib.parse
from datetime import date, datetime
from pathlib import Path
@@ -57,37 +55,38 @@ def invalid_ical_content() -> str:
return "INVALID:CONTENT\nThis is not a valid iCal file."
def test_fetch_ical_file_protocol() -> None:
"""fetch_ical("file://tests/fixtures/pronote-4e.ics") retourne un contenu commençant par BEGIN:VCALENDAR.
@responses.activate
def test_fetch_ical_fixture_content() -> None:
"""fetch_ical("https://…") sur un mock réseau retourne un contenu commençant par BEGIN:VCALENDAR.
:return: None
"""
fixture_path = Path(__file__).parent.parent / "fixtures" / "pronote-4e.ics"
url = f"file://{fixture_path}"
responses.add(
responses.GET,
"https://pronote.example.test/ical.ics",
body=fixture_path.read_text(encoding="utf-8"),
status=200,
)
content = fetch_ical(url)
content = fetch_ical("https://pronote.example.test/ical.ics")
assert content.lstrip().startswith("BEGIN:VCALENDAR")
def test_fetch_ical_file_uri_decoding() -> None:
"""fetch_ical("file://path%20with%20spaces") décode correctement le chemin.
@responses.activate
def test_fetch_ical_url_with_encoded_characters() -> None:
"""fetch_ical préserve les caractères encodés (%20) de l'URL interrogée.
:return: None
"""
# Créer un fichier temporaire avec un espace dans le nom
with tempfile.TemporaryDirectory() as tmpdir:
temp_path = Path(tmpdir) / "fichier avec espaces.ics"
temp_path.write_text(
"BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//Test//Test//FR\nEND:VCALENDAR",
encoding="utf-8",
responses.add(
responses.GET,
"https://pronote.example.test/fichier%20avec%20espaces.ics",
body="BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//Test//Test//FR\nEND:VCALENDAR",
status=200,
)
# URL encodée avec espace
encoded_name = urllib.parse.quote("fichier avec espaces.ics")
url = f"file://{tmpdir}/{encoded_name}"
# Cela devrait fonctionner car Path.read_text décode l'URL
content = fetch_ical(url)
content = fetch_ical("https://pronote.example.test/fichier%20avec%20espaces.ics")
assert content.lstrip().startswith("BEGIN:VCALENDAR")