Compare commits

...
Author SHA1 Message Date
Codex ee89f13d67 refactor(config): migrer les endpoints Pronote
Co-authored-by: Codex <codex@antoineve.me>
2026-09-13 11:54:19 +02:00
Codex 097a27fcf3 refactor(config): migrer le flux RSS vers l'endpoint commun
Co-authored-by: Codex <codex@antoineve.me>
2026-09-13 11:52:37 +02:00
Codex 93f66d6aca refactor(config): introduire l'endpoint CalDAV commun
Co-authored-by: Codex <codex@antoineve.me>
2026-09-13 11:50:20 +02:00
Codex eef81ea323 fix(docs): corriger le répertoire de clonage
Closes #56

Co-authored-by: Codex <codex@antoineve.me>
2026-09-13 00:25:28 +02:00
Codex a21acaa409 docs(packaging): aligner les métadonnées du projet
Closes #18

Co-authored-by: Codex <codex@antoineve.me>
2026-09-13 00:24:10 +02:00
Codex f261fed1af fix(blog): confirmer l'état mémoire après sauvegarde
Closes #49

Co-authored-by: Codex <codex@antoineve.me>
2026-09-13 00:15:07 +02:00
18 changed files with 565 additions and 152 deletions
+8 -4
View File
@@ -1,6 +1,7 @@
# --- Pronote --- # --- Pronote ---
PRONOTE_ICAL_URL=https://college.ent/pronote/ical/Edt_Jean.ics?icalsecurise=REPLACE_ME&version=2024 PRONOTE_ICAL_ENDPOINT__URL=https://college.ent/pronote/ical/Edt_Jean.ics?icalsecurise=REPLACE_ME&version=2024
PRONOTE_URL=https://college.ent/pronote/parent.html PRONOTE_ENDPOINT__URL=https://college.ent/pronote/parent.html
# Anciens noms temporairement supportés avec un avertissement : PRONOTE_ICAL_URL, PRONOTE_URL
PRONOTE_ACCOUNT_TYPE=parent PRONOTE_ACCOUNT_TYPE=parent
PRONOTE_USERNAME=parent.dupont PRONOTE_USERNAME=parent.dupont
PRONOTE_PASSWORD=your_secure_password PRONOTE_PASSWORD=your_secure_password
@@ -28,7 +29,9 @@ PRONOTE_AUTH_MODE=password
# PRONOTE_ACCOUNT_PIN= # PRONOTE_ACCOUNT_PIN=
# --- CalDAV --- # --- CalDAV ---
CALDAV_URL=https://caldav.example.com/calendars/user/pronote/ # Endpoint commun (URL potentiellement sensible, masquée dans les journaux)
CALDAV_ENDPOINT__URL=https://caldav.example.com/calendars/user/pronote/
# Ancien nom temporairement supporté avec un avertissement de dépréciation : CALDAV_URL
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/ CALDAV_CALENDAR_PATH=/pronote-sync/
@@ -92,7 +95,8 @@ AI_BASE_URL=https://api.openai.com/v1
# --- Blog --- # --- Blog ---
BLOG_ENABLED=false 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 temporairement supporté avec un avertissement de dépréciation : BLOG_RSS_URL
# --- Divers --- # --- Divers ---
DRY_RUN=false DRY_RUN=false
+2 -2
View File
@@ -140,7 +140,7 @@
"filename": "GUIDE_DEV_PYTHON.md", "filename": "GUIDE_DEV_PYTHON.md",
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa", "hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
"is_verified": false, "is_verified": false,
"line_number": 5186 "line_number": 5188
} }
], ],
"tests/unit/test_caldav_gateway.py": [ "tests/unit/test_caldav_gateway.py": [
@@ -185,5 +185,5 @@
} }
] ]
}, },
"generated_at": "2026-09-12T22:12:56Z" "generated_at": "2026-09-13T09:54:04Z"
} }
+2 -3
View File
@@ -40,7 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.1.0] - 2026-09-08 ## [0.1.0] - 2026-09-08
Initial release covering milestones M1 through M15. Initial release covering milestones M1 through M15, except the optional Gitea Actions workflow.
### Added ### Added
- **M1 (Scaffolding)**: Python project structure with `pyproject.toml`, and tooling configuration for `ruff`, `mypy`, `bandit`, and `pre-commit`. - **M1 (Scaffolding)**: Python project structure with `pyproject.toml`, and tooling configuration for `ruff`, `mypy`, `bandit`, and `pre-commit`.
@@ -57,5 +57,4 @@ Initial release covering milestones M1 through M15.
- **M12 (CLI entry point)**: `pronote-sync` command with `--dry-run` and `--log-level` options, redacted error display, and safe traceback in DEBUG mode. - **M12 (CLI entry point)**: `pronote-sync` command with `--dry-run` and `--log-level` options, redacted error display, and safe traceback in DEBUG mode.
- **M13 (Tests & coverage)**: 636 tests with 95.67% coverage, test fixtures (`pronote-4e.ics`, `pronote-6e.ics`), shared `conftest.py`, and secret non-leak tests. - **M13 (Tests & coverage)**: 636 tests with 95.67% coverage, test fixtures (`pronote-4e.ics`, `pronote-6e.ics`), shared `conftest.py`, and secret non-leak tests.
- **M14 (Deployment)**: systemd service and timer (daily at 18:00), logrotate configuration (daily, rotate 7, compress), `check_secrets.py` pre-deployment scanner, and exploitation guide. - **M14 (Deployment)**: systemd service and timer (daily at 18:00), logrotate configuration (daily, rotate 7, compress), `check_secrets.py` pre-deployment scanner, and exploitation guide.
- **M15 (Documentation)**: README, README.LLM.md (AI agent setup guide), MIT LICENSE, CHANGELOG, and Gitea Actions CI/CD reference for LXC/VPS (Debian/CentOS). - **M15 (Documentation)**: README, README.LLM.md (AI agent setup guide), MIT LICENSE, CHANGELOG, and local validation procedures. Gitea Actions CI/CD remains optional and is not delivered in this release.
- **Other**: MIT License. Gitea Actions CI/CD reference for LXC/VPS (Debian/CentOS) is planned and optional, not delivered in this release.
+34 -30
View File
@@ -259,12 +259,12 @@ Le projet utilise **`pydantic-settings`** pour valider et charger la configurati
| Variable | Description | Exemple (anonymisé) | Type | | 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_ENDPOINT__URL` | URL de la page Pronote via l'endpoint commun. | `https://college.ent/pronote/parent.html` | `ExternalEndpoint` |
| `PRONOTE_ICAL_URL` | URL du flux iCal Pronote (contient `icalsecurise`). | `https://college.ent/pronote/ical/...` | `SecretStr` | | `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_USERNAME` | Identifiant Pronote (si `pronotepy` utilisé). | `parent.dupont` | `str` |
| `PRONOTE_PASSWORD` | Mot de passe Pronote (si `pronotepy` utilisé). | `SecretStr` (masqué) | `SecretStr` | | `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` | | `PRONOTE_ENT` | Slug ENT supporté, résolu vers une fonction de `pronotepy.ent`. | `monbureaunumerique` | `str` |
| `CALDAV_URL` | URL du serveur CalDAV (masquée en `SecretStr`). | `https://caldav.example.com/calendars/...` | `SecretStr` | | `CALDAV_ENDPOINT__URL` | URL du serveur CalDAV, via l'endpoint commun (masquée en `SecretStr`). | `https://caldav.example.com/calendars/...` | `ExternalEndpoint` |
| `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` |
@@ -277,17 +277,22 @@ 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). > `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`. > 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`). > `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`.
Les variables Pronote sont obligatoires selon les sources activées : Les variables Pronote sont obligatoires selon les sources activées :
- la source iCal exige `PRONOTE_ICAL_URL` ; - la source iCal exige `PRONOTE_ICAL_ENDPOINT__URL` ;
- la source `pronotepy` exige `PRONOTE_URL`, `PRONOTE_USERNAME` et - la source `pronotepy` exige `PRONOTE_ENDPOINT__URL`, `PRONOTE_USERNAME` et
`PRONOTE_PASSWORD` ; `PRONOTE_PASSWORD` ;
- `PRONOTE_ENT` reste optionnel pour une connexion directe, mais, s'il est fourni, son slug doit - `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`. 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 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 `pronotepy.ParentClient`. Une généralisation à plusieurs profils ne sera ajoutée qu'en présence
d'un besoin réel et testé. d'un besoin réel et testé.
@@ -323,8 +328,8 @@ d'un besoin réel et testé.
```ini ```ini
# --- Pronote --- # --- Pronote ---
PRONOTE_URL=https://college.ent/pronote/parent.html PRONOTE_ENDPOINT__URL=https://college.ent/pronote/parent.html
PRONOTE_ICAL_URL=https://college.ent/pronote/ical/Edt_Jean.ics?icalsecurise=REPLACE_ME&version=2024 PRONOTE_ICAL_ENDPOINT__URL=https://college.ent/pronote/ical/Edt_Jean.ics?icalsecurise=REPLACE_ME&version=2024
PRONOTE_USERNAME=parent.dupont PRONOTE_USERNAME=parent.dupont
PRONOTE_PASSWORD=your_secure_password PRONOTE_PASSWORD=your_secure_password
PRONOTE_ENT=monbureaunumerique PRONOTE_ENT=monbureaunumerique
@@ -335,7 +340,7 @@ PRONOTE_HOMEWORK_SOURCE=auto
PRONOTE_MESSAGES_SOURCE=pronotepy PRONOTE_MESSAGES_SOURCE=pronotepy
# --- CalDAV --- # --- 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_ALLOW_INSECURE_HTTP=false
CALDAV_USERNAME=user@example.com CALDAV_USERNAME=user@example.com
CALDAV_PASSWORD=your_caldav_password CALDAV_PASSWORD=your_caldav_password
@@ -389,7 +394,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]`. > 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`. > `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`. > `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`. > `sync_past_days` et `sync_future_days` sont dans `AppSettings`, et non `CalDAVSettings`.
> `CalDAVSettings.calendar_path` a pour valeur par défaut `"/pronote-sync/"`. > `CalDAVSettings.calendar_path` a pour valeur par défaut `"/pronote-sync/"`.
> `XmppSettings.resource` a pour valeur par défaut `"pronote-sync"`. > `XmppSettings.resource` a pour valeur par défaut `"pronote-sync"`.
@@ -403,9 +408,9 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class PronoteSettings(BaseSettings): class PronoteSettings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="PRONOTE_", env_file=".env", extra="ignore") model_config = SettingsConfigDict(env_prefix="PRONOTE_", env_file=".env", env_nested_delimiter="__", extra="ignore")
url: str | None = None endpoint: ExternalEndpoint | None = None
ical_url: SecretStr | None = None ical_endpoint: ExternalEndpoint | 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
@@ -1113,7 +1118,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. Aucun fichier d'état local n'est utilisé : l'état est géré en mémoire par run.
# Initialisation # 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)() blog_state = ## (section obsolète supprimée)()
# Récupération des nouveaux articles # Récupération des nouveaux articles
@@ -1218,7 +1223,7 @@ Ajouter les variables suivantes dans la configuration :
| **Variable** | **Description** | **Valeur par défaut** | **Type** | | **Variable** | **Description** | **Valeur par défaut** | **Type** |
|----------------------------|-------------------------------------------------------------------------------|-----------------------|-------------------| |----------------------------|-------------------------------------------------------------------------------|-----------------------|-------------------|
| `BLOG_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_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 #### 5 bis.9.2 Modèle Pydantic pour la configuration du blog
@@ -1230,10 +1235,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class BlogSettings(BaseSettings): class BlogSettings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="BLOG_", env_file=".env", extra="ignore") model_config = SettingsConfigDict(env_prefix="BLOG_", env_file=".env", extra="ignore")
enabled: bool = Field(False, description="Activer la récupération du blog") enabled: bool = Field(False, description="Activer la récupération du blog")
rss_url: str = Field( endpoint: ExternalEndpoint
"https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2",
description="URL du flux RSS du blog",
)
``` ```
**Intégration dans `Settings`** : **Intégration dans `Settings`** :
@@ -1253,7 +1255,7 @@ class Settings(BaseSettings):
```ini ```ini
# --- Blog du collège --- # --- Blog du collège ---
BLOG_ENABLED=true 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
``` ```
--- ---
@@ -2705,7 +2707,7 @@ Utilisation de la bibliothèque [`caldav`](https://pypi.org/project/caldav/) (Py
#### API réelle (`caldav>=1.3.0`) #### API réelle (`caldav>=1.3.0`)
- **Connexion** : `caldav.DAVClient(url, username, password)` — les paramètres proviennent - **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()` ; - **Résolution du calendrier** : `DAVClient.principal()` puis `principal.calendars()` ;
sélectionner le calendrier dont l'URL correspond à **`CalDAVSettings.calendar_path`** 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 : (ex: `/pronote-sync/`). La résolution ne se fait **pas** par nom de calendrier :
@@ -4610,7 +4612,7 @@ def get_channel(settings: XmppSettings, dry_run: bool = False) -> Channel | None
- **XMPP** : Si l'envoi échoue → **logger l'erreur** mais continuer le pipeline. - **XMPP** : Si l'envoi échoue → **logger l'erreur** mais continuer le pipeline.
- **Erreurs critiques** : - **Erreurs critiques** :
- **Aucune source disponible** (iCal + pronotepy échouent) → **échec explicite** avec message clair. - **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 ### 11.2 Hiérarchie des erreurs
@@ -5341,12 +5343,14 @@ def mock_xmpp_channel():
def sample_settings(): def sample_settings():
"""Retourne une configuration de test.""" """Retourne une configuration de test."""
from pydantic import SecretStr 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( return Settings(
pronote=PronoteSettings( pronote=PronoteSettings(
url="https://test.ent/pronote/parent.html", endpoint=ExternalEndpoint(url=SecretStr("https://test.ent/pronote/parent.html")),
ical_url=SecretStr("https://test.ent/pronote/ical/test.ics"), ical_endpoint=ExternalEndpoint(url=SecretStr("https://test.ent/pronote/ical/test.ics")),
username="test_user", username="test_user",
password=SecretStr("test_password"), password=SecretStr("test_password"),
ent="monbureaunumerique", ent="monbureaunumerique",
@@ -5434,7 +5438,7 @@ def test_pipeline_full(mock_requests_get, mock_caldav_client, mock_ai_provider,
# Configurer le client CalDAV # Configurer le client CalDAV
caldav_client = CalDAVClient( 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, username=sample_settings.caldav.username,
password=sample_settings.caldav.password, password=sample_settings.caldav.password,
allow_insecure_http=sample_settings.caldav.allow_insecure_http, allow_insecure_http=sample_settings.caldav.allow_insecure_http,
@@ -5629,7 +5633,7 @@ TOTAL 1000 10 99%
| **Risque** | **Mesure de mitigation** | **Vérification** | **Statut** | | **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 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 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 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 | | Tokens dans les commits Git | Utiliser `.gitignore` pour `.env` et `pre-commit` pour bloquer les secrets. | `git grep "icalsecurise\|password" -- .` (contenu suivi courant) | ❌ Interdit |
@@ -5948,10 +5952,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 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 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 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`. | | É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)). | | 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`. | | Synchronisation CalDAV lente | Trop d'événements à synchroniser. | Réduire `SYNC_PAST_DAYS` ou `SYNC_FUTURE_DAYS`. |
+19 -2
View File
@@ -12,8 +12,8 @@ Synchronise l'agenda et les devoirs de **Pronote** vers un calendrier **CalDAV**
```bash ```bash
# Cloner le dépôt # Cloner le dépôt
git clone <repo-url> git clone https://git.antoineve.me/AntoineVe/college-infos
cd pronote-sync cd college-infos
# Créer l'environnement virtuel # Créer l'environnement virtuel
python3.13 -m venv .venv python3.13 -m venv .venv
@@ -48,6 +48,23 @@ pas garantir un état persistant cohérent pendant une simulation.
--- ---
## Validation et CI
Aucun workflow Gitea Actions n'est livré actuellement. Les validations du projet sont donc
exécutées localement avec les commandes suivantes :
```bash
pytest
ruff check .
mypy .
bandit -r pronote_sync/
```
`pre-commit run --all-files` regroupe également les contrôles de formatage, typage, sécurité et
détection de secrets.
---
## 🛠️ Déploiement ## 🛠️ Déploiement
Les artefacts pour **systemd/timer** et **logrotate** sont fournis dans `deploy/`. Voir [docs/exploitation.md](docs/exploitation.md) pour plus de détails. Les artefacts pour **systemd/timer** et **logrotate** sont fournis dans `deploy/`. Voir [docs/exploitation.md](docs/exploitation.md) pour plus de détails.
+1 -1
View File
@@ -308,5 +308,5 @@ Rédiger la documentation utilisateur et finaliser le projet.
### Critères d'acceptation ### Critères d'acceptation
- `README.md` permet d'installer et de lancer le projet sans le guide. - `README.md` permet d'installer et de lancer le projet sans le guide.
- Gitea Actions exécute tests + lint + sécurité. - Les procédures locales de test, lint et sécurité sont documentées et exécutables.
- Aucun secret dans la documentation. - Aucun secret dans la documentation.
+4 -4
View File
@@ -94,7 +94,7 @@ 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é). 🔹 **Source** : ⚠️ Recommandation du projet (inspirée des bonnes pratiques générales de sécurité).
### Intégration dans `pronote-sync` ### 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).
- **Comportement** : - **Comportement** :
- Prioritaire en mode `PRONOTE_AGENDA_SOURCE=auto`. - Prioritaire en mode `PRONOTE_AGENDA_SOURCE=auto`.
- Si l'URL est invalide ou expire, repli automatique vers `pronotepy` (si `PRONOTE_AGENDA_SOURCE=auto`). - Si l'URL est invalide ou expire, repli automatique vers `pronotepy` (si `PRONOTE_AGENDA_SOURCE=auto`).
@@ -126,7 +126,7 @@ Connexion via le protocole propriétaire de Pronote (JSON sur HTTPS), avec **chi
### Intégration dans `pronote-sync` ### Intégration dans `pronote-sync`
- **Paramètres** : - **Paramètres** :
- `PRONOTE_URL` (ex. `.env.example` ligne 3). - `PRONOTE_ENDPOINT__URL` (ex. `.env.example` ligne 3).
- `PRONOTE_USERNAME`, `PRONOTE_PASSWORD`. - `PRONOTE_USERNAME`, `PRONOTE_PASSWORD`.
- `PRONOTE_ENT` (slug dans `_ENT_NAMES`). - `PRONOTE_ENT` (slug dans `_ENT_NAMES`).
- `PRONOTE_ACCOUNT_TYPE` (ex. `parent`). - `PRONOTE_ACCOUNT_TYPE` (ex. `parent`).
@@ -288,8 +288,8 @@ Mécanisme d'appairage par QR code pour les appareils mobiles, **contournant l'a
### Alignement avec `.env.example` ### Alignement avec `.env.example`
| Paramètre | Document | Code | Statut | | Paramètre | Document | Code | Statut |
|-----------|----------|------|--------| |-----------|----------|------|--------|
| `PRONOTE_ICAL_URL` | ✅ Lignes 2, 4250 | ✅ `sources/ical.py` | **Cohérent** | | `PRONOTE_ICAL_ENDPOINT__URL` | ✅ Lignes 2, 4250 | ✅ `sources/ical.py` | **Cohérent** |
| `PRONOTE_URL` | ✅ Ligne 3 | ✅ `client.py` (ligne 294) | **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_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_AUTH_MODE=qr_token` | ✅ Ligne 23 | ✅ `client.py` (ligne 334) | **Cohérent** |
| `PRONOTE_QR_CODE_FILE` | ✅ Ligne 24 | ✅ `client.py` (ligne 387) | **Cohérent** | | `PRONOTE_QR_CODE_FILE` | ✅ Ligne 24 | ✅ `client.py` (ligne 387) | **Cohérent** |
+231 -54
View File
@@ -10,21 +10,76 @@ from __future__ import annotations
import warnings import warnings
from datetime import date from datetime import date
from typing import Literal from typing import Annotated, Literal
from urllib.parse import urlparse from urllib.parse import urlparse
from pydantic import ( from pydantic import (
AfterValidator,
BaseModel,
ConfigDict,
Field, Field,
SecretStr, SecretStr,
ValidationInfo,
field_serializer, field_serializer,
field_validator,
model_validator, model_validator,
) )
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
from pronote_sync.utils.redaction import redact_url from pronote_sync.utils.redaction import redact_url
_EXTERNAL_ENDPOINT_SCHEMES: frozenset[str] = frozenset({"file", "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.
: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.
"""
is_valid = False
try:
parsed = urlparse(value.get_secret_value())
_ = 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))
)
except ValueError:
pass
if not is_valid:
raise ValueError("Endpoint externe invalide : URL ou schéma non supporté") from None
return value
EndpointUrl = Annotated[SecretStr, AfterValidator(_validate_external_endpoint_url)]
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.
"""
model_config = ConfigDict(extra="forbid", frozen=True, hide_input_in_errors=True)
url: EndpointUrl
@field_serializer("url")
def _serialize_url(self, value: SecretStr) -> str:
"""Expurge l'URL lors de la sérialisation.
:param value: URL encapsulée à sérialiser.
:return: URL expurgée.
:rtype: str
"""
return redact_url(value.get_secret_value())
class PronoteSettings(BaseSettings): class PronoteSettings(BaseSettings):
"""Paramètres d'accès à Pronote (flux iCal et API ``pronotepy``). """Paramètres d'accès à Pronote (flux iCal et API ``pronotepy``).
@@ -33,13 +88,28 @@ class PronoteSettings(BaseSettings):
``PRONOTE_``. ``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_",
)
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 username: str | None = None
password: SecretStr | None = None password: SecretStr | None = None
ent: str | 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" account_type: Literal["student", "parent"] = "parent"
agenda_source: Literal["auto", "ical", "pronotepy"] = "auto" agenda_source: Literal["auto", "ical", "pronotepy"] = "auto"
homework_source: Literal["auto", "ical", "pronotepy"] = "auto" homework_source: Literal["auto", "ical", "pronotepy"] = "auto"
@@ -49,17 +119,57 @@ class PronoteSettings(BaseSettings):
qr_pin: SecretStr | None = None qr_pin: SecretStr | None = None
account_pin: SecretStr | None = None account_pin: SecretStr | None = None
@field_serializer("ical_url") @model_validator(mode="before")
def _serialize_ical_url(self, value: SecretStr | None) -> str | None: @classmethod
"""Masque l'URL iCal lors de la sérialisation (repr, str, JSON). def _migrate_legacy_endpoints(cls, data: object) -> object:
"""Migre les URL Pronote historiques vers les endpoints communs.
:param value: Valeur du champ ``ical_url``. :param data: Données brutes du modèle.
:return: ``"**********"`` si la valeur est définie, ``None`` sinon. :return: Données complétées avec les endpoints si nécessaire.
:rtype: str | None :rtype: object
""" """
if value is None: if not isinstance(data, dict):
return None return data
return "**********" 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 utilise HTTPS. Le flux iCal accepte également ``file``
afin de préserver les fixtures locales injectées.
:return: Instance validée inchangée.
:rtype: PronoteSettings
:raises ValueError: Si un endpoint utilise un schéma interdit.
"""
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 not in {"https", "file"}:
raise ValueError("URL iCal Pronote invalide : HTTPS ou file requis") from None
return self
@field_serializer("qr_pin") @field_serializer("qr_pin")
def _serialize_qr_pin(self, value: SecretStr | None) -> str | None: def _serialize_qr_pin(self, value: SecretStr | None) -> str | None:
@@ -91,73 +201,88 @@ class CalDAVSettings(BaseSettings):
Les variables d'environnement correspondantes sont préfixées par Les variables d'environnement correspondantes sont préfixées par
``CALDAV_``. L'URL est traitée comme potentiellement sensible (au même ``CALDAV_``. L'URL est traitée comme potentiellement sensible (au même
titre que ``PRONOTE_ICAL_URL``) : elle est de type ``SecretStr`` et titre que ``PRONOTE_ICAL_ENDPOINT__URL``) : elle est de type ``SecretStr`` et
masquée lors de la sérialisation. Par défaut, seul HTTPS est accepté ; masquée lors de la sérialisation. Par défaut, seul HTTPS est accepté ;
HTTP n'est toléré que pour un hôte de boucle locale (``localhost``, HTTP n'est toléré que pour un hôte de boucle locale (``localhost``,
``127.0.0.1``, ``::1``) lorsque ``allow_insecure_http`` vaut ``True``. ``127.0.0.1``, ``::1``) lorsque ``allow_insecure_http`` vaut ``True``.
Le nouvel endpoint se configure avec ``CALDAV_ENDPOINT__URL`` ;
``CALDAV_URL`` reste temporairement pris en charge avec un avertissement
de dépréciation.
""" """
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="CALDAV_") model_config = SettingsConfigDict(
env_file=".env",
env_nested_delimiter="__",
extra="ignore",
env_prefix="CALDAV_",
)
allow_insecure_http: bool = False allow_insecure_http: bool = False
url: SecretStr | None = None endpoint: ExternalEndpoint | None = None
url: SecretStr | None = Field(
default=None,
exclude=True,
deprecated="Utiliser endpoint.url à la place (CALDAV_URL obsolète).",
)
username: str | None = None username: str | None = None
password: SecretStr | None = None password: SecretStr | None = None
calendar_path: str = "/pronote-sync/" calendar_path: str = "/pronote-sync/"
@field_serializer("url") @model_validator(mode="before")
def _serialize_url(self, value: SecretStr | None) -> str | None:
"""Masque l'URL CalDAV lors de la sérialisation (repr, str, JSON).
:param value: Valeur du champ ``url`` (secret potentiel).
:return: URL avec les éléments sensibles remplacés par ``REDACTED``,
ou ``None`` si la valeur est absente.
:rtype: str | None
"""
if value is None:
return None
return redact_url(value.get_secret_value())
@field_validator("url")
@classmethod @classmethod
def _validate_url_https(cls, v: SecretStr | None, info: ValidationInfo) -> SecretStr | None: def _migrate_legacy_url(cls, data: object) -> object:
"""Valide le schéma de l'URL CalDAV (HTTPS obligatoire par défaut). """Migre ``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("url") is None:
return data
migrated_data = data.copy()
warnings.warn(
"CALDAV_URL est obsolète : utiliser CALDAV_ENDPOINT__URL.",
DeprecationWarning,
stacklevel=2,
)
if migrated_data.get("endpoint") is None:
migrated_data["endpoint"] = {"url": migrated_data["url"]}
return migrated_data
@model_validator(mode="after")
def _validate_endpoint_policy(self) -> CalDAVSettings:
"""Applique la politique HTTPS/HTTP loopback propre à CalDAV.
HTTPS est toujours accepté. HTTP n'est accepté que pour un hôte de HTTPS est toujours accepté. HTTP n'est accepté que pour un hôte de
boucle locale (``localhost``, ``127.0.0.1``, ``::1``) et uniquement boucle locale (``localhost``, ``127.0.0.1``, ``::1``) et uniquement
lorsque ``allow_insecure_http`` vaut ``True``. Les messages d'erreur lorsque ``allow_insecure_http`` vaut ``True``. Les autres schémas du
ne contiennent jamais l'URL brute (susceptible de contenir des socle commun sont refusés pour ce connecteur.
identifiants).
:param v: Valeur du champ ``url`` à valider. :return: Instance validée inchangée.
:param info: Contexte de validation (accès aux autres champs). :rtype: CalDAVSettings
:return: La valeur validée inchangée.
:rtype: SecretStr | None
:raises ValueError: Si le schéma n'est pas supporté ou si l'URL HTTP :raises ValueError: Si le schéma n'est pas supporté ou si l'URL HTTP
n'est pas autorisée. n'est pas autorisée.
""" """
if v is None: if self.endpoint is None:
return v return self
raw_url = v.get_secret_value() raw_url = self.endpoint.url.get_secret_value()
parsed = urlparse(raw_url) parsed = urlparse(raw_url)
if parsed.scheme not in ("http", "https"): if parsed.scheme not in ("http", "https"):
raise ValueError("URL CalDAV invalide : schéma non supporté") from None raise ValueError("URL CalDAV invalide : schéma non supporté") from None
if parsed.scheme == "https": if parsed.scheme == "https":
return v return self
# HTTP — check allow_insecure_http flag and loopback # HTTP — check allow_insecure_http flag and loopback
allow_insecure = info.data.get("allow_insecure_http", False) if not self.allow_insecure_http:
if not allow_insecure:
raise ValueError( raise ValueError(
"URL CalDAV non sécurisée : HTTPS requis (ou activer " "URL CalDAV non sécurisée : HTTPS requis (ou activer "
"CALDAV_ALLOW_INSECURE_HTTP pour localhost)" "CALDAV_ALLOW_INSECURE_HTTP pour localhost)"
) from None ) from None
hostname = parsed.hostname or "" hostname = parsed.hostname or ""
loopback_hosts = {"localhost", "127.0.0.1", "::1"} if hostname not in _LOOPBACK_HOSTS:
if hostname not in loopback_hosts:
raise ValueError( raise ValueError(
"URL CalDAV non sécurisée : HTTP autorisé uniquement pour localhost" "URL CalDAV non sécurisée : HTTP autorisé uniquement pour localhost"
) from None ) from None
return v return self
_XMPP_LOOPBACK_HOSTS: frozenset[str] = frozenset({"localhost", "127.0.0.1", "::1"}) _XMPP_LOOPBACK_HOSTS: frozenset[str] = frozenset({"localhost", "127.0.0.1", "::1"})
@@ -282,10 +407,60 @@ class BlogSettings(BaseSettings):
``BLOG_``. ``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_",
)
enabled: bool = False 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 de production.
Le transport ``file`` reste autorisé pour les fixtures locales.
:return: Instance validée inchangée.
:rtype: BlogSettings
:raises ValueError: Si le schéma n'est ni ``https`` ni ``file``.
"""
scheme = urlparse(self.endpoint.url.get_secret_value()).scheme
if scheme not in {"https", "file"}:
raise ValueError("URL RSS invalide : HTTPS ou file requis") from None
return self
class AppSettings(BaseSettings): class AppSettings(BaseSettings):
@@ -338,13 +513,15 @@ class Settings(BaseSettings):
:rtype: tuple[SecretStr, ...] :rtype: tuple[SecretStr, ...]
""" """
secrets = [ 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.password,
self.pronote.qr_pin, self.pronote.qr_pin,
self.pronote.account_pin, self.pronote.account_pin,
self.caldav.url, self.caldav.endpoint.url if self.caldav.endpoint is not None else None,
self.caldav.password, self.caldav.password,
self.xmpp.password, self.xmpp.password,
self.ai.api_key, self.ai.api_key,
self.blog.endpoint.url,
] ]
return tuple(dict.fromkeys(secret for secret in secrets if secret is not None)) 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 = ( comparator = (
AgendaComparator(theoretical_provider) if theoretical_provider is not None else None AgendaComparator(theoretical_provider) if theoretical_provider is not None else None
) )
blog_client = BlogRSSClient(settings.blog.rss_url) if settings.blog.enabled else None blog_client = (
BlogRSSClient(settings.blog.endpoint.url.get_secret_value())
if settings.blog.enabled
else None
)
blog_state = ( blog_state = (
BlogRSSState(persistence_enabled=persistence_enabled) if settings.blog.enabled else None BlogRSSState(persistence_enabled=persistence_enabled) if settings.blog.enabled else None
) )
+26 -9
View File
@@ -97,7 +97,10 @@ class BlogRSSState:
redact_exception(exc), redact_exception(exc),
) )
def _save(self) -> None: def _save(
self,
state: tuple[set[str], str | None, str | None] | None = None,
) -> bool:
"""Sauvegarde l'état dans le fichier JSON de manière atomique. """Sauvegarde l'état dans le fichier JSON de manière atomique.
La sortie est déterministe : ``known_guids`` est trié La sortie est déterministe : ``known_guids`` est trié
@@ -107,20 +110,30 @@ class BlogRSSState:
jamais laisser un fichier partiel en cas d'interruption. En cas jamais laisser un fichier partiel en cas d'interruption. En cas
d'erreur d'écriture, une erreur est journalisée sans être d'erreur d'écriture, une erreur est journalisée sans être
propagée et le fichier temporaire est supprimé. propagée et le fichier temporaire est supprimé.
:param state: État à sauvegarder ; l'état courant est utilisé par défaut.
:return: ``True`` si l'état a été sauvegardé ou si la persistance est désactivée.
:rtype: bool
""" """
if not self._persistence_enabled: if not self._persistence_enabled:
return return True
known_guids, etag, last_modified = state or (
self._known_guids,
self._etag,
self._last_modified,
)
payload = { payload = {
"version": _STATE_VERSION, "version": _STATE_VERSION,
"known_guids": sorted(self._known_guids), "known_guids": sorted(known_guids),
"etag": self._etag, "etag": etag,
"last_modified": self._last_modified, "last_modified": last_modified,
} }
tmp_file = self._state_file.with_suffix(".tmp") tmp_file = self._state_file.with_suffix(".tmp")
try: try:
with open(tmp_file, "w", encoding="utf-8") as handle: with open(tmp_file, "w", encoding="utf-8") as handle:
json.dump(payload, handle, indent=2) json.dump(payload, handle, indent=2)
tmp_file.replace(self._state_file) tmp_file.replace(self._state_file)
return True
except Exception as exc: except Exception as exc:
logger.error( logger.error(
"Impossible d'écrire le fichier d'état blog RSS %s : %s.", "Impossible d'écrire le fichier d'état blog RSS %s : %s.",
@@ -134,6 +147,7 @@ class BlogRSSState:
"Nettoyage du fichier temporaire échoué : %s", "Nettoyage du fichier temporaire échoué : %s",
redact_exception(cleanup_exc), redact_exception(cleanup_exc),
) )
return False
def get_known_guids(self) -> frozenset[str]: def get_known_guids(self) -> frozenset[str]:
"""Renvoie une copie immuable des GUID d'articles déjà connus. """Renvoie une copie immuable des GUID d'articles déjà connus.
@@ -168,10 +182,13 @@ class BlogRSSState:
""" """
if result.not_modified: if result.not_modified:
return return
self._known_guids.update(article.id for article in result.articles) new_state = (
self._etag = result.etag self._known_guids | {article.id for article in result.articles},
self._last_modified = result.last_modified result.etag,
self._save() result.last_modified,
)
if self._save(new_state):
self._known_guids, self._etag, self._last_modified = new_state
def get_cache_headers(self) -> tuple[str | None, str | None]: def get_cache_headers(self) -> tuple[str | None, str | None]:
"""Renvoie les en-têtes de cache HTTP mémorisés. """Renvoie les en-têtes de cache HTTP mémorisés.
+3 -3
View File
@@ -294,18 +294,18 @@ class PronoteClient:
est manquant, ou si l'ENT fourni est inconnu. est manquant, ou si l'ENT fourni est inconnu.
:raises pronotepy.PronoteAPIError: Si la connexion à Pronote échoue. :raises pronotepy.PronoteAPIError: Si la connexion à Pronote échoue.
""" """
url = self._settings.url endpoint = self._settings.endpoint
username = self._settings.username username = self._settings.username
password = self._settings.password password = self._settings.password
ent = self._settings.ent 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") raise ValueError("url, username et password sont requis pour pronotepy")
resolver = _resolve_ent(ent) if ent is not None else None resolver = _resolve_ent(ent) if ent is not None else None
client_class: type[pronotepy.Client] = ( client_class: type[pronotepy.Client] = (
pronotepy.ParentClient if self._settings.account_type == "parent" else pronotepy.Client pronotepy.ParentClient if self._settings.account_type == "parent" else pronotepy.Client
) )
self._client = client_class( self._client = client_class(
pronote_url=url, pronote_url=endpoint.url.get_secret_value(),
username=username, username=username,
password=password.get_secret_value(), password=password.get_secret_value(),
ent=resolver, ent=resolver,
+13 -13
View File
@@ -141,10 +141,10 @@ class PronoteFetcher:
def _is_ical_configured(self) -> bool: def _is_ical_configured(self) -> bool:
"""Vérifie que la source iCal est configurée. """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 :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: def _is_pronotepy_configured(self) -> bool:
"""Vérifie si la source pronotepy est utilisable selon le mode d'authentification. """Vérifie si la source pronotepy est utilisable selon le mode d'authentification.
@@ -155,13 +155,13 @@ class PronoteFetcher:
""" """
pronote = self._settings.pronote pronote = self._settings.pronote
if pronote.auth_mode == "qr_token": 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. # Le QR code et le PIN ne sont nécessaires que pour l'enrôlement initial.
# Les exécutions suivantes utilisent le token persisté. # 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. # En mode password, URL + identifiant + mot de passe sont requis.
return ( return (
pronote.url is not None pronote.endpoint is not None
and pronote.username is not None and pronote.username is not None
and pronote.password is not None and pronote.password is not None
) )
@@ -171,16 +171,16 @@ class PronoteFetcher:
:return: Tuple ``(cours, événements scolaires)``. :return: Tuple ``(cours, événements scolaires)``.
:rtype: tuple[list[Lesson], list[SchoolEvent]] :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 OSError: Si le fichier iCal local est illisible.
:raises requests.RequestException: Si la récupération HTTP échoue. :raises requests.RequestException: Si la récupération HTTP échoue.
""" """
if self._cache_ical_for_run and self._run_ical_agenda is not None: if self._cache_ical_for_run and self._run_ical_agenda is not None:
return self._run_ical_agenda return self._run_ical_agenda
ical_url = self._settings.pronote.ical_url ical_endpoint = self._settings.pronote.ical_endpoint
if ical_url is None: if ical_endpoint is None:
raise ValueError("PRONOTE_ICAL_URL est requis pour la source iCal") raise ValueError("PRONOTE_ICAL_ENDPOINT__URL est requis pour la source iCal")
raw_ical = fetch_ical(ical_url.get_secret_value()) raw_ical = fetch_ical(ical_endpoint.url.get_secret_value())
lessons, _, school_events = parse_ical(raw_ical) lessons, _, school_events = parse_ical(raw_ical)
result = (lessons, school_events) result = (lessons, school_events)
if self._cache_ical_for_run: if self._cache_ical_for_run:
@@ -210,7 +210,7 @@ class PronoteFetcher:
Les modes explicites ``ICAL`` et ``PRONOTEPY`` désignent la seule Les modes explicites ``ICAL`` et ``PRONOTEPY`` désignent la seule
source utilisée, sans aucun repli. En mode ``AUTO``, iCal est 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. configuration pronotepy est complète), sinon pronotepy sans repli.
:return: Tuple ``(source primaire, source de repli ou ``None``)``. :return: Tuple ``(source primaire, source de repli ou ``None``)``.
@@ -304,7 +304,7 @@ class PronoteFetcher:
:param target_date: Date cible pour laquelle collecter les devoirs. :param target_date: Date cible pour laquelle collecter les devoirs.
:return: Liste des devoirs. :return: Liste des devoirs.
:rtype: list[Homework] :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 OSError: Si le fichier iCal local est illisible.
:raises requests.RequestException: Si la récupération HTTP échoue. :raises requests.RequestException: Si la récupération HTTP échoue.
""" """
@@ -335,7 +335,7 @@ class PronoteFetcher:
Les modes explicites ``ICAL`` et ``PRONOTEPY`` désignent la seule Les modes explicites ``ICAL`` et ``PRONOTEPY`` désignent la seule
source utilisée, sans aucun repli. En mode ``AUTO``, iCal est 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. configuration pronotepy est complète), sinon pronotepy sans repli.
:return: Tuple ``(source primaire, source de repli ou ``None``)``. :return: Tuple ``(source primaire, source de repli ou ``None``)``.
+3 -2
View File
@@ -79,11 +79,12 @@ class CalDAVGateway:
else cast(Callable[..., Any], caldav.DAVClient) else cast(Callable[..., Any], caldav.DAVClient)
) )
self._calendar_path: str = settings.calendar_path self._calendar_path: str = settings.calendar_path
url_secret = settings.endpoint.url if settings.endpoint is not None else None
self._redacted_url: str | None = ( self._redacted_url: str | None = (
redact_url(settings.url.get_secret_value()) if settings.url else None redact_url(url_secret.get_secret_value()) if url_secret else None
) )
self._username: str | None = settings.username self._username: str | None = settings.username
self._url_secret: SecretStr | None = settings.url self._url_secret: SecretStr | None = url_secret
self._password_secret: SecretStr | None = settings.password self._password_secret: SecretStr | None = settings.password
self._client: Any = None self._client: Any = None
self._calendar: Any = None self._calendar: Any = None
+1 -1
View File
@@ -96,7 +96,7 @@ def synchronize(
) )
if ( if (
settings.caldav.url is None settings.caldav.endpoint is None
or settings.caldav.username is None or settings.caldav.username is None
or settings.caldav.password is None or settings.caldav.password is None
): ):
+6 -5
View File
@@ -7,9 +7,10 @@ name = "pronote-sync"
version = "0.1.2" version = "0.1.2"
description = "Synchronisation Pronote → CalDAV + XMPP" description = "Synchronisation Pronote → CalDAV + XMPP"
license = {text = "MIT"} license = {text = "MIT"}
readme = "README.md"
requires-python = ">=3.13.5" requires-python = ">=3.13.5"
authors = [ authors = [
{name = "Votre Nom", email = "votre@email.com"} {name = "Antoine Van Elstraete", email = "antoine@van-elstraete.net"}
] ]
keywords = ["pronote", "caldav", "xmpp", "sync", "school"] keywords = ["pronote", "caldav", "xmpp", "sync", "school"]
classifiers = [ classifiers = [
@@ -57,10 +58,10 @@ dev = [
pronote-sync = "pronote_sync.cli.main:main" pronote-sync = "pronote_sync.cli.main:main"
[project.urls] [project.urls]
Homepage = "https://github.com/votre-utilisateur/pronote-sync" Homepage = "https://git.antoineve.me/AntoineVe/college-infos"
Documentation = "https://github.com/votre-utilisateur/pronote-sync#readme" Documentation = "https://git.antoineve.me/AntoineVe/college-infos/wiki"
Repository = "https://github.com/votre-utilisateur/pronote-sync" Repository = "https://git.antoineve.me/AntoineVe/college-infos"
Issues = "https://github.com/votre-utilisateur/pronote-sync/issues" Issues = "https://git.antoineve.me/AntoineVe/college-infos/issues"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["."] where = ["."]
+37
View File
@@ -425,4 +425,41 @@ def test_atomic_save_preserves_on_error(tmp_path: Path) -> None:
assert state.get_known_guids() == frozenset({"original-guid-1", "original-guid-2", "new-guid"}) assert state.get_known_guids() == frozenset({"original-guid-1", "original-guid-2", "new-guid"})
def test_acknowledge_does_not_advance_memory_when_save_fails(
tmp_path: Path,
) -> None:
"""Conserve l'état précédent en mémoire si l'acquittement ne peut pas être sauvegardé.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "state.json"
state = BlogRSSState(state_file)
state.add_guids(["existing-guid"])
state.update_cache_headers("old-etag", "old-last-modified")
article = BlogArticle(
id="new-guid",
title="Article",
url="https://example.com/article",
published_at=datetime(2026, 9, 12, 8, 0, tzinfo=UTC),
updated_at=None,
category=None,
author=None,
content_html="<p>Contenu</p>",
content_text="Contenu",
)
with patch.object(Path, "replace", side_effect=OSError("replace failed")):
state.acknowledge(
BlogRSSFetchResult(
articles=(article,),
etag="new-etag",
last_modified="new-last-modified",
)
)
assert state.get_known_guids() == frozenset({"existing-guid"})
assert state.get_cache_headers() == ("old-etag", "old-last-modified")
# Ensure trailing newline # Ensure trailing newline
+153 -3
View File
@@ -13,7 +13,14 @@ import pytest
from pydantic import SecretStr, ValidationError from pydantic import SecretStr, ValidationError
from pronote_sync.config.env import load_settings 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: if TYPE_CHECKING:
from _pytest.monkeypatch import MonkeyPatch from _pytest.monkeypatch import MonkeyPatch
@@ -73,7 +80,7 @@ def test_secretstr_masking_ical_url() -> None:
# Vérification de la sérialisation JSON # Vérification de la sérialisation JSON
json_str = settings.model_dump_json() json_str = settings.model_dump_json()
assert "SECRET_TOKEN" not in json_str assert "SECRET_TOKEN" not in json_str
assert "**********" in json_str assert "REDACTED" in json_str
def test_secretstr_masking_password() -> None: def test_secretstr_masking_password() -> None:
@@ -128,7 +135,150 @@ def test_url_from_pronote_url_env_var(monkeypatch: MonkeyPatch) -> None:
test_url = "https://example.index-education.net/pronote/parent.html" test_url = "https://example.index-education.net/pronote/parent.html"
monkeypatch.setenv("PRONOTE_URL", test_url) monkeypatch.setenv("PRONOTE_URL", test_url)
settings = load_settings() 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",
"http://localhost:8080/test",
"file:///tmp/fixture.ics",
],
)
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
"""
endpoint = ExternalEndpoint(url=SecretStr("https://user:secret@example.test/calendar"))
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()
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"
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", "file:///tmp/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() == "file:///tmp/pronote.ics"
@pytest.mark.parametrize(
("field", "url", "message"),
[
("endpoint", "http://pronote.example.test", "HTTPS requis"),
("ical_endpoint", "http://pronote.example.test/calendar", "HTTPS ou file requis"),
],
)
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: def test_auth_mode_default_password() -> None:
+17 -15
View File
@@ -251,7 +251,7 @@ def test_fetch_agenda_auto_both_fail(mock_fetcher: PronoteFetcher) -> None:
:rtype: None :rtype: None
""" """
# Disable pronotepy so fallback is None # Disable pronotepy so fallback is None
mock_fetcher._settings.pronote.url = None mock_fetcher._settings.pronote.endpoint = None
with ( with (
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical, patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
@@ -280,7 +280,7 @@ def test_fetch_agenda_ical_mode_failure(mock_fetcher: PronoteFetcher) -> None:
""" """
# Override settings to use ical mode explicitly and disable fallback # Override settings to use ical mode explicitly and disable fallback
mock_fetcher._settings.pronote.agenda_source = "ical" mock_fetcher._settings.pronote.agenda_source = "ical"
mock_fetcher._settings.pronote.url = None mock_fetcher._settings.pronote.endpoint = None
with ( with (
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical, patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
@@ -311,7 +311,7 @@ def test_fetch_agenda_pronotepy_mode_failure(mock_fetcher: PronoteFetcher) -> No
""" """
# Override settings to use pronotepy mode explicitly and disable fallback # Override settings to use pronotepy mode explicitly and disable fallback
mock_fetcher._settings.pronote.agenda_source = "pronotepy" mock_fetcher._settings.pronote.agenda_source = "pronotepy"
mock_fetcher._settings.pronote.ical_url = None mock_fetcher._settings.pronote.ical_endpoint = None
client = MagicMock() client = MagicMock()
client.get_lessons.side_effect = OSError("Pronote API error") client.get_lessons.side_effect = OSError("Pronote API error")
@@ -438,7 +438,7 @@ def test_fetch_homework_auto_both_fail(mock_fetcher: PronoteFetcher) -> None:
target_date = date(2025, 9, 10) target_date = date(2025, 9, 10)
# Disable pronotepy so fallback is None # Disable pronotepy so fallback is None
mock_fetcher._settings.pronote.url = None mock_fetcher._settings.pronote.endpoint = None
with ( with (
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical, patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
@@ -531,7 +531,7 @@ def test_no_secrets_in_error_messages(
:rtype: None :rtype: None
""" """
# Disable pronotepy so fallback is None to trigger PipelineCriticalError # Disable pronotepy so fallback is None to trigger PipelineCriticalError
mock_fetcher._settings.pronote.url = None mock_fetcher._settings.pronote.endpoint = None
with ( with (
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical, patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
@@ -628,8 +628,8 @@ def test_fetch_agenda_no_source_configured_raises(mock_fetcher: PronoteFetcher)
:rtype: None :rtype: None
""" """
# Disable both sources # Disable both sources
mock_fetcher._settings.pronote.ical_url = None mock_fetcher._settings.pronote.ical_endpoint = None
mock_fetcher._settings.pronote.url = None mock_fetcher._settings.pronote.endpoint = None
with pytest.raises(PipelineCriticalError) as exc_info: with pytest.raises(PipelineCriticalError) as exc_info:
mock_fetcher.fetch_agenda() mock_fetcher.fetch_agenda()
@@ -912,8 +912,10 @@ def test_fetch_agenda_ical_url_none_raises_value_error(mock_fetcher: PronoteFetc
:return: None :return: None
:rtype: None :rtype: None
""" """
mock_fetcher._settings.pronote.ical_url = None mock_fetcher._settings.pronote.ical_endpoint = None
with pytest.raises(ValueError, match="PRONOTE_ICAL_URL est requis pour la source iCal"): with pytest.raises(
ValueError, match="PRONOTE_ICAL_ENDPOINT__URL est requis pour la source iCal"
):
mock_fetcher._fetch_agenda_ical() mock_fetcher._fetch_agenda_ical()
@@ -928,7 +930,7 @@ def test_agenda_sources_auto_only_pronotepy_configured(mock_fetcher: PronoteFetc
:rtype: None :rtype: None
""" """
mock_fetcher._settings.pronote.agenda_source = "auto" 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() primary, fallback = mock_fetcher._agenda_sources()
assert primary == "pronotepy" assert primary == "pronotepy"
assert fallback is None assert fallback is None
@@ -1020,7 +1022,7 @@ def test_homework_sources_explicit_ical_mode_strict(mock_fetcher: PronoteFetcher
assert fallback is None assert fallback is None
# Without pronotepy configured # Without pronotepy configured
mock_fetcher._settings.pronote.url = None mock_fetcher._settings.pronote.endpoint = None
primary, fallback = mock_fetcher._homework_sources() primary, fallback = mock_fetcher._homework_sources()
assert primary == "ical" assert primary == "ical"
assert fallback is None assert fallback is None
@@ -1046,7 +1048,7 @@ def test_homework_sources_explicit_pronotepy_mode_strict(
assert fallback is None assert fallback is None
# Without ical configured # Without ical configured
mock_fetcher._settings.pronote.ical_url = None mock_fetcher._settings.pronote.ical_endpoint = None
primary, fallback = mock_fetcher._homework_sources() primary, fallback = mock_fetcher._homework_sources()
assert primary == "pronotepy" assert primary == "pronotepy"
assert fallback is None assert fallback is None
@@ -1063,7 +1065,7 @@ def test_homework_sources_auto_only_pronotepy_configured(mock_fetcher: PronoteFe
:rtype: None :rtype: None
""" """
mock_fetcher._settings.pronote.homework_source = "auto" 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() primary, fallback = mock_fetcher._homework_sources()
assert primary == "pronotepy" assert primary == "pronotepy"
assert fallback is None assert fallback is None
@@ -1079,8 +1081,8 @@ def test_homework_sources_auto_no_source_configured_raises(mock_fetcher: Pronote
:rtype: None :rtype: None
""" """
mock_fetcher._settings.pronote.homework_source = "auto" mock_fetcher._settings.pronote.homework_source = "auto"
mock_fetcher._settings.pronote.ical_url = None mock_fetcher._settings.pronote.ical_endpoint = None
mock_fetcher._settings.pronote.url = None mock_fetcher._settings.pronote.endpoint = None
with pytest.raises(PipelineCriticalError) as exc_info: with pytest.raises(PipelineCriticalError) as exc_info:
mock_fetcher._homework_sources() mock_fetcher._homework_sources()