Compare commits
48
Commits
9e968dd522
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6609d5c4ca | ||
|
|
839fdd202a | ||
|
|
eef81ea323 | ||
|
|
a21acaa409 | ||
|
|
dee5fe8eff | ||
|
|
7387f9a78d | ||
|
|
894f5d137a | ||
|
|
f261fed1af | ||
|
|
e6f0659cbf | ||
|
|
50c3696b06 | ||
|
|
c0a1c5ca62 | ||
|
|
52c791695c | ||
|
|
9433a69d70 | ||
|
|
4f95ca9256 | ||
|
|
81ff7238dd | ||
|
|
c47325ef14 | ||
|
|
fba5a060df | ||
|
|
1f03b114a9 | ||
|
|
4c6b8dbee8 | ||
|
|
423a32f220 | ||
|
|
79ef5434ee | ||
|
|
d5e4964887 | ||
|
|
35e93cc993 | ||
|
|
9cd3918291 | ||
|
|
f421a386f5 | ||
|
|
79858a0849 | ||
|
|
8efac8645b | ||
|
|
8c6a0e3f29 | ||
|
|
4ad62ad50f | ||
|
|
6575a5f60b | ||
|
|
5bd97402bf | ||
|
|
75dd78ee1e | ||
|
|
6bb8ad1ed1 | ||
|
|
2d55ac8d2b | ||
|
|
0ff6e687b6 | ||
|
|
573705a3d0 | ||
|
|
d45d38d365 | ||
|
|
f194ed985d | ||
|
|
d124f78b55 | ||
|
|
0dd4ee68c1 | ||
|
|
fde8fbe264 | ||
|
|
145dace289 | ||
|
|
642bbf16c1 | ||
|
|
2b82ffb38d | ||
|
|
4228c1e636 | ||
|
|
8b924b55d1 | ||
|
|
22a662ab39 | ||
|
|
5188761209 |
+17
-3
@@ -22,7 +22,10 @@ PRONOTE_AUTH_MODE=password
|
|||||||
# Le QR code expire ~10 minutes après génération
|
# Le QR code expire ~10 minutes après génération
|
||||||
# PRONOTE_AUTH_MODE=qr_token
|
# PRONOTE_AUTH_MODE=qr_token
|
||||||
# PRONOTE_QR_CODE_FILE=/path/to/qr_code.json
|
# PRONOTE_QR_CODE_FILE=/path/to/qr_code.json
|
||||||
# PRONOTE_QR_PIN=1234
|
# PRONOTE_QR_PIN=
|
||||||
|
# Valeur à définir localement dans .env ; ne jamais la committer.
|
||||||
|
# PIN de second facteur du compte Pronote, distinct du PIN de déchiffrement du QR code
|
||||||
|
# PRONOTE_ACCOUNT_PIN=
|
||||||
|
|
||||||
# --- CalDAV ---
|
# --- CalDAV ---
|
||||||
CALDAV_URL=https://caldav.example.com/calendars/user/pronote/
|
CALDAV_URL=https://caldav.example.com/calendars/user/pronote/
|
||||||
@@ -32,7 +35,7 @@ CALDAV_CALENDAR_PATH=/pronote-sync/
|
|||||||
# Autoriser HTTP (non-HTTPS) pour un serveur CalDAV local (localhost uniquement)
|
# Autoriser HTTP (non-HTTPS) pour un serveur CalDAV local (localhost uniquement)
|
||||||
CALDAV_ALLOW_INSECURE_HTTP=false
|
CALDAV_ALLOW_INSECURE_HTTP=false
|
||||||
|
|
||||||
# Fenêtre de synchronisation (jours)
|
# Fenêtre de synchronisation (jours) — entier >= 0 ; 0 = aucune journée supplémentaire de ce côté (le jour courant reste inclus). Valeurs négatives refusées au chargement (ValidationError).
|
||||||
SYNC_PAST_DAYS=7
|
SYNC_PAST_DAYS=7
|
||||||
SYNC_FUTURE_DAYS=30
|
SYNC_FUTURE_DAYS=30
|
||||||
|
|
||||||
@@ -50,7 +53,11 @@ XMPP_HOST=example.com
|
|||||||
XMPP_PORT=5222
|
XMPP_PORT=5222
|
||||||
XMPP_TO=parent@example.com
|
XMPP_TO=parent@example.com
|
||||||
XMPP_RESOURCE=pronote-sync
|
XMPP_RESOURCE=pronote-sync
|
||||||
XMPP_USE_TLS=true
|
# Mode TLS : "starttls" (port 5222, défaut), "direct" (port 5223) ou "disabled" (localhost uniquement)
|
||||||
|
XMPP_TLS_MODE=starttls
|
||||||
|
# XMPP_USE_TLS=true/false est obsolète mais encore supporté (alias de XMPP_TLS_MODE)
|
||||||
|
XMPP_CONNECT_TIMEOUT=15
|
||||||
|
XMPP_CLEANUP_TIMEOUT=10
|
||||||
XMPP_TIMEOUT=30
|
XMPP_TIMEOUT=30
|
||||||
|
|
||||||
# --- IA (optionnelle) ---
|
# --- IA (optionnelle) ---
|
||||||
@@ -62,6 +69,13 @@ AI_BASE_URL=https://api.openai.com/v1
|
|||||||
# AI_API_KEY=
|
# AI_API_KEY=
|
||||||
# AI_MODEL=gpt-4o-mini # exemple recommandé, non activé par défaut
|
# AI_MODEL=gpt-4o-mini # exemple recommandé, non activé par défaut
|
||||||
|
|
||||||
|
# NOTE : la validation structurelle de AI_BASE_URL s'applique à TOUS les
|
||||||
|
# providers (openai, litellm, openai-compatible) : HTTPS obligatoire sauf si
|
||||||
|
# AI_ALLOW_INSECURE_HTTP=true, aucun credential embarqué (user:pass@hôte),
|
||||||
|
# aucun paramètre sensible dans la query string (token, key, api_key,
|
||||||
|
# secret, password, auth), et aucune manipulation automatique de /v1.
|
||||||
|
# Seul le provider openai-compatible exige AI_BASE_URL et AI_MODEL.
|
||||||
|
|
||||||
# Exemple : OpenRouter (HTTPS)
|
# Exemple : OpenRouter (HTTPS)
|
||||||
# AI_PROVIDER=openai-compatible
|
# AI_PROVIDER=openai-compatible
|
||||||
# AI_BASE_URL=https://openrouter.ai/api/v1
|
# AI_BASE_URL=https://openrouter.ai/api/v1
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ Thumbs.db
|
|||||||
.caldav_sync_state.json
|
.caldav_sync_state.json
|
||||||
# État d'authentification pronotepy (QR code / token rotation)
|
# État d'authentification pronotepy (QR code / token rotation)
|
||||||
.pronote_auth_state.json
|
.pronote_auth_state.json
|
||||||
|
.pronote_auth_state.json.lock
|
||||||
*.state.json
|
*.state.json
|
||||||
|
|
||||||
# --- Local scratch / WIP files ---
|
# --- Local scratch / WIP files ---
|
||||||
|
|||||||
+13
-5
@@ -139,9 +139,8 @@
|
|||||||
"type": "Hex High Entropy String",
|
"type": "Hex High Entropy String",
|
||||||
"filename": "GUIDE_DEV_PYTHON.md",
|
"filename": "GUIDE_DEV_PYTHON.md",
|
||||||
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
|
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
|
||||||
"is_verified": true,
|
"is_verified": false,
|
||||||
"line_number": 5065,
|
"line_number": 5186
|
||||||
"is_secret": false
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"tests/unit/test_caldav_gateway.py": [
|
"tests/unit/test_caldav_gateway.py": [
|
||||||
@@ -157,7 +156,7 @@
|
|||||||
"filename": "tests/unit/test_caldav_gateway.py",
|
"filename": "tests/unit/test_caldav_gateway.py",
|
||||||
"hashed_secret": "1c58bd92003bbaa0538e249fff6ee19a270dec5f",
|
"hashed_secret": "1c58bd92003bbaa0538e249fff6ee19a270dec5f",
|
||||||
"is_verified": false,
|
"is_verified": false,
|
||||||
"line_number": 763
|
"line_number": 794
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"tests/unit/test_caldav_security.py": [
|
"tests/unit/test_caldav_security.py": [
|
||||||
@@ -175,7 +174,16 @@
|
|||||||
"is_verified": false,
|
"is_verified": false,
|
||||||
"line_number": 28
|
"line_number": 28
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"tests/unit/test_xmpp_channel.py": [
|
||||||
|
{
|
||||||
|
"type": "Secret Keyword",
|
||||||
|
"filename": "tests/unit/test_xmpp_channel.py",
|
||||||
|
"hashed_secret": "b01bcded8826562355b3e32a8b31e214ff533511",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 1011
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"generated_at": "2026-09-11T05:17:54Z"
|
"generated_at": "2026-09-12T22:12:56Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,9 +159,12 @@ pronote-sync --dry-run
|
|||||||
- Après chaque login réussi, les credentials exportées par `pronotepy.export_credentials()` sont
|
- Après chaque login réussi, les credentials exportées par `pronotepy.export_credentials()` sont
|
||||||
persistées dans `.pronote_auth_state.json` (permissions `0600`, format JSON versionné, écriture
|
persistées dans `.pronote_auth_state.json` (permissions `0600`, format JSON versionné, écriture
|
||||||
atomique). Le token rotate à chaque session et peut également être rafraîchi pendant l'exécution
|
atomique). Le token rotate à chaque session et peut également être rafraîchi pendant l'exécution
|
||||||
(refresh automatique pronotepy après une `PronoteAPIError`). Les credentials sont persistées après
|
(refresh automatique pronotepy après une `PronoteAPIError`). La persistance s'applique après
|
||||||
chaque login réussi **et après chaque opération de données réussie** (agenda, devoirs, messages,
|
chaque login réussi **et après chaque opération de données réussie** (agenda, devoirs, messages)
|
||||||
informations) pour garantir la persistance du token valide.
|
pour garantir la persistance du token valide ; seules les opérations qui se connectent réellement
|
||||||
|
et récupèrent des données déclenchent la persistance. En mode `qr_token`, `get_informations()`
|
||||||
|
est ignorée (retour immédiat `[]` sans connexion ni verrou) et ne déclenche donc aucune
|
||||||
|
persistance.
|
||||||
- Les logins suivants utilisent `pronotepy.token_login(**credentials)` avec le token persisté.
|
- Les logins suivants utilisent `pronotepy.token_login(**credentials)` avec le token persisté.
|
||||||
- En cas d'échec de `token_login` (token expiré/invalide), une `PronoteAuthRotationError` est levée.
|
- En cas d'échec de `token_login` (token expiré/invalide), une `PronoteAuthRotationError` est levée.
|
||||||
Cette erreur se propage sans wrapping à travers `PronoteFetcher` et `fetch_step` jusqu'à
|
Cette erreur se propage sans wrapping à travers `PronoteFetcher` et `fetch_step` jusqu'à
|
||||||
@@ -172,6 +175,11 @@ pronote-sync --dry-run
|
|||||||
- `PronoteAuthRotationError` est re-levée telle quelle (`except PronoteAuthRotationError: raise`)
|
- `PronoteAuthRotationError` est re-levée telle quelle (`except PronoteAuthRotationError: raise`)
|
||||||
dans toutes les couches d'enveloppement du chemin critique (fetch_agenda, fetch_homework,
|
dans toutes les couches d'enveloppement du chemin critique (fetch_agenda, fetch_homework,
|
||||||
fetch_step). Ne pas l'attraper avec `except Exception` sans la re-léver d'abord.
|
fetch_step). Ne pas l'attraper avec `except Exception` sans la re-léver d'abord.
|
||||||
|
- En mode `qr_token`, `get_informations()` retourne **inconditionnellement** une liste vide
|
||||||
|
(`[]`) sans connexion, verrou, chargement d'état ni appel réseau, et journalise un message
|
||||||
|
INFO unique : l'endpoint `PageActualites` renvoie une erreur pronotepy 20 sur les instances
|
||||||
|
HubEduConnect testées, provoquant un refresh redondant du token. Ce contournement n'est pas
|
||||||
|
configurable ; aucun état anti-répétition n'est conservé.
|
||||||
- Le fichier `.pronote_auth_state.json` ne doit jamais être committé (couvert par `.gitignore`).
|
- Le fichier `.pronote_auth_state.json` ne doit jamais être committé (couvert par `.gitignore`).
|
||||||
Son contenu (token vivant) ne doit jamais apparaître dans les logs, les messages d'erreur ou
|
Son contenu (token vivant) ne doit jamais apparaître dans les logs, les messages d'erreur ou
|
||||||
les notifications XMPP.
|
les notifications XMPP.
|
||||||
@@ -222,7 +230,8 @@ def fetch_ical(url: str) -> str:
|
|||||||
- **Masquage** : Utiliser systématiquement `redact_url()`, `redact_secrets()`, et `redact_exception()` depuis `utils/redaction.py`.
|
- **Masquage** : Utiliser systématiquement `redact_url()`, `redact_secrets()`, et `redact_exception()` depuis `utils/redaction.py`.
|
||||||
- **Chaînage d'exceptions** : Ne jamais conserver comme `__cause__` ou `__context__` une exception
|
- **Chaînage d'exceptions** : Ne jamais conserver comme `__cause__` ou `__context__` une exception
|
||||||
externe brute susceptible de contenir un secret. Journaliser la version expurgée puis utiliser
|
externe brute susceptible de contenir un secret. Journaliser la version expurgée puis utiliser
|
||||||
`raise ... from None`, ou chaîner une cause elle-même expurgée.
|
`raise ... from None` hors du bloc `except` (car `from None` seul laisse
|
||||||
|
l'exception externe dans `__context__`), ou chaîner une cause elle-même expurgée.
|
||||||
- **Tests de non-fuite** : Vérifier les messages, les logs, `__cause__`, `__context__` et le
|
- **Tests de non-fuite** : Vérifier les messages, les logs, `__cause__`, `__context__` et le
|
||||||
traceback complet avec des sentinelles distinctes pour chaque secret.
|
traceback complet avec des sentinelles distinctes pour chaque secret.
|
||||||
|
|
||||||
|
|||||||
+3
-8
@@ -25,17 +25,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- `PRONOTE_URL` ignoré à cause du double préfixe `env_prefix` (renommage `pronote_url` → `url` dans `PronoteSettings`)
|
- `PRONOTE_URL` ignoré à cause du double préfixe `env_prefix` (renommage `pronote_url` → `url` dans `PronoteSettings`)
|
||||||
- `PRONOTE_ENT` rendu optionnel pour les connexions pronotepy directes
|
- `PRONOTE_ENT` rendu optionnel pour les connexions pronotepy directes
|
||||||
- `.env.example` corrigé (`eleve.html` → `parent.html`)
|
- `.env.example` corrigé (`eleve.html` → `parent.html`)
|
||||||
|
- #20/#21 — `get_informations()` ignorée en mode `qr_token` : retourne `[]` immédiatement, sans connexion, verrou ni appel réseau, évitant l'échec systématique de l'endpoint `PageActualites` (erreur pronotepy 20 sur les instances HubEduConnect testées) et le refresh redondant du token associé.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Wiki `GuidePronote` enrichi : section "Quand l'ENT est obligatoire" (EduConnect/HubEduConnect), exemple Bordeaux
|
- Wiki `GuidePronote` enrichi : section "Quand l'ENT est obligatoire" (EduConnect/HubEduConnect), exemple Bordeaux
|
||||||
- `AGENTS.md` : ajout de la section §13 "Versionnage et releases"
|
- `AGENTS.md` : ajout de la section §13 "Versionnage et releases"
|
||||||
|
|
||||||
### Known Issues
|
|
||||||
|
|
||||||
- #20 — Triple authentification pronotepy (double INIT + refresh) lors d'un run
|
|
||||||
- #21 — Erreur pronotepy 20 « La page a expiré ! (11) » sur `get_informations`
|
|
||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
|
|
||||||
- 694 tests passés, couverture 94.93%
|
- 694 tests passés, couverture 94.93%
|
||||||
@@ -44,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`.
|
||||||
@@ -61,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.
|
|
||||||
|
|||||||
+171
-50
@@ -275,7 +275,8 @@ Le projet utilise **`pydantic-settings`** pour valider et charger la configurati
|
|||||||
|
|
||||||
> ⚠️ **Décision d'implémentation** :
|
> ⚠️ **Décision d'implémentation** :
|
||||||
> `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_USE_TLS`, `XMPP_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`).
|
||||||
> Une section `BLOG_ENABLED` et `BLOG_RSS_URL` a été ajoutée dans `.env.example`.
|
> Une section `BLOG_ENABLED` et `BLOG_RSS_URL` a été ajoutée dans `.env.example`.
|
||||||
|
|
||||||
Les variables Pronote sont obligatoires selon les sources activées :
|
Les variables Pronote sont obligatoires selon les sources activées :
|
||||||
@@ -298,18 +299,18 @@ d'un besoin réel et testé.
|
|||||||
| `PRONOTE_AGENDA_SOURCE` | Source pour l'agenda (`auto`, `ical`, `pronotepy`). | `auto` | `Literal` |
|
| `PRONOTE_AGENDA_SOURCE` | Source pour l'agenda (`auto`, `ical`, `pronotepy`). | `auto` | `Literal` |
|
||||||
| `PRONOTE_HOMEWORK_SOURCE` | Source pour les devoirs (`auto`, `ical`, `pronotepy`). | `auto` | `Literal` |
|
| `PRONOTE_HOMEWORK_SOURCE` | Source pour les devoirs (`auto`, `ical`, `pronotepy`). | `auto` | `Literal` |
|
||||||
| `PRONOTE_MESSAGES_SOURCE` | Source pour les messages (`pronotepy` uniquement). | `pronotepy` | `Literal` |
|
| `PRONOTE_MESSAGES_SOURCE` | Source pour les messages (`pronotepy` uniquement). | `pronotepy` | `Literal` |
|
||||||
| `SYNC_PAST_DAYS` | Nombre de jours dans le passé pour la sync CalDAV. | `7` | `int` |
|
| `SYNC_PAST_DAYS` | Nombre de jours dans le passé pour la sync CalDAV, entier `>= 0` (`0` = aucune journée supplémentaire de ce côté ; le jour courant reste inclus). | `7` | `int` |
|
||||||
| `SYNC_FUTURE_DAYS` | Nombre de jours dans le futur pour la sync CalDAV. | `30` | `int` |
|
| `SYNC_FUTURE_DAYS` | Nombre de jours dans le futur pour la sync CalDAV, entier `>= 0` (`0` = aucune journée supplémentaire de ce côté ; le jour courant reste inclus). | `30` | `int` |
|
||||||
| `THEORETICAL_AGENDA_PATH` | Chemin vers le fichier JSON de l'agenda théorique. | `None` | `str \| None`|
|
| `THEORETICAL_AGENDA_PATH` | Chemin vers le fichier JSON de l'agenda théorique. | `None` | `str \| None`|
|
||||||
| `SCHOOL_HOLIDAYS_PATH` | Chemin vers le fichier JSON des vacances scolaires. | `None` | `str \| None`|
|
| `SCHOOL_HOLIDAYS_PATH` | Chemin vers le fichier JSON des vacances scolaires. | `None` | `str \| None`|
|
||||||
| `THEORETICAL_WEEK_ANCHOR_DATE` | Date de référence pour la parité des semaines (paire/impaire). | `None` | `date \| None`|
|
| `THEORETICAL_WEEK_ANCHOR_DATE` | Date de référence pour la parité des semaines (paire/impaire). | `None` | `date \| None`|
|
||||||
| `THEORETICAL_WEEK_ANCHOR_TYPE` | Parité de la semaine de référence (`even` ou `odd`). | `None` | `Literal["even", "odd"] \| None`|
|
| `THEORETICAL_WEEK_ANCHOR_TYPE` | Parité de la semaine de référence (`even` ou `odd`). | `None` | `Literal["even", "odd"] \| None`|
|
||||||
| `AI_ENABLED` | Activer la synthèse IA. | `False` | `bool` |
|
| `AI_ENABLED` | Activer la synthèse IA. | `False` | `bool` |
|
||||||
| `AI_PROVIDER` | Fournisseur IA (`openai`, `openai-compatible` ou `litellm`). | `openai` | `Literal["openai", "litellm", "openai-compatible"]` |
|
| `AI_PROVIDER` | Fournisseur IA (`openai`, `openai-compatible` ou `litellm`). | `openai` | `Literal["openai", "litellm", "openai-compatible"]` |
|
||||||
| `AI_BASE_URL` | URL de base pour l'API IA (ex: OpenAI compatible). | `None` | `str \| None`|
|
| `AI_BASE_URL` | URL de base pour l'API IA (validée structurellement pour tous les providers, voir ci-dessous). | `None` | `str \| None`|
|
||||||
| `AI_API_KEY` | Clé API pour l'API IA. | `None` | `SecretStr` |
|
| `AI_API_KEY` | Clé API pour l'API IA. | `None` | `SecretStr` |
|
||||||
| `AI_MODEL` | Modèle IA à utiliser (exemple recommandé : `gpt-4o-mini`). | `None` | `str \| None`|
|
| `AI_MODEL` | Modèle IA à utiliser (exemple recommandé : `gpt-4o-mini`). | `None` | `str \| None`|
|
||||||
| `AI_ALLOW_INSECURE_HTTP` | Autoriser HTTP (non sécurisé) pour `openai-compatible` uniquement. | `False` | `bool` |
|
| `AI_ALLOW_INSECURE_HTTP` | Autoriser HTTP (non sécurisé) pour tous les providers (openai, litellm, openai-compatible). | `False` | `bool` |
|
||||||
| `DRY_RUN` | Simulation sans sortie distante ni état local persistant ; incompatible avec `qr_token`. | `False` | `bool` |
|
| `DRY_RUN` | Simulation sans sortie distante ni état local persistant ; incompatible avec `qr_token`. | `False` | `bool` |
|
||||||
| `LOG_LEVEL` | Niveau de log (`DEBUG`, `INFO`, `WARNING`, `ERROR`). | `INFO` | `str` |
|
| `LOG_LEVEL` | Niveau de log (`DEBUG`, `INFO`, `WARNING`, `ERROR`). | `INFO` | `str` |
|
||||||
|
|
||||||
@@ -393,7 +394,7 @@ LOG_LEVEL=INFO
|
|||||||
> `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"`.
|
||||||
> > ``AISettings.provider`` accepte également ``openai-compatible`` (réutilise ``OpenAISynthesisProvider`` avec un ``base_url`` personnalisé).
|
> > ``AISettings.provider`` accepte également ``openai-compatible`` (réutilise ``OpenAISynthesisProvider`` avec un ``base_url`` personnalisé).
|
||||||
> > ``AISettings.allow_insecure_http`` (défaut ``False``) autorise les URLs HTTP pour le provider ``openai-compatible`` uniquement.
|
> > ``AISettings.allow_insecure_http`` (défaut ``False``) autorise les URLs HTTP pour les trois providers (``openai``, ``litellm`` et ``openai-compatible``).
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
@@ -420,8 +421,8 @@ class CalDAVSettings(BaseSettings):
|
|||||||
password: SecretStr | None = None
|
password: SecretStr | None = None
|
||||||
calendar_path: str = "/pronote-sync/"
|
calendar_path: str = "/pronote-sync/"
|
||||||
allow_insecure_http: bool = False
|
allow_insecure_http: bool = False
|
||||||
sync_past_days: int = 7
|
sync_past_days: int = Field(default=7, ge=0)
|
||||||
sync_future_days: int = 30
|
sync_future_days: int = Field(default=30, ge=0)
|
||||||
|
|
||||||
|
|
||||||
class AISettings(BaseSettings):
|
class AISettings(BaseSettings):
|
||||||
@@ -1531,6 +1532,14 @@ Sinon :
|
|||||||
retourner J+1 (no-school, avec libellé de vacances si applicable)
|
retourner J+1 (no-school, avec libellé de vacances si applicable)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Dans l'implémentation Python actuelle (`pronote_sync.pipeline.steps.fetch.resolve_target_date`),
|
||||||
|
seuls les cours non annulés sont considérés comme effectifs ; un cours déplacé reste donc
|
||||||
|
éligible. La recherche du prochain cours connu ne dépend pas de la présence d'un cours à J.
|
||||||
|
Lorsqu'aucun cours futur n'est fourni et qu'un événement scolaire couvre J+1, sa borne
|
||||||
|
`to_date` (exclusive) est utilisée comme reprise connue. Sinon, J+1 est conservé comme
|
||||||
|
valeur de repli explicite. La fonction journalise la règle choisie avec la date et une raison
|
||||||
|
non personnelle.
|
||||||
|
|
||||||
**Exemple Python** :
|
**Exemple Python** :
|
||||||
```python
|
```python
|
||||||
Optional, Tuple, List
|
Optional, Tuple, List
|
||||||
@@ -1892,6 +1901,20 @@ def generate_deterministic_uid(
|
|||||||
|
|
||||||
#### 5.1.6 Parsing complet du flux iCal (`sources/pronote/ical.py`)
|
#### 5.1.6 Parsing complet du flux iCal (`sources/pronote/ical.py`)
|
||||||
|
|
||||||
|
Le parseur de production accepte les variantes contrôlées des libellés et des
|
||||||
|
balises `strong` générées par Pronote. Il distingue les catégories `Congés` /
|
||||||
|
`Vacances` (`HOLIDAY`) des catégories `Jour(s) férié(s)` (`PUBLIC_HOLIDAY`),
|
||||||
|
conserve la borne `DTEND` comme date exclusive et reconnaît les statuts
|
||||||
|
annulé, déplacé ou modifié. Les sections de devoirs conservent un HTML
|
||||||
|
nettoyé (scripts, styles et attributs exécutables supprimés) ainsi qu'un texte
|
||||||
|
lisible. La déduplication utilise la date, la matière, les enseignants et le
|
||||||
|
texte normalisé : une copie du même devoir est fusionnée, tandis qu'un devoir
|
||||||
|
homonyme d'une autre matière ou d'un autre enseignant est conservé.
|
||||||
|
|
||||||
|
Le champ `Partie(s) de classe` est conservé dans `Lesson.class_part` et dans
|
||||||
|
la description iCalendar sérialisée. Les catégories ou sections inconnues ne
|
||||||
|
doivent pas être interprétées comme des vacances, une annulation ou un devoir.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
List, Optional, Tuple
|
List, Optional, Tuple
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
@@ -2256,10 +2279,38 @@ méthodes agenda/devoirs ne transforment jamais une erreur en liste vide : elles
|
|||||||
version expurgée puis lèvent une erreur expurgée avec `from None`. Les méthodes de messages et
|
version expurgée puis lèvent une erreur expurgée avec `from None`. Les méthodes de messages et
|
||||||
d'informations sont non critiques et peuvent retourner une liste vide avec un warning.
|
d'informations sont non critiques et peuvent retourner une liste vide avec un warning.
|
||||||
|
|
||||||
|
En mode `qr_token`, `get_informations()` est ignorée : elle retourne immédiatement `[]` sans
|
||||||
|
connexion, verrou ni appel réseau, et journalise un message INFO unique. L'endpoint
|
||||||
|
`PageActualites` renvoie en effet une erreur pronotepy 20 sur les instances HubEduConnect
|
||||||
|
testées, provoquant un refresh redondant du token. Ce comportement n'est pas configurable ;
|
||||||
|
`get_messages()` n'est pas concernée par ce contournement.
|
||||||
|
|
||||||
Les objets renvoyés par `client.homework(start, end)` couvrent une fenêtre. Le résultat destiné à
|
Les objets renvoyés par `client.homework(start, end)` couvrent une fenêtre. Le résultat destiné à
|
||||||
un jour cible est donc filtré explicitement sur `homework.date == target_date`.
|
un jour cible est donc filtré explicitement sur `homework.date == target_date`.
|
||||||
|
|
||||||
#### 5.1.8 Logique de repli (`sources/pronote/fallback.py`)
|
#### 5.1.8 Verrou du cycle d'authentification QR/token
|
||||||
|
|
||||||
|
En mode `qr_token`, le token Pronote est un état partagé et rotatif. Afin d'éviter que deux
|
||||||
|
exécutions ne réutilisent ou n'écrasent cet état simultanément, le client protège chaque cycle
|
||||||
|
d'authentification et de récupération par un verrou POSIX local non bloquant, situé dans
|
||||||
|
`.pronote_auth_state.json.lock`, à côté de `.pronote_auth_state.json`.
|
||||||
|
|
||||||
|
Le verrou couvre l'ensemble du cycle QR/token : chargement de l'état, connexion par token ou
|
||||||
|
enrôlement QR initial, opération de données (agenda, devoirs, messages ; informations hors mode
|
||||||
|
`qr_token`), puis persistance des credentials actualisées. En mode `qr_token`, `get_informations()`
|
||||||
|
est ignorée (retour immédiat `[]` sans connexion ni verrou) : elle n'acquiert pas le verrou et ne
|
||||||
|
déclenche aucune persistance. Une tentative concurrente échoue immédiatement avec une erreur d'état
|
||||||
|
d'authentification expurgée ; elle ne patiente pas et ne relance pas l'authentification. Le contenu
|
||||||
|
du token, le PIN et les autres credentials ne sont jamais inclus dans les logs ni dans ce message
|
||||||
|
d'erreur.
|
||||||
|
|
||||||
|
Ce mécanisme est un contrat **local** : il coordonne des processus sur le même hôte Linux et un
|
||||||
|
filesystem local. Pour des déploiements conteneurisés, les conteneurs qui partagent le même compte
|
||||||
|
Pronote doivent également partager le fichier d'état et son fichier de verrou. Le verrou ne fournit
|
||||||
|
aucune exclusion fiable entre plusieurs hôtes ou via NFS ; dans ces cas, l'opérateur doit prévoir
|
||||||
|
une exclusion externe ou utiliser un token distinct par instance.
|
||||||
|
|
||||||
|
#### 5.1.9 Logique de repli (`sources/pronote/fallback.py`)
|
||||||
|
|
||||||
Le `PronoteFetcher` dépend de `Settings` et d'un protocole de client injecté ; il ne construit pas
|
Le `PronoteFetcher` dépend de `Settings` et d'un protocole de client injecté ; il ne construit pas
|
||||||
de singleton et ne contient pas d'identifiants dupliqués.
|
de singleton et ne contient pas d'identifiants dupliqués.
|
||||||
@@ -3874,16 +3925,26 @@ L'import de `litellm` est conditionnel avec `try/except ImportError` → `None`.
|
|||||||
| ``openai-compatible`` | Proxy ou serveur compatible OpenAI | ``OpenAISynthesisProvider`` |
|
| ``openai-compatible`` | Proxy ou serveur compatible OpenAI | ``OpenAISynthesisProvider`` |
|
||||||
| ``litellm`` | Bibliothèque LiteLLM embarquée | ``LiteLLMSynthesisProvider`` |
|
| ``litellm`` | Bibliothèque LiteLLM embarquée | ``LiteLLMSynthesisProvider`` |
|
||||||
|
|
||||||
Pour le provider ``openai-compatible``, la validation de la configuration est stricte :
|
Pour le provider ``openai-compatible``, ``AI_BASE_URL`` et ``AI_MODEL`` sont requis ; pour
|
||||||
|
``openai`` et ``litellm``, ils sont optionnels. La validation structurelle de ``AI_BASE_URL``
|
||||||
|
(partagée via ``_validate_base_url``) s'applique de façon identique aux trois providers dès que
|
||||||
|
l'URL est renseignée :
|
||||||
|
|
||||||
- ``AI_BASE_URL`` est requis.
|
- URL parsable par ``urlparse`` (``ValueError`` ou schéma vide → refusée) et hostname non vide.
|
||||||
- ``AI_MODEL`` est requis et ne doit pas être vide.
|
- Schéma limité à ``http``/``https`` ; ``http`` refusé sauf si ``AI_ALLOW_INSECURE_HTTP=true``.
|
||||||
- ``AI_API_KEY`` est requis (MVP).
|
- Credentials dans l'URL (``user:pass@host``) refusés.
|
||||||
- L'URL doit utiliser le schéma ``https`` sauf si ``AI_ALLOW_INSECURE_HTTP=true``.
|
- Paramètres sensibles dans la *query string* refusés, y compris sans valeur
|
||||||
- Les credentials dans l'URL sont refusés.
|
(``token``, ``key``, ``api_key``, ``secret``, ``password``, ``auth``).
|
||||||
- Les paramètres sensibles dans la *query string* sont refusés.
|
|
||||||
- Aucune manipulation automatique de ``/v1`` n'est effectuée.
|
- Aucune manipulation automatique de ``/v1`` n'est effectuée.
|
||||||
- Si la configuration est incomplète, la factory retourne ``None`` avec un avertissement (mode dégradé).
|
- En cas de violation, un avertissement est journalisé (URL masquée via ``redact_url``) et la
|
||||||
|
factory retourne ``None`` (mode dégradé) ; la factory ne lève jamais d'exception et ne fait
|
||||||
|
aucun appel réseau.
|
||||||
|
|
||||||
|
| Provider | `AI_BASE_URL` | `AI_MODEL` | Validation structurelle |
|
||||||
|
|---------------------|---------------|------------|---------------------------------------------|
|
||||||
|
| ``openai`` | optionnel | optionnel | `_validate_base_url("openai", ...)` |
|
||||||
|
| ``litellm`` | optionnel | optionnel | `_validate_base_url("litellm", ...)` |
|
||||||
|
| ``openai-compatible`` | requis | requis | `_validate_openai_compatible_config` (présence puis `_validate_base_url`) |
|
||||||
|
|
||||||
La politique hors réseau de la table des modèles litellm est gérée par `LITELLM_LOCAL_MODEL_COST_MAP=true`. Les tests utilisent `pytest.importorskip("litellm")`.
|
La politique hors réseau de la table des modèles litellm est gérée par `LITELLM_LOCAL_MODEL_COST_MAP=true`. Les tests utilisent `pytest.importorskip("litellm")`.
|
||||||
|
|
||||||
@@ -3899,37 +3960,69 @@ from ..utils.redaction import redact_url
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
_SENSITIVE_QUERY_PARAMS = {"token", "key", "api_key", "secret", "password", "auth"}
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_base_url(provider: str, url: str, allow_insecure_http: bool) -> str | None:
|
||||||
|
"""Valide structurellement une URL de base IA, partagée entre providers."""
|
||||||
|
try:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if not parsed.scheme:
|
||||||
|
logger.warning("URL invalide pour le provider %s : %s", provider, redact_url(url))
|
||||||
|
return None
|
||||||
|
if not parsed.hostname:
|
||||||
|
logger.warning(
|
||||||
|
"URL sans hostname pour le provider %s : %s", provider, redact_url(url)
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
if parsed.scheme not in ("http", "https"):
|
||||||
|
logger.warning(
|
||||||
|
"Schéma d'URL non supporté pour le provider %s : %s", provider, redact_url(url)
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
if parsed.scheme == "http" and not allow_insecure_http:
|
||||||
|
logger.warning(
|
||||||
|
"URL HTTP non autorisée sans AI_ALLOW_INSECURE_HTTP=true pour le provider %s : %s",
|
||||||
|
provider,
|
||||||
|
redact_url(url),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
if parsed.username is not None or parsed.password is not None:
|
||||||
|
logger.warning(
|
||||||
|
"Credentials dans l'URL refusés pour le provider %s : %s",
|
||||||
|
provider,
|
||||||
|
redact_url(url),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
param_names = [
|
||||||
|
name.lower() for name, _ in parse_qsl(parsed.query, keep_blank_values=True)
|
||||||
|
]
|
||||||
|
if any(name in _SENSITIVE_QUERY_PARAMS for name in param_names):
|
||||||
|
logger.warning(
|
||||||
|
"Paramètres sensibles dans l'URL refusés pour le provider %s : %s",
|
||||||
|
provider,
|
||||||
|
redact_url(url),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
# Accéder à parsed.port peut lever ValueError (port invalide/hors bornes).
|
||||||
|
parsed.port # noqa: B018
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("URL invalide pour le provider %s : %s", provider, redact_url(url))
|
||||||
|
return None
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
def _validate_openai_compatible_config(
|
def _validate_openai_compatible_config(
|
||||||
url: str | None, model: str | None, allow_insecure_http: bool
|
url: str | None, model: str | None, allow_insecure_http: bool
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Valide la configuration du provider ``openai-compatible``."""
|
"""Valide la configuration du provider ``openai-compatible``."""
|
||||||
if not url or not model:
|
if not url:
|
||||||
|
logger.warning("URL de base requise pour le provider openai-compatible")
|
||||||
return None
|
return None
|
||||||
try:
|
if not model:
|
||||||
parsed = urlparse(url)
|
logger.warning("Modèle requis pour le provider openai-compatible")
|
||||||
except ValueError:
|
|
||||||
logger.warning("URL invalide : %s", redact_url(url))
|
|
||||||
return None
|
return None
|
||||||
if not parsed.hostname:
|
return _validate_base_url("openai-compatible", url, allow_insecure_http)
|
||||||
logger.warning("URL sans hostname : %s", redact_url(url))
|
|
||||||
return None
|
|
||||||
if parsed.scheme not in ("http", "https"):
|
|
||||||
return None
|
|
||||||
if parsed.scheme == "http" and not allow_insecure_http:
|
|
||||||
return None
|
|
||||||
if parsed.username is not None or parsed.password is not None:
|
|
||||||
logger.warning("Credentials dans l'URL refusés : %s", redact_url(url))
|
|
||||||
return None
|
|
||||||
sensitive_names = {"token", "key", "api_key", "secret", "password", "auth"}
|
|
||||||
param_names = [
|
|
||||||
name.lower() for name, _ in parse_qsl(parsed.query, keep_blank_values=True)
|
|
||||||
]
|
|
||||||
if any(name in sensitive_names for name in param_names):
|
|
||||||
logger.warning(
|
|
||||||
"Paramètres sensibles dans l'URL refusés : %s", redact_url(url)
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
return url
|
|
||||||
|
|
||||||
|
|
||||||
def get_synthesis_provider(settings: AISettings) -> SynthesisProvider | None:
|
def get_synthesis_provider(settings: AISettings) -> SynthesisProvider | None:
|
||||||
@@ -3938,7 +4031,10 @@ def get_synthesis_provider(settings: AISettings) -> SynthesisProvider | None:
|
|||||||
Retourne ``None`` lorsque la synthèse IA est désactivée ou qu'aucune clé
|
Retourne ``None`` lorsque la synthèse IA est désactivée ou qu'aucune clé
|
||||||
API n'est configurée. Pour le provider ``litellm``, le paquet ``litellm``
|
API n'est configurée. Pour le provider ``litellm``, le paquet ``litellm``
|
||||||
(extra ``ai-litellm``) est requis : s'il est absent, un avertissement est
|
(extra ``ai-litellm``) est requis : s'il est absent, un avertissement est
|
||||||
journalisé et ``None`` est retourné.
|
journalisé et ``None`` est retourné. Pour ``openai`` et ``litellm``,
|
||||||
|
une ``base_url`` éventuelle est validée par :func:`_validate_base_url` ;
|
||||||
|
pour le provider ``openai-compatible``, la configuration est validée par
|
||||||
|
:func:`_validate_openai_compatible_config`.
|
||||||
|
|
||||||
:param settings: Paramètres IA.
|
:param settings: Paramètres IA.
|
||||||
:return: Le fournisseur configuré, ou ``None`` si désactivé ou sans clé API.
|
:return: Le fournisseur configuré, ou ``None`` si désactivé ou sans clé API.
|
||||||
@@ -3958,6 +4054,10 @@ def get_synthesis_provider(settings: AISettings) -> SynthesisProvider | None:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
logger.warning("Extra 'ai-litellm' requis pour le provider litellm")
|
logger.warning("Extra 'ai-litellm' requis pour le provider litellm")
|
||||||
return None
|
return None
|
||||||
|
if base_url is not None and (
|
||||||
|
_validate_base_url("litellm", base_url, settings.allow_insecure_http) is None
|
||||||
|
):
|
||||||
|
return None
|
||||||
return LiteLLMSynthesisProvider(api_key=settings.api_key, base_url=base_url, model=model)
|
return LiteLLMSynthesisProvider(api_key=settings.api_key, base_url=base_url, model=model)
|
||||||
|
|
||||||
if settings.provider == "openai-compatible":
|
if settings.provider == "openai-compatible":
|
||||||
@@ -3968,6 +4068,10 @@ def get_synthesis_provider(settings: AISettings) -> SynthesisProvider | None:
|
|||||||
return None
|
return None
|
||||||
return OpenAISynthesisProvider(api_key=settings.api_key, base_url=url, model=model)
|
return OpenAISynthesisProvider(api_key=settings.api_key, base_url=url, model=model)
|
||||||
|
|
||||||
|
if base_url is not None and (
|
||||||
|
_validate_base_url("openai", base_url, settings.allow_insecure_http) is None
|
||||||
|
):
|
||||||
|
return None
|
||||||
return OpenAISynthesisProvider(api_key=settings.api_key, base_url=base_url, model=model)
|
return OpenAISynthesisProvider(api_key=settings.api_key, base_url=base_url, model=model)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -4046,7 +4150,9 @@ Si le besoin évolue (ex: **plusieurs destinataires**), les étapes suivantes so
|
|||||||
| `XMPP_PORT` | Port XMPP (5222 pour STARTTLS, 5223 pour TLS direct). | `5222` | `int` | ❌ Non |
|
| `XMPP_PORT` | Port XMPP (5222 pour STARTTLS, 5223 pour TLS direct). | `5222` | `int` | ❌ Non |
|
||||||
| `XMPP_TO` | Destinataire unique (ex: `parent@exemple.org`). | `None` | `str` | ✅ Oui |
|
| `XMPP_TO` | Destinataire unique (ex: `parent@exemple.org`). | `None` | `str` | ✅ Oui |
|
||||||
| `XMPP_RESOURCE` | Ressource XMPP (ex: `pronote-sync`). | `"pronote-sync"` | `str` | ❌ Non |
|
| `XMPP_RESOURCE` | Ressource XMPP (ex: `pronote-sync`). | `"pronote-sync"` | `str` | ❌ Non |
|
||||||
| `XMPP_USE_TLS` | Utiliser TLS pour la connexion. | `True` | `bool` | ❌ Non |
|
| `XMPP_TLS_MODE` | Mode TLS : `starttls` (défaut), `direct` ou `disabled` (localhost uniquement). | `"starttls"` | `Literal` | ❌ Non |
|
||||||
|
| `XMPP_CONNECT_TIMEOUT` | Timeout de connexion (secondes). | `15` | `float` | ❌ Non |
|
||||||
|
| `XMPP_CLEANUP_TIMEOUT` | Timeout de nettoyage (secondes). | `10` | `float` | ❌ Non |
|
||||||
| `XMPP_TIMEOUT` | Timeout de connexion (secondes). | `30` | `int` | ❌ Non |
|
| `XMPP_TIMEOUT` | Timeout de connexion (secondes). | `30` | `int` | ❌ Non |
|
||||||
|
|
||||||
**⚠️ Notes** :
|
**⚠️ Notes** :
|
||||||
@@ -4054,6 +4160,7 @@ Si le besoin évolue (ex: **plusieurs destinataires**), les étapes suivantes so
|
|||||||
- **Pas de variables PubSub** : `XMPP_PUBSUB_NODE`, `XMPP_ROOM`, `XMPP_SUBSCRIBERS` **ne doivent pas être introduites** pour l'instant.
|
- **Pas de variables PubSub** : `XMPP_PUBSUB_NODE`, `XMPP_ROOM`, `XMPP_SUBSCRIBERS` **ne doivent pas être introduites** pour l'instant.
|
||||||
- **Sécurité** : `XMPP_JID`, `XMPP_PASSWORD` et `XMPP_TO` **ne doivent jamais apparaître** dans les logs, erreurs ou fixtures.
|
- **Sécurité** : `XMPP_JID`, `XMPP_PASSWORD` et `XMPP_TO` **ne doivent jamais apparaître** dans les logs, erreurs ou fixtures.
|
||||||
- **Standardisation** : `XMPP_TO` est mappé sur le champ `to` dans le modèle Pydantic.
|
- **Standardisation** : `XMPP_TO` est mappé sur le champ `to` dans le modèle Pydantic.
|
||||||
|
- **`XMPP_USE_TLS` obsolète** : remplacée par `XMPP_TLS_MODE` (`starttls`, `direct` ou `disabled`) ; `XMPP_USE_TLS=true` → `direct` et `XMPP_USE_TLS=false` → `starttls`, avec un `DeprecationWarning`.
|
||||||
|
|
||||||
#### 10.2.2 Exemple de configuration dans `.env`
|
#### 10.2.2 Exemple de configuration dans `.env`
|
||||||
|
|
||||||
@@ -4066,13 +4173,19 @@ XMPP_HOST=exemple.org
|
|||||||
XMPP_PORT=5222
|
XMPP_PORT=5222
|
||||||
XMPP_TO=parent@exemple.org
|
XMPP_TO=parent@exemple.org
|
||||||
XMPP_RESOURCE=pronote-digest
|
XMPP_RESOURCE=pronote-digest
|
||||||
XMPP_USE_TLS=true
|
# XMPP_TLS_MODE : "starttls" (défaut), "direct" (port 5223) ou "disabled" (localhost uniquement)
|
||||||
|
XMPP_TLS_MODE=starttls
|
||||||
|
# XMPP_USE_TLS=true/false est obsolète mais encore supporté (alias de XMPP_TLS_MODE)
|
||||||
|
XMPP_CONNECT_TIMEOUT=15
|
||||||
|
XMPP_CLEANUP_TIMEOUT=10
|
||||||
XMPP_TIMEOUT=30
|
XMPP_TIMEOUT=30
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 10.2.3 Modèle Pydantic pour la configuration XMPP
|
#### 10.2.3 Modèle Pydantic pour la configuration XMPP
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import SecretStr, Field
|
from pydantic import SecretStr, Field
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
@@ -4098,14 +4211,22 @@ class XmppSettings(BaseSettings):
|
|||||||
port: int = Field(default=5222, ge=1, le=65535)
|
port: int = Field(default=5222, ge=1, le=65535)
|
||||||
to: str | None = None
|
to: str | None = None
|
||||||
resource: str = "pronote-sync"
|
resource: str = "pronote-sync"
|
||||||
use_tls: bool = True
|
tls_mode: Literal["direct", "starttls", "disabled"] = "starttls"
|
||||||
timeout: int = Field(default=30, gt=0)
|
use_tls: bool | None = Field(
|
||||||
|
default=None,
|
||||||
|
deprecated="Utiliser tls_mode à la place (XMPP_USE_TLS obsolète).",
|
||||||
|
)
|
||||||
|
timeout: float = Field(default=30, gt=0)
|
||||||
|
connect_timeout: float = Field(default=15, gt=0)
|
||||||
|
cleanup_timeout: float = Field(default=10, gt=0)
|
||||||
```
|
```
|
||||||
|
|
||||||
> **⚠️ Mapping TLS** :
|
> **⚠️ Mapping TLS** :
|
||||||
> - `use_tls=True` → **TLS direct** (port 5223, `enable_direct_tls=True`, `enable_starttls=False`).
|
> - `tls_mode="starttls"` (défaut) → **STARTTLS** (port 5222, `enable_starttls=True`, `enable_direct_tls=False`).
|
||||||
> - `use_tls=False` → **STARTTLS** (port 5222, `enable_starttls=True`, `enable_direct_tls=False`).
|
> - `tls_mode="direct"` → **TLS direct** (port 5223, `enable_direct_tls=True`, `enable_starttls=False`).
|
||||||
> La validation refuse `use_tls=False` si `host` n'est pas un hôte de boucle locale (`localhost`, `127.0.0.1`, `::1`).
|
> - `tls_mode="disabled"` → TLS désactivé ; **uniquement** autorisé sur un hôte de boucle locale (`localhost`, `127.0.0.1`, `::1`).
|
||||||
|
> - `XMPP_USE_TLS` (`use_tls`) est **obsolète** : `use_tls=True` → `tls_mode="direct"` et
|
||||||
|
> `use_tls=False` → `tls_mode="starttls"` (avec `DeprecationWarning`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -71,10 +71,11 @@ The following variables can be safely pre-configured in `/etc/pronote-sync/prono
|
|||||||
- `THEORETICAL_WEEK_ANCHOR_DATE`, `THEORETICAL_WEEK_ANCHOR_TYPE`
|
- `THEORETICAL_WEEK_ANCHOR_DATE`, `THEORETICAL_WEEK_ANCHOR_TYPE`
|
||||||
|
|
||||||
- **XMPP:**
|
- **XMPP:**
|
||||||
- `XMPP_ENABLED`, `XMPP_HOST`, `XMPP_PORT`, `XMPP_USE_TLS`, `XMPP_TIMEOUT`, `XMPP_RESOURCE`
|
- `XMPP_ENABLED`, `XMPP_HOST`, `XMPP_PORT`, `XMPP_TLS_MODE`, `XMPP_TIMEOUT`, `XMPP_CONNECT_TIMEOUT`, `XMPP_CLEANUP_TIMEOUT`, `XMPP_RESOURCE`
|
||||||
|
- `XMPP_USE_TLS` is deprecated but still supported (aliased to `XMPP_TLS_MODE`)
|
||||||
|
|
||||||
- **AI:**
|
- **AI:**
|
||||||
- `AI_ENABLED`, `AI_PROVIDER`, `AI_BASE_URL`, `AI_MODEL`, `AI_ALLOW_INSECURE_HTTP`
|
- `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:**
|
||||||
- `BLOG_ENABLED`, `BLOG_RSS_URL`
|
- `BLOG_ENABLED`, `BLOG_RSS_URL`
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
% =============================================================================
|
||||||
|
% ANNEXE B : TABLEAU COMPARATIF SYNTHÉTIQUE
|
||||||
|
% =============================================================================
|
||||||
|
|
||||||
|
\chapter{Tableau comparatif synthétique}
|
||||||
|
\label{annex:comparison}
|
||||||
|
|
||||||
|
\begin{table}[htbp]
|
||||||
|
\centering
|
||||||
|
\caption{Comparatif des méthodes d'authentification}
|
||||||
|
\label{tab:comparison}
|
||||||
|
\begin{tabularx}{\textwidth}{L{2cm} L{2.5cm} L{2.5cm} L{2cm} L{2cm} L{2.5cm}}
|
||||||
|
\toprule
|
||||||
|
\textbf{Méthode} & \textbf{Périmètre} & \textbf{Identifiants} & \textbf{Expiration} & \textbf{Complexité} & \textbf{Statut dans \texttt{pronote-sync}} \\
|
||||||
|
\midrule
|
||||||
|
|
||||||
|
\textbf{URL iCal sécurisée} & EDT + devoirs (si inclus dans le flux) & Jeton URL & Longue durée (dépend de l'instance) & Très faible & \emojicheck\ Pris en charge \\
|
||||||
|
|
||||||
|
\textbf{Connexion directe} (mot de passe + ENT) & Agenda/EDT, devoirs, messages & Identifiant + mot de passe + ENT & Session dépendante de l'instance & Élevée & \emojicheck\ Pris en charge \\
|
||||||
|
|
||||||
|
\textbf{QR Code + token mobile} & Agenda/EDT, devoirs, messages & PIN 4 chiffres \rightarrow token + UUID & QR ~10 min ({\warningicon\ hypothèse}) ; token dépendant de l'instance & Modérée & \emojicheck\ Pris en charge \\
|
||||||
|
|
||||||
|
\textbf{SSO ENT (CAS/SAML)} & — & Identifiants ENT & Session ENT & Très élevée & \emojicross\ Non implémenté (seuls les 30 ENT de la liste fermée \texttt{\_ENT\_NAMES} sont supportés via \texttt{pronotepy}) \\
|
||||||
|
|
||||||
|
\textbf{EduConnect} & — & Identifiants nationaux & Session EduConnect & Très élevée & \emojicross\ Non implémenté \\
|
||||||
|
|
||||||
|
\textbf{CAS direct} & — & Identifiants CAS & Ticket single-use & Modérée & \emojicross\ Non implémenté \\
|
||||||
|
|
||||||
|
\textbf{API publique} & N/A & N/A & N/A & N/A & \emojicross\ Inexistante \\
|
||||||
|
|
||||||
|
\bottomrule
|
||||||
|
\end{tabularx}
|
||||||
|
\end{table}
|
||||||
|
|
||||||
|
\section*{Légende}
|
||||||
|
\addcontentsline{toc}{section}{Légende}
|
||||||
|
|
||||||
|
\begin{itemize}
|
||||||
|
\item \textbf{Périmètre} : Les méthodes \textbf{iCal} sont en lecture seule (EDT + devoirs si inclus
|
||||||
|
dans le flux). Les méthodes \textbf{Connexion directe} et \textbf{QR Code + token mobile}
|
||||||
|
permettent les opérations \texttt{pronotepy} effectivement implémentées par ce projet (agenda,
|
||||||
|
devoirs, messages ; informations ignorées en mode \texttt{qr\_token}).
|
||||||
|
\item \textbf{QR ~10 min} : {\warningicon\ \textbf{Hypothèse non vérifiée}} (observé dans
|
||||||
|
\texttt{pronotepy} via un message d'exception, dépend de l'instance).
|
||||||
|
\item \textbf{Session dépendante de l'instance} : {\warningicon\ \textbf{Hypothèse non vérifiée}}
|
||||||
|
(nécessite un essai réel).
|
||||||
|
\end{itemize}
|
||||||
|
|
||||||
|
% =============================================================================
|
||||||
|
% FIN DE L'ANNEXE
|
||||||
|
% =============================================================================
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
% =============================================================================
|
||||||
|
% ANNEXE C : ÉCARTS ET NOTES DE COHÉRENCE
|
||||||
|
% =============================================================================
|
||||||
|
|
||||||
|
\chapter{Écarts et notes de cohérence}
|
||||||
|
\label{annex:ecarts}
|
||||||
|
|
||||||
|
\section{Alignement avec \texttt{.env.example}}
|
||||||
|
\label{sec:alignement-env}
|
||||||
|
|
||||||
|
\begin{table}[htbp]
|
||||||
|
\centering
|
||||||
|
\caption{Alignement avec \texttt{.env.example}}
|
||||||
|
\label{tab:alignement-env}
|
||||||
|
\begin{tabularx}{\textwidth}{L{4.2cm} L{3.2cm} L{4.4cm} L{2.2cm}}
|
||||||
|
\toprule
|
||||||
|
\textbf{Paramètre} & \textbf{Document} & \textbf{Code} & \textbf{Statut} \\
|
||||||
|
\midrule
|
||||||
|
|
||||||
|
\texttt{PRONOTE\_ICAL\_URL} & \emojicheck\ Lignes 2, 42--50 & \emojicheck\ \texttt{sources/ical.py} & \textbf{Cohérent} \\
|
||||||
|
|
||||||
|
\texttt{PRONOTE\_URL} & \emojicheck\ Ligne 3 & \emojicheck\ \texttt{client.py} (ligne 294) & \textbf{Cohérent} \\
|
||||||
|
|
||||||
|
\texttt{PRONOTE\_ENT} & \emojicheck\ Ligne 7 & \emojicheck\ \texttt{client.py} (ligne 297, \texttt{\_ENT\_NAMES}) & \textbf{Cohérent} \\
|
||||||
|
|
||||||
|
\texttt{PRONOTE\_AUTH\_MODE=qr\_token} & \emojicheck\ Ligne 23 & \emojicheck\ \texttt{client.py} (ligne 334) & \textbf{Cohérent} \\
|
||||||
|
|
||||||
|
\texttt{PRONOTE\_QR\_CODE\_FILE} & \emojicheck\ Ligne 24 & \emojicheck\ \texttt{client.py} (ligne 387) & \textbf{Cohérent} \\
|
||||||
|
|
||||||
|
\texttt{PRONOTE\_QR\_PIN} & \emojicheck\ Ligne 25 & \emojicheck\ \texttt{client.py} (ligne 388) & \textbf{Cohérent} \\
|
||||||
|
|
||||||
|
\emph{« Le QR code se génère sur le site web »} & \emojicheck\ Ligne 21 & \emojicheck\ Section « Procédure QR pour \texttt{pronote-sync} » & \textbf{Cohérent} \\
|
||||||
|
|
||||||
|
\bottomrule
|
||||||
|
\end{tabularx}
|
||||||
|
\end{table}
|
||||||
|
|
||||||
|
\section{Écarts avec \texttt{docs/exploitation.md}}
|
||||||
|
\label{sec:ecarts-exploitation}
|
||||||
|
|
||||||
|
\begin{table}[htbp]
|
||||||
|
\centering
|
||||||
|
\caption{Écarts avec \texttt{docs/exploitation.md}}
|
||||||
|
\label{tab:ecarts-exploitation}
|
||||||
|
\begin{tabularx}{\textwidth}{L{4.2cm} L{2.6cm} L{5cm} L{2.6cm}}
|
||||||
|
\toprule
|
||||||
|
\textbf{Élément} & \textbf{\texttt{exploitation.md}} & \textbf{Ce document} & \textbf{Action} \\
|
||||||
|
\midrule
|
||||||
|
|
||||||
|
\emph{« Le QR code expire \textasciitilde{}10 minutes »} & Ligne 22 & \warningicon\ \textbf{Hypothèse non vérifiée} (section « Cycle de vie des tokens ») & \textbf{Aucune} (hors périmètre). \\
|
||||||
|
|
||||||
|
\emph{« Mode \texttt{qr\_token} incompatible avec dry-run »} & Lignes 65--66 & \emojicheck\ Section « Incompatibilités » & \textbf{Cohérent} \\
|
||||||
|
|
||||||
|
\emph{« Fichier \texttt{.pronote\_auth\_state.json} (mode 0600) »} & Lignes 70--75 & \emojicheck\ Section « Sécurité » & \textbf{Cohérent} \\
|
||||||
|
|
||||||
|
\bottomrule
|
||||||
|
\end{tabularx}
|
||||||
|
\end{table}
|
||||||
|
|
||||||
|
% =============================================================================
|
||||||
|
% FIN DE L'ANNEXE
|
||||||
|
% =============================================================================
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
% =============================================================================
|
||||||
|
% ANNEXE A : BIBLIOTHÈQUES TIERCES
|
||||||
|
% =============================================================================
|
||||||
|
|
||||||
|
\chapter{Bibliothèques tierces}
|
||||||
|
\label{annex:libraries}
|
||||||
|
|
||||||
|
\begin{table}[htbp]
|
||||||
|
\centering
|
||||||
|
\caption{Bibliothèques tierces et leur statut dans \texttt{pronote-sync}}
|
||||||
|
\label{tab:libraries}
|
||||||
|
\begin{tabularx}{\textwidth}{L{2.5cm} L{1.5cm} L{3cm} L{3cm} L{2.5cm}}
|
||||||
|
\toprule
|
||||||
|
\textbf{Bibliothèque} & \textbf{Langage} & \textbf{Dépôt} & \textbf{Méthodes supportées} & \textbf{Statut dans \texttt{pronote-sync}} \\
|
||||||
|
\midrule
|
||||||
|
|
||||||
|
\textbf{pronotepy} & Python & \url{https://github.com/bain3/pronotepy} & Connexion directe, QR code/token, \textbf{30 ENT} (liste fermée) & \emojicheck\ \textbf{Dépendance principale} (version \textbf{2.15.7} vérifiée). \\
|
||||||
|
|
||||||
|
\textbf{pronote-api} & TypeScript & \url{https://github.com/Litarvan/pronote-api} & Connexion directe, CAS, ENT & \emojicross\ Non utilisée. \\
|
||||||
|
|
||||||
|
\textbf{pronote-qrcode-api} & JavaScript & \url{https://github.com/Androz2091/pronote-qrcode-api} & Déchiffrement QR code & \emojicross\ Non utilisée (intégration native via \texttt{pronotepy}). \\
|
||||||
|
|
||||||
|
\bottomrule
|
||||||
|
\end{tabularx}
|
||||||
|
\end{table}
|
||||||
|
|
||||||
|
\begin{infobox}
|
||||||
|
\textbf{Source} : {\emojiinfo\ Observé dans \texttt{pyproject.toml}} (dépendances) + code source local
|
||||||
|
(2026-09-12).
|
||||||
|
\end{infobox}
|
||||||
|
|
||||||
|
% =============================================================================
|
||||||
|
% FIN DE L'ANNEXE
|
||||||
|
% =============================================================================
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
% =============================================================================
|
||||||
|
% CHAPITRE : INTRODUCTION
|
||||||
|
% =============================================================================
|
||||||
|
|
||||||
|
\chapter{Introduction}
|
||||||
|
\label{ch:introduction}
|
||||||
|
|
||||||
|
Ce document documente \textbf{uniquement les méthodes d'authentification prises en charge ou
|
||||||
|
délégables par \texttt{pronote-sync}}, en alignement strict avec :
|
||||||
|
|
||||||
|
\begin{itemize}
|
||||||
|
\item Le code source de \texttt{pronote\_sync/sources/pronote/client.py} (liste fermée
|
||||||
|
\texttt{\_ENT\_NAMES}, gestion des tokens).
|
||||||
|
\item La bibliothèque \texttt{pronotepy} \textbf{2.15.7} (contrat des méthodes
|
||||||
|
\texttt{qrcode\_login}, \texttt{token\_login}, \texttt{export\_credentials}).
|
||||||
|
\item Les paramètres de configuration \texttt{.env.example} (lignes 21–25).
|
||||||
|
\end{itemize}
|
||||||
|
|
||||||
|
\section{Périmètre}
|
||||||
|
|
||||||
|
\begin{itemize}
|
||||||
|
\item {\emojicheck\ \textbf{Pris en charge}} : Méthodes implémentées et testées dans
|
||||||
|
\texttt{pronote-sync}.
|
||||||
|
\item {\emojiinfo\ \textbf{Délégable à pronotepy}} : Méthodes gérées par \texttt{pronotepy}
|
||||||
|
mais non directement exposées par \texttt{pronote-sync}.
|
||||||
|
\item {\emojicross\ \textbf{Non implémenté}} : Méthodes non supportées (ex. CAS/SAML
|
||||||
|
générique, EduConnect direct).
|
||||||
|
\end{itemize}
|
||||||
|
|
||||||
|
\section{Public cible}
|
||||||
|
|
||||||
|
Développeurs et intégrateurs de \texttt{pronote-sync}.
|
||||||
|
|
||||||
|
% =============================================================================
|
||||||
|
% FIN DU CHAPITRE
|
||||||
|
% =============================================================================
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
% =============================================================================
|
||||||
|
% CHAPITRE : MÉTHODE 1 — URL ICAL SÉCURISÉE
|
||||||
|
% =============================================================================
|
||||||
|
|
||||||
|
\chapter{Méthode 1 : URL iCal sécurisée (\texttt{icalsecurise})}
|
||||||
|
\label{ch:method1}
|
||||||
|
|
||||||
|
\section{Principe général}
|
||||||
|
|
||||||
|
Pronote expose un flux de calendrier en lecture seule conforme à la norme
|
||||||
|
\textbf{iCalendar (RFC 5545)}. L'accès est contrôlé par un \textbf{jeton secret} intégré dans
|
||||||
|
l'URL sous forme de paramètre de requête \texttt{icalsecurise}.
|
||||||
|
|
||||||
|
\begin{infobox}
|
||||||
|
\textbf{Statut} : {\emojiinfo\ Observé localement} (fonctionnalité native de Pronote, version non spécifiée).
|
||||||
|
\end{infobox}
|
||||||
|
|
||||||
|
\section{Obtention du jeton}
|
||||||
|
|
||||||
|
\begin{enumerate}
|
||||||
|
\item Se connecter à Pronote (Espace Parents ou Élève) via n'importe quelle méthode.
|
||||||
|
\item Accéder à la vue \guillemotleft Emploi du temps \guillemotright.
|
||||||
|
\item Utiliser la fonction \guillemotleft Export iCal \guillemotright ou \guillemotleft Exporter \guillemotright.
|
||||||
|
\item Pronote génère une URL contenant un jeton secret \texttt{icalsecurise}.
|
||||||
|
\item \textbf{Copier cette URL} : elle constitue une crédentiale unique sensible qui doit être
|
||||||
|
protégée comme un mot de passe.
|
||||||
|
\end{enumerate}
|
||||||
|
|
||||||
|
\begin{infobox}
|
||||||
|
\textbf{Source} : {\emojiinfo\ Observé localement dans les interfaces Pronote} (version non spécifiée,
|
||||||
|
hypothèse à valider).
|
||||||
|
|
||||||
|
\textbf{Note} : Les étapes dépendent de l'instance et du type de compte. L'exemple ci-dessus est
|
||||||
|
non fonctionnel et ne contient aucun secret.
|
||||||
|
\end{infobox}
|
||||||
|
|
||||||
|
\section{Format de l'URL}
|
||||||
|
|
||||||
|
\begin{lstlisting}[language=bash, caption=Format d'URL iCal Pronote, label=lst:ical-url]
|
||||||
|
https://{etablissement}.index-education.net/pronote/ical/Edt_{prenom}.ics?icalsecurise={jeton}&version={version}¶m={param}
|
||||||
|
\end{lstlisting}
|
||||||
|
|
||||||
|
\begin{itemize}
|
||||||
|
\item \texttt{icalsecurise} : \textbf{Jeton secret} (crédentiale).
|
||||||
|
\item \texttt{version} : Version de Pronote (ex. \texttt{2024}).
|
||||||
|
\item \texttt{param} : Paramètres optionnels.
|
||||||
|
\end{itemize}
|
||||||
|
|
||||||
|
\begin{infobox}
|
||||||
|
\textbf{Source} : {\emojiinfo\ Analysé via \texttt{pronote\_sync/sources/ical.py}}.
|
||||||
|
\end{infobox}
|
||||||
|
|
||||||
|
\section{Cycle de vie}
|
||||||
|
|
||||||
|
\begin{itemize}
|
||||||
|
\item \textbf{Pérennité} : Le jeton reste valide jusqu'à :
|
||||||
|
\begin{itemize}
|
||||||
|
\item Révocation manuelle par l'utilisateur dans Pronote.
|
||||||
|
\item Régénération par l'établissement (ex. à la rentrée scolaire).
|
||||||
|
\end{itemize}
|
||||||
|
\item {\warningicon\ \textbf{Hypothèse à valider}} : La durée exacte dépend des politiques
|
||||||
|
de l'établissement (non documentée officiellement).
|
||||||
|
\end{itemize}
|
||||||
|
|
||||||
|
\begin{infobox}
|
||||||
|
\textbf{Source} : {\warningicon\ Comportement variable selon les instances} (à tester localement).
|
||||||
|
\end{infobox}
|
||||||
|
|
||||||
|
\section{Sécurité}
|
||||||
|
|
||||||
|
\begin{enumerate}
|
||||||
|
\item \textbf{Surface d'attaque} : Le jeton est encodé dans l'URL \rightarrow risque d'exposition via :
|
||||||
|
\begin{itemize}
|
||||||
|
\item Logs serveur/proxy.
|
||||||
|
\item En-tête \texttt{Referer}.
|
||||||
|
\item Historique du navigateur.
|
||||||
|
\end{itemize}
|
||||||
|
\item \textbf{Recommandations} :
|
||||||
|
\begin{itemize}
|
||||||
|
\item \textbf{Ne jamais versionner} l'URL (ex. dans \texttt{.env} ou Git).
|
||||||
|
\item Utiliser \textbf{HTTPS} (obligatoire).
|
||||||
|
\item Masquer l'URL dans les logs (ex. via \texttt{redact\_url()} dans
|
||||||
|
\texttt{pronote-sync}).
|
||||||
|
\end{itemize}
|
||||||
|
\end{enumerate}
|
||||||
|
|
||||||
|
\begin{infobox}
|
||||||
|
\textbf{Source} : {\warningicon\ Recommandation du projet} (inspirée des bonnes pratiques générales
|
||||||
|
de sécurité).
|
||||||
|
\end{infobox}
|
||||||
|
|
||||||
|
\section{Intégration dans \texttt{pronote-sync}}
|
||||||
|
|
||||||
|
\begin{itemize}
|
||||||
|
\item \textbf{Paramètre} : \texttt{PRONOTE\_ICAL\_URL} (ex. \texttt{.env.example} ligne 2).
|
||||||
|
\item \textbf{Comportement} :
|
||||||
|
\begin{itemize}
|
||||||
|
\item Prioritaire en mode \texttt{PRONOTE\_AGENDA\_SOURCE=auto}.
|
||||||
|
\item Si l'URL est invalide ou expire, repli automatique vers \texttt{pronotepy} (si
|
||||||
|
\texttt{PRONOTE\_AGENDA\_SOURCE=auto}).
|
||||||
|
\item \textbf{Aucun repli} si \texttt{PRONOTE\_AGENDA\_SOURCE=ical} (échec explicite).
|
||||||
|
\end{itemize}
|
||||||
|
\end{itemize}
|
||||||
|
|
||||||
|
\begin{infobox}
|
||||||
|
\textbf{Source} : {\emojiinfo\ \texttt{pronote\_sync/config/settings.py} +
|
||||||
|
\texttt{pronote\_sync/sources/ical.py}}.
|
||||||
|
\end{infobox}
|
||||||
|
|
||||||
|
% =============================================================================
|
||||||
|
% FIN DU CHAPITRE
|
||||||
|
% =============================================================================
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
% =============================================================================
|
||||||
|
% CHAPITRE : MÉTHODE 2 — CONNEXION DIRECTE
|
||||||
|
% =============================================================================
|
||||||
|
|
||||||
|
\chapter{Méthode 2 : Connexion directe (identifiant / mot de passe + ENT)}
|
||||||
|
\label{ch:method2}
|
||||||
|
|
||||||
|
\section{Principe général}
|
||||||
|
|
||||||
|
Connexion via le protocole propriétaire de Pronote (JSON sur HTTPS), avec
|
||||||
|
\textbf{chiffrement AES-CBC utilisant une clé dérivée par MD5 (16 octets, soit AES-128)} comme
|
||||||
|
implémenté dans pronotepy 2.15.7 et mécanisme de défi-réponse. Utilisé par l'interface web.
|
||||||
|
|
||||||
|
\begin{infobox}
|
||||||
|
\textbf{Statut} : {\emojiinfo\ Observé dans le code local} (délégable à pronotepy via
|
||||||
|
\texttt{ParentClient} ou \texttt{Client}).
|
||||||
|
\end{infobox}
|
||||||
|
|
||||||
|
\section{Flux d'authentification}
|
||||||
|
|
||||||
|
\begin{enumerate}
|
||||||
|
\item \textbf{Initialisation} : Requête GET vers \texttt{/pronote/\{espace\}.html} (ex.
|
||||||
|
\texttt{parent.html}).
|
||||||
|
\begin{itemize}
|
||||||
|
\item Récupère \texttt{h} (ID de session), \texttt{a} (ID espace), \texttt{sCrA}/\texttt{sCoA}
|
||||||
|
(drapeaux chiffrement/compression).
|
||||||
|
\end{itemize}
|
||||||
|
\item \textbf{Échange de clés} : Requête POST vers
|
||||||
|
\texttt{/pronote/appelfonction/\{a\}/\{h\}/\{numeroOrdre\}}.
|
||||||
|
\item \textbf{Identification} : Soumission de \texttt{identifiant},
|
||||||
|
\texttt{genreConnexion=0}, \texttt{genreEspace=\{a\}}.
|
||||||
|
\item \textbf{Résolution du défi} :
|
||||||
|
\begin{itemize}
|
||||||
|
\item Calcul de \texttt{mtp = MAJUSCULE(HEX(SHA256(alea + mot\_de\_passe)))}.
|
||||||
|
\item Dérivation de \texttt{key\_challenge = MD5(nom\_utilisateur + mtp)}.
|
||||||
|
\item Déchiffrement du \texttt{challenge} avec
|
||||||
|
\textbf{AES-CBC utilisant une clé dérivée par MD5 (16 octets, soit AES-128)}.
|
||||||
|
\end{itemize}
|
||||||
|
\item \textbf{Authentification} : Soumission de la réponse au défi.
|
||||||
|
\end{enumerate}
|
||||||
|
|
||||||
|
\begin{infobox}
|
||||||
|
\textbf{Source} : {\emojiinfo\ Observé dans le code local de \texttt{pronotepy} 2.15.7} (module
|
||||||
|
\texttt{clients.py} et \texttt{pronoteAPI.py}, vérifié le 2026-09-12).
|
||||||
|
\end{infobox}
|
||||||
|
|
||||||
|
\section{Intégration dans \texttt{pronote-sync}}
|
||||||
|
|
||||||
|
\begin{itemize}
|
||||||
|
\item \textbf{Paramètres} :
|
||||||
|
\begin{itemize}
|
||||||
|
\item \texttt{PRONOTE\_URL} (ex. \texttt{.env.example} ligne 3).
|
||||||
|
\item \texttt{PRONOTE\_USERNAME}, \texttt{PRONOTE\_PASSWORD}.
|
||||||
|
\item \texttt{PRONOTE\_ENT} (slug dans \texttt{\_ENT\_NAMES}).
|
||||||
|
\item \texttt{PRONOTE\_ACCOUNT\_TYPE} (ex. \texttt{parent}).
|
||||||
|
\end{itemize}
|
||||||
|
\item \textbf{Comportement} :
|
||||||
|
\begin{itemize}
|
||||||
|
\item Utilise \texttt{pronotepy.ParentClient} pour les comptes parents.
|
||||||
|
\item \textbf{Repli} : Si iCal échoue en mode \texttt{auto}, \texttt{pronotepy} est utilisé.
|
||||||
|
\item \textbf{Pas de repli} si \texttt{PRONOTE\_AGENDA\_SOURCE=pronotepy} (échec explicite).
|
||||||
|
\end{itemize}
|
||||||
|
\end{itemize}
|
||||||
|
|
||||||
|
\begin{infobox}
|
||||||
|
\textbf{Source} : {\emojiinfo\ \texttt{pronote\_sync/sources/pronote/client.py}} (méthode
|
||||||
|
\texttt{\_connect\_password}).
|
||||||
|
\end{infobox}
|
||||||
|
|
||||||
|
\section{ENT supportés}
|
||||||
|
|
||||||
|
Liste \textbf{fermée} des \textbf{30 ENT} résolubles (lignes 52–83 de
|
||||||
|
\texttt{pronote\_sync/sources/pronote/client.py}) :
|
||||||
|
|
||||||
|
\begin{lstlisting}[language=Python, caption=Liste des ENT supportés, label=lst:ent-list]
|
||||||
|
_monbureaunumerique, ent_elyco, bordeaux, ent_creuse, occitanie_montpellier, ...
|
||||||
|
\end{lstlisting}
|
||||||
|
|
||||||
|
\begin{warningbox}
|
||||||
|
\textbf{Hypothèse à valider} : Les ENT non listés nécessitent une contribution à
|
||||||
|
\texttt{pronotepy}.
|
||||||
|
\end{warningbox}
|
||||||
|
|
||||||
|
\begin{infobox}
|
||||||
|
\textbf{Source} : {\emojiinfo\ Code source local de \texttt{pronote-sync}} (2026-09-12).
|
||||||
|
\end{infobox}
|
||||||
|
|
||||||
|
% =============================================================================
|
||||||
|
% FIN DU CHAPITRE
|
||||||
|
% =============================================================================
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
% =============================================================================
|
||||||
|
% CHAPITRE : MÉTHODE 3 — QR CODE + TOKEN MOBILE
|
||||||
|
% =============================================================================
|
||||||
|
|
||||||
|
\chapter{Méthode 3 : QR Code + token mobile (pour \texttt{pronote-sync})}
|
||||||
|
\label{ch:method3}
|
||||||
|
|
||||||
|
\section{Principe général}
|
||||||
|
|
||||||
|
Mécanisme d'appairage par QR code pour les appareils mobiles, \textbf{contournant
|
||||||
|
l'authentification ENT/EduConnect}. Le QR code est généré \textbf{depuis l'interface web
|
||||||
|
Pronote, espace parent \rightarrow paramètres \rightarrow QR code}, puis utilisé avec un
|
||||||
|
\textbf{PIN à 4 chiffres} pour obtenir un token dont la durée dépend de l'instance/serveur.
|
||||||
|
|
||||||
|
\begin{infobox}
|
||||||
|
\textbf{Statut} : {\emojicheck\ Pris en charge} par \texttt{pronote-sync} (mode
|
||||||
|
\texttt{PRONOTE\_AUTH\_MODE=qr\_token}).
|
||||||
|
\end{infobox}
|
||||||
|
|
||||||
|
\section{Procédure QR pour \texttt{pronote-sync}}
|
||||||
|
\label{sec:procedure-qr}
|
||||||
|
|
||||||
|
\subsection{Étape 1 : Génération du QR code (interface web)}
|
||||||
|
|
||||||
|
\begin{enumerate}
|
||||||
|
\item Se connecter à Pronote via un navigateur (espace \textbf{Parent}).
|
||||||
|
\item Aller dans \textbf{Paramètres \rightarrow Accès mobile / Application mobile}.
|
||||||
|
\item Définir un \textbf{PIN temporaire à 4 chiffres}.
|
||||||
|
\item Pronote affiche un \textbf{QR code} contenant un JSON avec les clés :
|
||||||
|
\begin{itemize}
|
||||||
|
\item \texttt{login}, \texttt{jeton}, et \texttt{url} (ex.
|
||||||
|
\texttt{https://[host]/pronote/mobile.parent.html}).
|
||||||
|
\end{itemize}
|
||||||
|
\textbf{Le fichier JSON réel contient des identifiants chiffrés et ne doit jamais être
|
||||||
|
copié, partagé, ou committé.}
|
||||||
|
\item \textbf{Exporter le QR code} :
|
||||||
|
\begin{itemize}
|
||||||
|
\item Sauvegardez le fichier JSON localement ou scannez-le avec un appareil.
|
||||||
|
\item \textbf{Ne jamais partager} le JSON ou le PIN.
|
||||||
|
\item \textbf{Le fichier JSON du QR code contient des identifiants chiffrés : ne jamais
|
||||||
|
le coller dans la documentation ni le committer.}
|
||||||
|
\end{itemize}
|
||||||
|
\end{enumerate}
|
||||||
|
|
||||||
|
\begin{infobox}
|
||||||
|
\textbf{Source} : {\emojiinfo\ Observé localement dans l'interface web Pronote} (version non
|
||||||
|
spécifiée, hypothèse à valider).
|
||||||
|
\end{infobox}
|
||||||
|
|
||||||
|
\subsection{Étape 2 : Configuration de \texttt{pronote-sync}}
|
||||||
|
|
||||||
|
\begin{enumerate}
|
||||||
|
\item \textbf{Enregistrer le QR code} :
|
||||||
|
\begin{itemize}
|
||||||
|
\item Sauvegarder le JSON dans un fichier (ex. \texttt{/path/to/qr\_code.json}).
|
||||||
|
\item \textbf{Permissions} : \texttt{chmod 600 /path/to/qr\_code.json}.
|
||||||
|
\end{itemize}
|
||||||
|
\item \textbf{Configurer \texttt{.env}} :
|
||||||
|
\begin{lstlisting}[language=bash, caption=Configuration QR Code dans .env, label=lst:qr-env]
|
||||||
|
PRONOTE_AUTH_MODE=qr_token
|
||||||
|
PRONOTE_QR_CODE_FILE=/path/to/qr_code.json
|
||||||
|
PRONOTE_QR_PIN= # renseigner localement la valeur secrète du PIN (jamais committée)
|
||||||
|
\end{lstlisting}
|
||||||
|
\textbf{Note} : \texttt{.env.example} ne doit \textbf{jamais} contenir de PIN concret.
|
||||||
|
\end{enumerate}
|
||||||
|
|
||||||
|
\begin{infobox}
|
||||||
|
\textbf{Source} : {\emojiinfo\ Observé dans \texttt{.env.example}} (lignes 21–25) et
|
||||||
|
\texttt{pronote\_sync/sources/pronote/client.py}.
|
||||||
|
\end{infobox}
|
||||||
|
|
||||||
|
\subsection{Étape 3 : Premier login (enrôlement)}
|
||||||
|
|
||||||
|
\begin{enumerate}
|
||||||
|
\item \texttt{pronote-sync} lit \texttt{PRONOTE\_QR\_CODE\_FILE} et \texttt{PRONOTE\_QR\_PIN}.
|
||||||
|
\item Appel à \texttt{pronotepy.ParentClient.qrcode\_login(qr\_code, pin, uuid)} :
|
||||||
|
\begin{itemize}
|
||||||
|
\item \texttt{qr\_code} : JSON du fichier QR.
|
||||||
|
\item \texttt{pin} : PIN à 4 chiffres.
|
||||||
|
\item \texttt{uuid} : UUID permanent généré par \texttt{pronote-sync} (ex.
|
||||||
|
\texttt{pronote-sync-\{uuid4()\}}).
|
||||||
|
\end{itemize}
|
||||||
|
\item \textbf{Déchiffrement} :
|
||||||
|
\begin{itemize}
|
||||||
|
\item Algorithme :
|
||||||
|
\textbf{AES-CBC utilisant une clé dérivée par MD5 (16 octets, soit AES-128)}.
|
||||||
|
\item Clé : \texttt{MD5(PIN)} (dérivée du PIN secret).
|
||||||
|
\item IV : 16 octets nuls.
|
||||||
|
\item Résultat : \texttt{login} et \texttt{jeton} en clair.
|
||||||
|
\end{itemize}
|
||||||
|
\item \textbf{Échange de token} : Pronote retourne un \texttt{jetonConnexionAppliMobile} (token
|
||||||
|
dont la durée dépend de l'instance/serveur).
|
||||||
|
\item \textbf{Persistance} : \texttt{pronote-sync} sauvegarde les credentials via
|
||||||
|
\texttt{export\_credentials()} dans \texttt{.pronote\_auth\_state.json} (mode \texttt{0600}).
|
||||||
|
\end{enumerate}
|
||||||
|
|
||||||
|
\begin{infobox}
|
||||||
|
\textbf{Source} : {\emojiinfo\ Observé dans le code local de \texttt{pronotepy} 2.15.7}
|
||||||
|
(\texttt{clients.py} lignes 181–189) + \texttt{pronote\_sync/sources/pronote/client.py} (méthode
|
||||||
|
\texttt{\_enroll\_qr\_code}).
|
||||||
|
\end{infobox}
|
||||||
|
|
||||||
|
\subsection{Étape 4 : Connexions ultérieures (auto-login)}
|
||||||
|
|
||||||
|
\begin{enumerate}
|
||||||
|
\item \texttt{pronote-sync} charge \texttt{.pronote\_auth\_state.json}.
|
||||||
|
\item Appel à \texttt{pronotepy.ParentClient.token\_login(**credentials)} :
|
||||||
|
\begin{itemize}
|
||||||
|
\item \texttt{pronote\_url}, \texttt{username}, \texttt{password} (token), \texttt{uuid}.
|
||||||
|
\end{itemize}
|
||||||
|
\item \textbf{Rotation du token} : Le token est \textbf{remplacé uniquement si le serveur
|
||||||
|
renvoie \texttt{jetonConnexionAppliMobile}} (observé dans \texttt{pronotepy} 2.15.7,
|
||||||
|
\texttt{clients.py:382–387}).
|
||||||
|
\item \textbf{Persistance} : Les credentials sont sauvegardés dans
|
||||||
|
\texttt{.pronote\_auth\_state.json} après chaque opération réussie.
|
||||||
|
\end{enumerate}
|
||||||
|
|
||||||
|
\begin{infobox}
|
||||||
|
\textbf{Source} : {\emojiinfo\ Observé dans le code local de \texttt{pronotepy} 2.15.7}
|
||||||
|
(\texttt{clients.py} lignes 245–280) + \texttt{pronote\_sync/sources/pronote/client.py} (méthode
|
||||||
|
\texttt{\_connect\_qr\_token}).
|
||||||
|
\end{infobox}
|
||||||
|
|
||||||
|
\section{Cycle de vie des tokens}
|
||||||
|
|
||||||
|
\begin{itemize}
|
||||||
|
\item \textbf{QR code} : Valide \textbf{~10 minutes} après génération
|
||||||
|
({\warningicon\ \textbf{Hypothèse à valider}} : cette durée n'est attestée que par un
|
||||||
|
message d'exception dans \texttt{pronotepy} et n'est pas une garantie officielle/indépendante
|
||||||
|
de l'instance).
|
||||||
|
\item \textbf{\texttt{jetonConnexionAppliMobile}} :
|
||||||
|
\begin{itemize}
|
||||||
|
\item Durée \textbf{dépendante de l'instance/serveur} (observation du 2026-09-12,
|
||||||
|
hypothèse : peut persister jusqu'à la fin de l'année scolaire, non garanti).
|
||||||
|
\item \textbf{Remplacé uniquement si le serveur renvoie
|
||||||
|
\texttt{jetonConnexionAppliMobile}} (observé dans \texttt{pronotepy} 2.15.7,
|
||||||
|
\texttt{clients.py:382–387}).
|
||||||
|
\item \textbf{Révocable} manuellement dans Pronote (Paramètres \rightarrow Accès mobile).
|
||||||
|
\end{itemize}
|
||||||
|
\end{itemize}
|
||||||
|
|
||||||
|
\begin{infobox}
|
||||||
|
\textbf{Source} : {\warningicon\ Comportement variable selon les instances} (à tester localement).
|
||||||
|
\end{infobox}
|
||||||
|
|
||||||
|
\section{Sécurité}
|
||||||
|
|
||||||
|
\begin{enumerate}
|
||||||
|
\item \textbf{Fichiers sensibles} :
|
||||||
|
\begin{itemize}
|
||||||
|
\item \texttt{.pronote\_auth\_state.json} : \textbf{Ne jamais versionner} (couvert par
|
||||||
|
\texttt{.gitignore}).
|
||||||
|
\item \texttt{PRONOTE\_QR\_CODE\_FILE} : \textbf{Ne jamais committer} (ex. dans Git).
|
||||||
|
\end{itemize}
|
||||||
|
\item \textbf{Secrets} :
|
||||||
|
\begin{itemize}
|
||||||
|
\item Le PIN et le contenu du QR code sont \textbf{masqués} dans les logs (via
|
||||||
|
\texttt{redact\_secrets()}).
|
||||||
|
\item Les exceptions sont \textbf{expurgées} (via \texttt{redact\_exception()}).
|
||||||
|
\end{itemize}
|
||||||
|
\item \textbf{Recommandations} :
|
||||||
|
\begin{itemize}
|
||||||
|
\item Utiliser un \textbf{PIN robuste} (éviter les codes simples comme \guillemotleft 0000 \guillemotright ou
|
||||||
|
des séquences évidentes).
|
||||||
|
\item \textbf{Révoquer} le token en cas de compromission (via Pronote web).
|
||||||
|
\end{itemize}
|
||||||
|
\end{enumerate}
|
||||||
|
|
||||||
|
\begin{infobox}
|
||||||
|
\textbf{Source} : {\emojiinfo\ \texttt{pronote\_sync/utils/redaction.py} +
|
||||||
|
\texttt{pronote\_sync/sources/pronote/client.py}} (méthode \texttt{\_collect\_auth\_secrets}).
|
||||||
|
\end{infobox}
|
||||||
|
|
||||||
|
\section{Erreurs et repli}
|
||||||
|
|
||||||
|
\begin{itemize}
|
||||||
|
\item \textbf{\texttt{PronoteAuthRotationError}} : Levée si :
|
||||||
|
\begin{itemize}
|
||||||
|
\item Le token persisté est \textbf{invalide/expiré}.
|
||||||
|
\item Le fichier QR ou le PIN est \textbf{manquant/invalide}.
|
||||||
|
\end{itemize}
|
||||||
|
\item \textbf{Action requise} :
|
||||||
|
\begin{enumerate}
|
||||||
|
\item Supprimer \texttt{.pronote\_auth\_state.json}.
|
||||||
|
\item Générer un \textbf{nouveau QR code depuis l'interface web Pronote, espace parent}.
|
||||||
|
\item Relancer \texttt{pronote-sync}.
|
||||||
|
\end{enumerate}
|
||||||
|
\end{itemize}
|
||||||
|
|
||||||
|
\begin{infobox}
|
||||||
|
\textbf{Source} : {\emojiinfo\ Observé dans \texttt{pronote\_sync/errors.py} +
|
||||||
|
\texttt{pronote\_sync/sources/pronote/client.py}} (lignes 346–364).
|
||||||
|
\end{infobox}
|
||||||
|
|
||||||
|
\section{Incompatibilités}
|
||||||
|
|
||||||
|
\begin{itemize}
|
||||||
|
\item \textbf{Mode \texttt{dry-run}} : \textbf{Incompatible} avec
|
||||||
|
\texttt{PRONOTE\_AUTH\_MODE=qr\_token} (risque de désynchronisation du token local).
|
||||||
|
\begin{itemize}
|
||||||
|
\item \texttt{pronote-sync --dry-run} \textbf{refuse} le mode \texttt{qr\_token} avant toute
|
||||||
|
connexion.
|
||||||
|
\end{itemize}
|
||||||
|
\end{itemize}
|
||||||
|
|
||||||
|
\begin{infobox}
|
||||||
|
\textbf{Source} : {\emojiinfo\ \texttt{docs/exploitation.md}} (ligne 65–66).
|
||||||
|
\end{infobox}
|
||||||
|
|
||||||
|
% =============================================================================
|
||||||
|
% FIN DU CHAPITRE
|
||||||
|
% =============================================================================
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
% =============================================================================
|
||||||
|
% CHAPITRE : MÉTHODE 4 — SSO ENT (CAS/SAML)
|
||||||
|
% =============================================================================
|
||||||
|
|
||||||
|
\chapter{Méthode 4 : SSO ENT (CAS/SAML)}
|
||||||
|
\label{ch:method4}
|
||||||
|
|
||||||
|
\begin{warningbox}
|
||||||
|
\textbf{Statut} : {\emojicross\ Non implémenté} dans \texttt{pronote-sync}.
|
||||||
|
|
||||||
|
Seuls les 30 ENT de la liste fermée \texttt{\_ENT\_NAMES} (pronotepy 2.15.7) sont supportés via
|
||||||
|
\texttt{ent} dans \texttt{ParentClient}. \textbf{Pas de SSO générique CAS/SAML.}
|
||||||
|
\end{warningbox}
|
||||||
|
|
||||||
|
% =============================================================================
|
||||||
|
% FIN DU CHAPITRE
|
||||||
|
% =============================================================================
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
% =============================================================================
|
||||||
|
% CHAPITRE : MÉTHODE 5 — EDUCONNECT (HUBEDUCONNECT)
|
||||||
|
% =============================================================================
|
||||||
|
|
||||||
|
\chapter{Méthode 5 : EduConnect (HubEduConnect)}
|
||||||
|
\label{ch:method5}
|
||||||
|
|
||||||
|
\begin{warningbox}
|
||||||
|
\textbf{Statut} : {\emojicross\ Non implémenté} dans \texttt{pronote-sync}.
|
||||||
|
|
||||||
|
Nécessite une intégration CAS/SAML générique, \textbf{non supportée par ce projet}.
|
||||||
|
\end{warningbox}
|
||||||
|
|
||||||
|
% =============================================================================
|
||||||
|
% FIN DU CHAPITRE
|
||||||
|
% =============================================================================
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
% =============================================================================
|
||||||
|
% CHAPITRE : MÉTHODE 6 — CAS DIRECT
|
||||||
|
% =============================================================================
|
||||||
|
|
||||||
|
\chapter{Méthode 6 : CAS direct}
|
||||||
|
\label{ch:method6}
|
||||||
|
|
||||||
|
\begin{warningbox}
|
||||||
|
\textbf{Statut} : {\emojicross\ Non implémenté} dans \texttt{pronote-sync}.
|
||||||
|
|
||||||
|
Non supporté.
|
||||||
|
\end{warningbox}
|
||||||
|
|
||||||
|
% =============================================================================
|
||||||
|
% FIN DU CHAPITRE
|
||||||
|
% =============================================================================
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
% =============================================================================
|
||||||
|
% CHAPITRE : MÉTHODE 7 — API PUBLIQUE
|
||||||
|
% =============================================================================
|
||||||
|
|
||||||
|
\chapter{Méthode 7 : API publique}
|
||||||
|
\label{ch:method7}
|
||||||
|
|
||||||
|
\begin{warningbox}
|
||||||
|
\textbf{Statut} : {\emojicross\ Inexistante}.
|
||||||
|
|
||||||
|
Aucune API publique n'est utilisée/implémentée par ce projet ; aucune API officielle
|
||||||
|
publique n'a été vérifiée à la date du 2026-09-12.
|
||||||
|
\end{warningbox}
|
||||||
|
|
||||||
|
% =============================================================================
|
||||||
|
% FIN DU CHAPITRE
|
||||||
|
% =============================================================================
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
% =============================================================================
|
||||||
|
% CHAPITRE : TABLEAU DE COMPATIBILITÉ
|
||||||
|
% =============================================================================
|
||||||
|
|
||||||
|
\chapter{Tableau de compatibilité avec \texttt{pronote-sync}}
|
||||||
|
\label{ch:overview}
|
||||||
|
|
||||||
|
\begin{table}[htbp]
|
||||||
|
\centering
|
||||||
|
\caption{Méthodes d'authentification et leur statut dans \texttt{pronote-sync}}
|
||||||
|
\label{tab:compatibilite}
|
||||||
|
\begin{tabularx}{\textwidth}{L{2.5cm} L{3cm} L{3cm} L{2.5cm} L{4cm}}
|
||||||
|
\toprule
|
||||||
|
\textbf{Méthode} & \textbf{Statut dans \texttt{pronote-sync}} & \textbf{Implémentation} & \textbf{Source} & \textbf{Notes} \\
|
||||||
|
\midrule
|
||||||
|
|
||||||
|
|
||||||
|
\textbf{URL iCal sécurisée} (\texttt{icalsecurise}) & \emojicheck\ Pris en charge & Source \texttt{ical} (prioritaire en mode \texttt{auto}) & \emojiinfo\ \texttt{pronote\_sync/sources/ical.py} & Jeton dans l'URL traité comme secret. \\
|
||||||
|
|
||||||
|
\textbf{Connexion directe} (mot de passe + ENT) & \emojicheck\ Pris en charge & Source \texttt{pronotepy} (repli si iCal échoue) & \emojiinfo\ \texttt{pronote\_sync/sources/pronote/client.py} & Liste fermée \texttt{\_ENT\_NAMES} (lignes 52–84). \\
|
||||||
|
|
||||||
|
\textbf{QR Code + token mobile} & \emojicheck\ Pris en charge & Mode \texttt{PRONOTE\_AUTH\_MODE=qr\_token} & \emojiinfo\ \texttt{pronotepy.Client.qrcode\_login} + \texttt{token\_login} & Voir \hyperref[sec:procedure-qr]{Procédure QR} (Section \ref{sec:procedure-qr}). \\
|
||||||
|
|
||||||
|
\textbf{SSO ENT (CAS/SAML)} & \emojicross\ Non implémenté & — & — & Seuls les 30 ENT de la liste fermée \texttt{\_ENT\_NAMES} (pronotepy 2.15.7) sont supportés via \texttt{ent} dans \texttt{ParentClient}. \textbf{Pas de SSO générique CAS/SAML.} \\
|
||||||
|
|
||||||
|
\textbf{EduConnect (HubEduConnect)} & \emojicross\ Non implémenté & — & — & Nécessite une intégration CAS/SAML générique, \textbf{non supportée par ce projet}. \\
|
||||||
|
|
||||||
|
\textbf{CAS direct} & \emojicross\ Non implémenté & — & — & Non supporté. \\
|
||||||
|
|
||||||
|
\textbf{API publique} & \emojicross\ Inexistante & — & — & Aucune API publique n'est utilisée/implémentée par ce projet ; aucune API officielle publique n'a été vérifiée à la date du 2026-09-12. \\
|
||||||
|
|
||||||
|
\bottomrule
|
||||||
|
\end{tabularx}
|
||||||
|
\end{table}
|
||||||
|
|
||||||
|
% =============================================================================
|
||||||
|
% FIN DU CHAPITRE
|
||||||
|
% =============================================================================
|
||||||
+56
-4
@@ -17,6 +17,24 @@ sudo install -d -m 0700 -o <utilisateur-service> -g <groupe-service> <repertoire
|
|||||||
sudo install -m 0600 -o <utilisateur-service> -g <groupe-service> .env <fichier-environnement>
|
sudo install -m 0600 -o <utilisateur-service> -g <groupe-service> .env <fichier-environnement>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Pour créer directement la configuration à partir du modèle versionné, utilisez
|
||||||
|
la même installation protégée puis éditez le fichier avec un compte autorisé :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo install -m 0600 -o <utilisateur-service> -g <groupe-service> \
|
||||||
|
/opt/pronote-sync/.env.example <fichier-environnement>
|
||||||
|
```
|
||||||
|
|
||||||
|
Contrôlez ensuite uniquement les métadonnées du fichier, sans afficher son
|
||||||
|
contenu :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
stat -c 'mode=%a proprietaire=%U groupe=%G chemin=%n' <fichier-environnement>
|
||||||
|
```
|
||||||
|
|
||||||
|
Le résultat attendu est le mode `600` et le propriétaire/groupe du compte de
|
||||||
|
service. Le répertoire de configuration doit rester en `0700`.
|
||||||
|
|
||||||
Les unités fournies nécessitent l'interface CLI livrée au jalon M12. Avant de
|
Les unités fournies nécessitent l'interface CLI livrée au jalon M12. Avant de
|
||||||
les installer, vérifiez que la version installée contient bien ce point
|
les installer, vérifiez que la version installée contient bien ce point
|
||||||
d'entrée :
|
d'entrée :
|
||||||
@@ -42,12 +60,39 @@ bloquent donc pas le déploiement. Il ne valide ni les valeurs ni les permission
|
|||||||
du fichier d'environnement. Pour analyser seulement le contenu indexé avant un
|
du fichier d'environnement. Pour analyser seulement le contenu indexé avant un
|
||||||
commit, utilisez `scripts/check_secrets.py --staged`.
|
commit, utilisez `scripts/check_secrets.py --staged`.
|
||||||
|
|
||||||
|
Dans une URL, le contrôle exempte uniquement les placeholders documentaires
|
||||||
|
explicitement reconnus : `{token}`/`{jeton}` (et les quelques noms documentaires
|
||||||
|
équivalents), leur forme `<token>`, les suites de trois points ou les caractères
|
||||||
|
de masquage `•`/`…`. Les valeurs entre accolades ou chevrons au contenu arbitraire,
|
||||||
|
les astérisques et toute valeur réelle restent signalés. Un même paramètre URL est
|
||||||
|
contrôlé même si un autre paramètre sensible de la ligne est déjà un placeholder.
|
||||||
|
|
||||||
Le dry-run autorise les lectures nécessaires à la simulation, mais n'applique aucune sortie
|
Le dry-run autorise les lectures nécessaires à la simulation, mais n'applique aucune sortie
|
||||||
CalDAV/XMPP et ne modifie aucun état local persistant. L'état RSS reste limité à la mémoire du
|
CalDAV/XMPP et ne modifie aucun état local persistant. L'état RSS reste limité à la mémoire du
|
||||||
processus. Le mode `PRONOTE_AUTH_MODE=qr_token` est incompatible avec cette garantie : la commande
|
processus. Le mode `PRONOTE_AUTH_MODE=qr_token` est incompatible avec cette garantie : la commande
|
||||||
le refuse avant toute connexion afin de ne pas désynchroniser le token local du token distant.
|
le refuse avant toute connexion afin de ne pas désynchroniser le token local du token distant.
|
||||||
Le dry-run ne remplace pas une vérification des paramètres réellement chargés.
|
Le dry-run ne remplace pas une vérification des paramètres réellement chargés.
|
||||||
|
|
||||||
|
En mode `PRONOTE_AUTH_MODE=qr_token`, `PRONOTE_QR_PIN` déchiffre le QR code
|
||||||
|
exporté depuis le site web Pronote. Si le compte exige un second facteur,
|
||||||
|
configurez aussi `PRONOTE_ACCOUNT_PIN` avec le PIN du compte. Ce PIN est
|
||||||
|
transmis uniquement à `pronotepy` lors de l'enrôlement QR et des connexions par
|
||||||
|
token ; il n'est jamais écrit dans `.pronote_auth_state.json` ni dans les logs.
|
||||||
|
|
||||||
|
Si le blog RSS est activé, ses GUID ne sont acquittés qu'après confirmation de
|
||||||
|
l'envoi XMPP. Un refus, une exception, l'absence de canal ou un `--dry-run`
|
||||||
|
laisse donc les articles récupérables à l'exécution suivante ; les en-têtes
|
||||||
|
HTTP associés à ces articles suivent la même règle pour éviter un `304` qui
|
||||||
|
masquerait une livraison non confirmée.
|
||||||
|
|
||||||
|
En mode `PRONOTE_AUTH_MODE=qr_token`, le fichier
|
||||||
|
`.pronote_auth_state.json` et son verrou frère sont créés dans le répertoire
|
||||||
|
de travail du service (par exemple `/var/lib/pronote-sync`) avec le mode
|
||||||
|
`0600`. Ils contiennent des credentials vivantes et ne doivent être ni copiés,
|
||||||
|
ni versionnés, ni affichés. Le PIN du QR code et, si nécessaire, le PIN de
|
||||||
|
second facteur du compte sont également des secrets ; configurez-les uniquement
|
||||||
|
dans le fichier d'environnement protégé.
|
||||||
|
|
||||||
## Installation systemd
|
## Installation systemd
|
||||||
|
|
||||||
Les fichiers versionnés sont :
|
Les fichiers versionnés sont :
|
||||||
@@ -79,10 +124,17 @@ sudo systemctl start pronote-sync.service
|
|||||||
sudo systemctl status pronote-sync.service
|
sudo systemctl status pronote-sync.service
|
||||||
```
|
```
|
||||||
|
|
||||||
Une exécution en échec laisse l'unité `pronote-sync.service` en état `failed`.
|
La CLI expose un contrat de sortie stable : `0` signifie une exécution complète,
|
||||||
La supervision de l'hôte doit donc déclencher une alerte sur cet état ou sur un
|
`2` une exécution dégradée (les données Pronote sont disponibles mais une étape
|
||||||
échec du timer/service ; le transport de cette alerte (courriel, XMPP ou système
|
optionnelle, CalDAV ou XMPP a échoué), et `1` un échec critique. Tout code non
|
||||||
de supervision) relève de l'exploitation locale.
|
nul laisse l'unité `pronote-sync.service` en état `failed` ; la supervision doit
|
||||||
|
donc alerter sur cet état ou sur le code de sortie. Le code `2` permet de
|
||||||
|
distinguer automatiquement une alerte dégradée d'une panne critique, sans lire
|
||||||
|
les journaux.
|
||||||
|
|
||||||
|
Le `--dry-run` n'écrit ni dans CalDAV/XMPP ni dans l'état local. Il conserve le
|
||||||
|
même contrat de codes : `0` si la simulation est complète, `2` si elle est
|
||||||
|
dégradée et `1` si elle est critique.
|
||||||
|
|
||||||
## Journaux et alertes
|
## Journaux et alertes
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Corpus de fixtures Pronote
|
||||||
|
|
||||||
|
Le corpus versionné de `tests/fixtures/` est limité à des exemples iCalendar
|
||||||
|
minimaux et anonymisés. Il est rejouable sans connexion Pronote et sert à
|
||||||
|
vérifier les modèles métier finaux, pas seulement l'absence d'exception.
|
||||||
|
|
||||||
|
Chaque fichier `*.ics` de variante possède un manifeste JSON homonyme. Le
|
||||||
|
manifeste indique la source, la version connue (ou `unknown`), les scénarios
|
||||||
|
couverts et les invariants à préserver. Les réponses API Pronote ne sont pas
|
||||||
|
stockées tant qu'elles ne peuvent pas être réduites sans conserver de données
|
||||||
|
personnelles ou de secret.
|
||||||
|
|
||||||
|
Avant d'ajouter un export : supprimer les URL, jetons, identifiants, noms,
|
||||||
|
établissement et contenu personnel ; réduire le document aux propriétés
|
||||||
|
nécessaires ; exécuter `scripts/check_secrets.py` ; puis ajouter un test qui
|
||||||
|
vérifie les modèles `Lesson`, `Homework` et `SchoolEvent` obtenus.
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
% =============================================================================
|
||||||
|
% PAGE DE TITRE — Authentification Pronote
|
||||||
|
% =============================================================================
|
||||||
|
|
||||||
|
\begin{titlepage}
|
||||||
|
\newgeometry{margin=2cm}
|
||||||
|
\begin{center}
|
||||||
|
\vspace*{2cm}
|
||||||
|
|
||||||
|
{\Huge\bfseries\color{primaryNavy} Authentification Pronote\\}
|
||||||
|
{\huge\bfseries\color{secondarySlate} Référence technique pour \texttt{pronote-sync}}
|
||||||
|
|
||||||
|
\vspace{1.5cm}
|
||||||
|
|
||||||
|
{\Large\color{secondarySlate} Version 1.0 — 12 septembre 2026}
|
||||||
|
|
||||||
|
\vspace{2cm}
|
||||||
|
|
||||||
|
{\large\color{gray} Équipe Architecture et Sécurité}
|
||||||
|
|
||||||
|
\vspace{1cm}
|
||||||
|
|
||||||
|
{\small\color{gray}
|
||||||
|
Ce document n'est pas un guide officiel. Il n'est ni approuvé, ni validé, ni autorisé
|
||||||
|
par le Ministère de l'Éducation Nationale française ni par Docaposte (éditeur de
|
||||||
|
Pronote / Index Éducation).
|
||||||
|
}
|
||||||
|
|
||||||
|
\vspace{2cm}
|
||||||
|
|
||||||
|
\begin{warningbox}
|
||||||
|
\centering
|
||||||
|
{\bfseries\large\color{warningRed} AVERTISSEMENT}\\
|
||||||
|
\vspace{0.5cm}
|
||||||
|
{\small
|
||||||
|
Ce document repose sur des observations directes du code source de
|
||||||
|
\texttt{pronotepy} (version 2.15.7, vérifiée localement le 2026-09-12), des issues
|
||||||
|
publiques du dépôt \texttt{bain3/pronotepy}, et des tests d'intégration du projet
|
||||||
|
\texttt{pronote-sync}.\\
|
||||||
|
|
||||||
|
{\bfseries Un essai réel sur l'instance Pronote cible est nécessaire avant tout usage
|
||||||
|
en production. Les tests/mocks ne prouvent pas la compatibilité d'instance.}
|
||||||
|
}
|
||||||
|
\end{warningbox}
|
||||||
|
|
||||||
|
\vfill
|
||||||
|
|
||||||
|
{\small\color{gray} Compilé avec LuaLaTeX}
|
||||||
|
|
||||||
|
\end{center}
|
||||||
|
\restoregeometry
|
||||||
|
\end{titlepage}
|
||||||
|
|
||||||
|
% =============================================================================
|
||||||
|
% TABLE DES MATIÈRES
|
||||||
|
% =============================================================================
|
||||||
|
|
||||||
|
\newpage
|
||||||
|
\tableofcontents
|
||||||
|
\thispagestyle{fancy}
|
||||||
|
|
||||||
|
% =============================================================================
|
||||||
|
% LISTE DES TABLEAUX (optionnel)
|
||||||
|
% =============================================================================
|
||||||
|
|
||||||
|
\newpage
|
||||||
|
\listoftables
|
||||||
|
\thispagestyle{fancy}
|
||||||
|
|
||||||
|
% =============================================================================
|
||||||
|
% AVERTISSEMENT INITIAL
|
||||||
|
% =============================================================================
|
||||||
|
|
||||||
|
\newpage
|
||||||
|
\section*{Avertissement initial}
|
||||||
|
\addcontentsline{toc}{section}{Avertissement initial}
|
||||||
|
|
||||||
|
\begin{infobox}
|
||||||
|
\textbf{Convention de qualification} :
|
||||||
|
|
||||||
|
\begin{itemize}
|
||||||
|
\item {\emojiinfo\ \textbf{Observé dans le code local}} : Mécanisme vérifié dans le code
|
||||||
|
source de \texttt{pronotepy} 2.15.7 ou \texttt{pronote-sync}.
|
||||||
|
\item {\emojiinfo\ \textbf{Observé localement}} : Comportement constaté dans les interfaces
|
||||||
|
Pronote (version non spécifiée, hypothèse à valider).
|
||||||
|
\item {\warningicon\ \textbf{Hypothèse à valider}} : Affirmation non vérifiée, nécessitant
|
||||||
|
une confirmation par test sur une instance réelle.
|
||||||
|
\end{itemize}
|
||||||
|
\end{infobox}
|
||||||
|
|
||||||
|
\vspace{1cm}
|
||||||
|
|
||||||
|
\begin{warningbox}
|
||||||
|
\textbf{Note importante (2026-09-12)} :
|
||||||
|
|
||||||
|
Un essai réel sur l'instance Pronote cible est nécessaire avant tout usage en production.
|
||||||
|
Les tests/mocks ne prouvent pas la compatibilité d'instance.
|
||||||
|
\end{warningbox}
|
||||||
|
|
||||||
|
\newpage
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
% =============================================================================
|
||||||
|
% GLOSSAIRE (chapitre non numéroté)
|
||||||
|
% =============================================================================
|
||||||
|
|
||||||
|
\chapter*{Glossaire}
|
||||||
|
\addcontentsline{toc}{chapter}{Glossaire}
|
||||||
|
|
||||||
|
\begin{tabularx}{\textwidth}{L{4cm} L{12cm}}
|
||||||
|
\toprule
|
||||||
|
\textbf{Terme} & \textbf{Définition} \\
|
||||||
|
\midrule
|
||||||
|
|
||||||
|
\textbf{ENT} & Espace Numérique de Travail (ex. Mon Bureau Numérique, Paris Classe Numérique). \\
|
||||||
|
|
||||||
|
\textbf{Jeton \texttt{icalsecurise}} & Token secret intégré dans l'URL iCal, équivalent à un mot de passe. \\
|
||||||
|
|
||||||
|
\textbf{\texttt{jetonConnexionAppliMobile}} & Token obtenu après appairage QR code, dont la durée dépend de l'instance/du serveur et n'est pas garantie. \\
|
||||||
|
|
||||||
|
\textbf{UUID} & Identifiant unique permanent pour l'application (ex. \texttt{pronote-sync-\{uuid4()\}}). \\
|
||||||
|
|
||||||
|
\textbf{PIN} & Code à 4 chiffres défini lors de la génération du QR code. \\
|
||||||
|
|
||||||
|
\bottomrule
|
||||||
|
\end{tabularx}
|
||||||
|
|
||||||
|
% =============================================================================
|
||||||
|
% FIN DU GLOSSAIRE
|
||||||
|
% =============================================================================
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
% =============================================================================
|
||||||
|
% HISTORIQUE DES RÉVISIONS (chapitre non numéroté)
|
||||||
|
% =============================================================================
|
||||||
|
|
||||||
|
\chapter*{Historique des révisions}
|
||||||
|
\addcontentsline{toc}{chapter}{Historique des révisions}
|
||||||
|
|
||||||
|
\begin{tabularx}{\textwidth}{L{2.4cm} L{2.6cm} L{11cm}}
|
||||||
|
\toprule
|
||||||
|
\textbf{Date} & \textbf{Auteur} & \textbf{Modifications} \\
|
||||||
|
\midrule
|
||||||
|
|
||||||
|
2026-09-12 & Agent tech-writer & Refonte complète : qualification des affirmations, ajout des sources, tableau de compatibilité, procédure QR alignée sur pronotepy 2.15.7. \\
|
||||||
|
|
||||||
|
2026-09-12 & Agent tech-writer & Corrections suite à revue indépendante (ticket \#10) : précision cryptographie (AES-128), qualification des sources, alignement nombre d'ENT (30), clarification SSO/CAS, exemples non copiables, note d'essai réel. \\
|
||||||
|
|
||||||
|
2026-09-12 & Agent tech-writer & Corrections suite à revue ticket \#10 : suppression de tous les placeholders de secrets remplacés par de la prose descriptive ; clarification du périmètre réel des méthodes d'authentification (accès limité aux opérations implémentées) ; qualification de la durée du token \texttt{jetonConnexionAppliMobile} comme dépendante de l'instance/serveur et non garantie. \\
|
||||||
|
|
||||||
|
\bottomrule
|
||||||
|
\end{tabularx}
|
||||||
|
|
||||||
|
% =============================================================================
|
||||||
|
% FIN DE L'HISTORIQUE
|
||||||
|
% =============================================================================
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
% =============================================================================
|
||||||
|
% PRÉAMBULE LaTeX — Authentification Pronote
|
||||||
|
% =============================================================================
|
||||||
|
|
||||||
|
% --- Classe de document ---
|
||||||
|
\documentclass[11pt,a4paper,oneside]{report}
|
||||||
|
|
||||||
|
% --- Encodage et langue ---
|
||||||
|
\usepackage[utf8]{inputenc}
|
||||||
|
\usepackage[T1]{fontenc}
|
||||||
|
\usepackage[french]{babel}
|
||||||
|
\usepackage{lmodern}
|
||||||
|
|
||||||
|
% --- Polices (plan documentaire : EB Garamond / Carlito / DejaVu Sans Mono) ---
|
||||||
|
% EB Garamond (sérif) : police principale du document
|
||||||
|
\usepackage{ebgaramond}
|
||||||
|
% Carlito (sans-sérif, compatible métriquement Calibri)
|
||||||
|
\usepackage[sfdefault]{carlito}
|
||||||
|
% DejaVu Sans Mono (chasse fixe) : code, JSON, requêtes HTTP
|
||||||
|
% (fontspec est déjà chargé par EB Garamond sous LuaLaTeX)
|
||||||
|
\setmonofont{DejaVu Sans Mono}[Scale=0.9]
|
||||||
|
|
||||||
|
% --- Couleurs (palette Bleu Institutionnel & Ardoise) ---
|
||||||
|
\usepackage{xcolor}
|
||||||
|
\definecolor{primaryNavy}{RGB}{20,45,85}
|
||||||
|
\definecolor{secondarySlate}{RGB}{70,95,125}
|
||||||
|
\definecolor{accentSteel}{RGB}{0,115,150}
|
||||||
|
\definecolor{lightGray}{RGB}{240,240,240}
|
||||||
|
\definecolor{warningRed}{RGB}{180,50,50}
|
||||||
|
|
||||||
|
% --- Mise en page ---
|
||||||
|
\usepackage[a4paper, margin=2.5cm, top=2cm, bottom=2cm]{geometry}
|
||||||
|
\usepackage{parskip}
|
||||||
|
\setlength{\parskip}{1em}
|
||||||
|
\setlength{\parindent}{0pt}
|
||||||
|
|
||||||
|
% --- Hyperliens et PDF ---
|
||||||
|
\usepackage{hyperref}
|
||||||
|
\hypersetup{
|
||||||
|
colorlinks=true,
|
||||||
|
linkcolor=accentSteel,
|
||||||
|
urlcolor=accentSteel,
|
||||||
|
citecolor=secondarySlate,
|
||||||
|
filecolor=primaryNavy,
|
||||||
|
menucolor=primaryNavy,
|
||||||
|
runcolor=primaryNavy,
|
||||||
|
pdfauthor={Équipe Architecture & Sécurité},
|
||||||
|
pdftitle={Authentification Pronote — Référence technique},
|
||||||
|
pdfsubject={Référence technique d'authentification pour pronote-sync},
|
||||||
|
pdfkeywords={Pronote, authentification, pronote-sync, QR code, token, ENT, iCal},
|
||||||
|
bookmarksopen=true,
|
||||||
|
bookmarksnumbered=true
|
||||||
|
}
|
||||||
|
|
||||||
|
% --- Titres et sections ---
|
||||||
|
\usepackage{titlesec}
|
||||||
|
\titleformat{\chapter}[display]
|
||||||
|
{\normalfont\huge\bfseries\color{primaryNavy}}
|
||||||
|
{\chaptertitlename\ \thechapter}
|
||||||
|
{20pt}
|
||||||
|
{\Huge\color{primaryNavy}}
|
||||||
|
\titleformat{\section}
|
||||||
|
{\normalfont\Large\bfseries\color{primaryNavy}}
|
||||||
|
{\thesection}
|
||||||
|
{1em}
|
||||||
|
{\color{primaryNavy}}
|
||||||
|
\titleformat{\subsection}
|
||||||
|
{\normalfont\large\bfseries\color{secondarySlate}}
|
||||||
|
{\thesubsection}
|
||||||
|
{1em}
|
||||||
|
{\color{secondarySlate}}
|
||||||
|
\titleformat{\subsubsection}
|
||||||
|
{\normalfont\bfseries\color{secondarySlate}}
|
||||||
|
{\thesubsubsection}
|
||||||
|
{1em}
|
||||||
|
{\color{secondarySlate}}
|
||||||
|
|
||||||
|
% --- Tableaux ---
|
||||||
|
\usepackage{tabularx}
|
||||||
|
\usepackage{booktabs}
|
||||||
|
\usepackage{array}
|
||||||
|
\newcolumntype{L}[1]{>{\raggedright\let\newline\\\arraybackslash\hspace{0pt}\hspace{1em}}p{#1}}
|
||||||
|
\newcolumntype{C}[1]{>{\centering\let\newline\\\arraybackslash\hspace{0pt}\hspace{1em}}p{#1}}
|
||||||
|
\newcolumntype{R}[1]{>{\raggedleft\let\newline\\\arraybackslash\hspace{0pt}\hspace{1em}}p{#1}}
|
||||||
|
|
||||||
|
% --- Listes et encadrés ---
|
||||||
|
\usepackage{enumitem}
|
||||||
|
\setlist[itemize]{leftmargin=*, topsep=0.5em, itemsep=0.25em}
|
||||||
|
\setlist[enumerate]{leftmargin=*, topsep=0.5em, itemsep=0.25em}
|
||||||
|
|
||||||
|
% --- Callouts (encadrés colorés) ---
|
||||||
|
\usepackage{mdframed}
|
||||||
|
\newmdenv[linecolor=warningRed, linewidth=2pt, leftmargin=10pt, rightmargin=10pt, innerleftmargin=10pt, innerrightmargin=10pt, backgroundcolor=red!5!white, roundcorner=5pt, skipabove=10pt, skipbelow=10pt]{warningbox}
|
||||||
|
\newmdenv[linecolor=secondarySlate, linewidth=2pt, leftmargin=10pt, rightmargin=10pt, innerleftmargin=10pt, innerrightmargin=10pt, backgroundcolor=blue!5!white, roundcorner=5pt, skipabove=10pt, skipbelow=10pt]{infobox}
|
||||||
|
|
||||||
|
% --- Code source ---
|
||||||
|
\usepackage{listings}
|
||||||
|
\lstdefinestyle{pronote}{
|
||||||
|
basicstyle=\ttfamily\footnotesize,
|
||||||
|
breaklines=true,
|
||||||
|
frame=single,
|
||||||
|
rulecolor=\color{gray!30},
|
||||||
|
backgroundcolor=\color{lightGray},
|
||||||
|
keywordstyle=\color{primaryNavy},
|
||||||
|
stringstyle=\color{secondarySlate},
|
||||||
|
commentstyle=\color{gray},
|
||||||
|
showstringspaces=false,
|
||||||
|
tabsize=2,
|
||||||
|
captionpos=b,
|
||||||
|
xleftmargin=10pt,
|
||||||
|
xrightmargin=10pt,
|
||||||
|
aboveskip=10pt,
|
||||||
|
belowskip=10pt
|
||||||
|
}
|
||||||
|
\lstset{style=pronote}
|
||||||
|
|
||||||
|
% --- Symboles et icônes ---
|
||||||
|
\usepackage{amssymb}
|
||||||
|
\usepackage{pifont}
|
||||||
|
\newcommand{\checkmarkicon}{\ding{51}} % ✓
|
||||||
|
\newcommand{\warningicon}{\ding{43}} % ⚠
|
||||||
|
\newcommand{\infoicon}{\ding{48}} % ℹ
|
||||||
|
\newcommand{\crossicon}{\ding{55}} % ✗
|
||||||
|
|
||||||
|
% --- Mathématiques ---
|
||||||
|
\usepackage{amsmath}
|
||||||
|
|
||||||
|
% --- Table des matières ---
|
||||||
|
\usepackage{tocloft}
|
||||||
|
\renewcommand{\cftchapfont}{\normalfont\bfseries\color{primaryNavy}}
|
||||||
|
\renewcommand{\cftsecfont}{\normalfont\color{primaryNavy}}
|
||||||
|
\renewcommand{\cftsubsecfont}{\normalfont\color{secondarySlate}}
|
||||||
|
|
||||||
|
% --- En-têtes et pieds de page ---
|
||||||
|
\usepackage{fancyhdr}
|
||||||
|
\pagestyle{fancy}
|
||||||
|
\fancyhf{}
|
||||||
|
\fancyhead[LE,RO]{\thepage}
|
||||||
|
\fancyhead[LO]{\nouppercase{\rightmark}}
|
||||||
|
\fancyhead[RE]{\nouppercase{\leftmark}}
|
||||||
|
\renewcommand{\headrulewidth}{0.5pt}
|
||||||
|
\renewcommand{\footrulewidth}{0pt}
|
||||||
|
|
||||||
|
% --- Espacement vertical ---
|
||||||
|
\usepackage{setspace}
|
||||||
|
\onehalfspacing
|
||||||
|
|
||||||
|
% --- Césure ---
|
||||||
|
\usepackage{microtype}
|
||||||
|
|
||||||
|
% --- Support des emojis (repli sur texte si non disponible) ---
|
||||||
|
\newcommand{\emojiwarning}{\warningicon}
|
||||||
|
\newcommand{\emojicheck}{\checkmarkicon}
|
||||||
|
\newcommand{\emojicross}{\crossicon}
|
||||||
|
\newcommand{\emojiinfo}{\infoicon}
|
||||||
|
|
||||||
|
% =============================================================================
|
||||||
|
% FIN DU PRÉAMBULE
|
||||||
|
% =============================================================================
|
||||||
+252
-496
@@ -1,570 +1,326 @@
|
|||||||
> ⚠️ **AVERTISSEMENT**
|
> ⚠️ **AVERTISSEMENT**
|
||||||
>
|
>
|
||||||
> Ce document **n'est pas un guide officiel**. Il n'est ni approuvé, ni validé, ni autorisé par le Ministère de l'Éducation Nationale française ni par Docaposte (éditeur de Pronote / Index Éducation). Les informations présentées reposent sur des recherches publiques, des travaux de rétro-ingénierie menés par la communauté open-source et des analyses techniques. Les protocoles décrits ne sont pas officiellement publiés par Index Éducation et peuvent évoluer sans préavis.
|
> Ce document **n'est pas un guide officiel**. Il n'est ni approuvé, ni validé, ni autorisé par le Ministère de l'Éducation Nationale française ni par Docaposte (éditeur de Pronote / Index Éducation). Les informations présentées reposent sur :
|
||||||
|
> - des **observations directes** du code source de `pronotepy` (version **2.15.7**, vérifiée localement le 2026-09-12) ;
|
||||||
|
> - des **issues publiques** du dépôt [bain3/pronotepy](https://github.com/bain3/pronotepy) (ex. #309, #344) ;
|
||||||
|
> - des **tests d'intégration** du projet `pronote-sync`.
|
||||||
>
|
>
|
||||||
> Ce document a été produit à l'aide de plusieurs agents basés sur des modèles de langage (LLM) :
|
> **⚠️ Note importante (2026-09-12)** : Un essai réel sur l'instance Pronote cible est nécessaire avant tout usage en production. Les tests/mocks ne prouvent pas la compatibilité d'instance.
|
||||||
> - **Mercury 2.5** (agent explorer) — exploration du code pour établir les faits techniques.
|
>
|
||||||
> - **Gemini 3.5 Flash** (agent web-explorer) — recherche des méthodes d'authentification externes.
|
> **Convention de qualification** :
|
||||||
> - **Hy3** (agent planner) — planification de la structure du document et découpage en sections.
|
> ✅ **Observé dans le code local** : Mécanisme vérifié dans le code source de `pronotepy` 2.15.7 ou `pronote-sync`.
|
||||||
> - **Mistral Medium** (agent tech-writer) — rédaction du document.
|
> 🔍 **Observé localement** : Comportement constaté dans les interfaces Pronote (version non spécifiée, hypothèse à valider).
|
||||||
> - **GPT-5.6 Luna** (agent reviewer) — revue du contenu pour l'exactitude et la cohérence.
|
> ⚠️ **Hypothèse à valider** : Affirmation non vérifiée, nécessitant une confirmation par test sur une instance réelle.
|
||||||
> - **GLM-5.2** (agent orchestrator) — coordination et intégration du travail.
|
|
||||||
> - **Gemini 3.7 Flash** (agent ui-designer) — conception de la version LaTeX/PDF.
|
---
|
||||||
# Authentification Pronote — Référence technique
|
|
||||||
|
# Authentification Pronote — Référence technique pour `pronote-sync`
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
Ce manuel documente l’ensemble des méthodes d’authentification connues pour accéder aux données élèves/parents de Pronote (notes, emploi du temps, devoirs, absences, etc.). Il couvre à la fois les mécanismes officiellement supportés et les protocoles issus de l’analyse communautaire.
|
Ce document documente **uniquement les méthodes d'authentification prises en charge ou délégables par `pronote-sync`**, en alignement strict avec :
|
||||||
|
- Le code source de `pronote_sync/sources/pronote/client.py` (liste fermée `_ENT_NAMES`, gestion des tokens).
|
||||||
|
- La bibliothèque `pronotepy` **2.15.7** (contrat des méthodes `qrcode_login`, `token_login`, `export_credentials`).
|
||||||
|
- Les paramètres de configuration `.env.example` (lignes 21–25).
|
||||||
|
|
||||||
Le public visé inclut les développeurs, ingénieurs sécurité et intégrateurs système devant maîtriser l’authentification Pronote au niveau protocolaire. Les descriptions utilisent du pseudocode générique, des échanges HTTP et des schémas protocoles, sans présupposer de langage ou framework spécifique.
|
**Périmètre** :
|
||||||
|
- **Pris en charge** : Méthodes implémentées et testées dans `pronote-sync`.
|
||||||
|
- **Délégable à pronotepy** : Méthodes gérées par `pronotepy` mais non directement exposées par `pronote-sync`.
|
||||||
|
- **Non implémenté** : Méthodes non supportées (ex. CAS/SAML générique, EduConnect direct).
|
||||||
|
|
||||||
*Remarque méthodologique* : Les méthodes au-delà de l’export iCal s’appuient sur des recherches publiques et l’analyse de la communauté open source. Ces protocoles, non publiés officiellement par Index Éducation, peuvent évoluer sans préavis.
|
**Public cible** : Développeurs et intégrateurs de `pronote-sync`.
|
||||||
|
|
||||||
## Vue d'ensemble comparative
|
---
|
||||||
|
|
||||||
| Méthode | Périmètre de données | Identifiants requis | Expiration du jeton | Complexité | Statut |
|
## Tableau de compatibilité avec `pronote-sync`
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| URL iCal sécurisée (`icalsecurise`) | Emploi du temps et, si inclus, cahier de textes/devoirs | Jeton dans l'URL | Longue durée | Très faible | Officiel |
|
|
||||||
| Connexion directe (identifiant/mot de passe) | Complet | Identifiant + mot de passe établissement | Session ~15–30 min | Élevée | Reverse-engineered |
|
|
||||||
| SSO ENT (CAS / SAML / Oze) | Complet | Identifiants ENT | Dépend de la session ENT | Très élevée | Reverse-engineered |
|
|
||||||
| SSO EduConnect | Complet | Identifiants nationaux EduConnect | Dépend de la session EduConnect | Très élevée | Reverse-engineered |
|
|
||||||
| CAS (Central Authentication Service) | Complet | Identifiants CAS | Dépend du ticket de service | Modérée | Officiel |
|
|
||||||
| QR Code + jeton mobile | Complet | Code PIN à 4 chiffres → `jetonConnexionAppliMobile` + UUID | QR valable 10 min ; jeton longue durée | Modérée | Reverse-engineered |
|
|
||||||
| API publique | N/A | N/A | N/A | N/A | Inexistante |
|
|
||||||
|
|
||||||
*Complet* désigne l’accès aux notes, emploi du temps, devoirs, absences, messagerie et paramètres, tandis que la méthode iCal se limite à l’emploi du temps et éventuellement aux devoirs.
|
| Méthode | Statut dans `pronote-sync` | Implémentation | Source | Notes |
|
||||||
|
|---------|----------------------------|----------------|--------|-------|
|
||||||
|
| **URL iCal sécurisée** (`icalsecurise`) | ✅ Pris en charge | Source `ical` (prioritaire en mode `auto`) | 🔍 `pronote_sync/sources/ical.py` | Jeton dans l'URL traité comme secret. |
|
||||||
|
| **Connexion directe (mot de passe + ENT)** | ✅ Pris en charge | Source `pronotepy` (repli si iCal échoue) | 🔍 `pronote_sync/sources/pronote/client.py` | Liste fermée `_ENT_NAMES` (lignes 52–84). |
|
||||||
|
| **QR Code + token mobile** | ✅ Pris en charge | Mode `PRONOTE_AUTH_MODE=qr_token` | 🔍 `pronotepy.Client.qrcode_login` + `token_login` | Voir [Procédure QR](#procédure-qr-pour-pronote-sync). |
|
||||||
|
| **SSO ENT (CAS/SAML)** | ❌ Non implémenté | — | — | Seuls les 30 ENT de la liste fermée `_ENT_NAMES` (pronotepy 2.15.7) sont supportés via `ent` dans `ParentClient`. **Pas de SSO générique CAS/SAML.** |
|
||||||
|
| **EduConnect (HubEduConnect)** | ❌ Non implémenté | — | — | Nécessite une intégration CAS/SAML générique, **non supportée par ce projet**. |
|
||||||
|
| **CAS direct** | ❌ Non implémenté | — | — | Non supporté. |
|
||||||
|
| **API publique** | ❌ Inexistante | — | — | Aucune API publique n'est utilisée/implémentée par ce projet ; aucune API officielle publique n'a été vérifiée à la date du 2026-09-12.
|
||||||
|
|
||||||
## Méthode 1 : URL iCal sécurisée (icalsecurise)
|
---
|
||||||
|
|
||||||
|
## Méthode 1 : URL iCal sécurisée (`icalsecurise`)
|
||||||
|
|
||||||
### Principe général
|
### Principe général
|
||||||
Pronote expose un flux de calendrier en lecture seule conforme à la norme iCalendar (RFC 5545). L’accès est contrôlé par un jeton secret intégré dans l’URL sous forme de paramètre de requête `icalsecurise`. Ce jeton est unique par utilisateur et par établissement. **Le jeton constitue la seule crédentiale** : il doit être traité comme un mot de passe. Toute requête HTTP GET vers cette URL permet de récupérer les données du calendrier, sans nécessiter de cookies, de session ni d’en-têtes d’authentification.
|
Pronote expose un flux de calendrier en lecture seule conforme à la norme **iCalendar (RFC 5545)**. L'accès est contrôlé par un **jeton secret** intégré dans l'URL sous forme de paramètre de requête `icalsecurise`.
|
||||||
|
|
||||||
|
✅ **Statut** : **Observé localement** (fonctionnalité native de Pronote, version non spécifiée).
|
||||||
|
|
||||||
### Obtention du jeton
|
### Obtention du jeton
|
||||||
Le jeton s’obtient manuellement depuis l’interface web de Pronote :
|
1. Se connecter à Pronote (Espace Parents ou Élève) via n'importe quelle méthode.
|
||||||
1. Se connecter à Pronote (Espace Parents ou Espace Élève) via n’importe quelle méthode d’authentification.
|
2. Accéder à la vue *« Emploi du temps »*.
|
||||||
2. Accéder à la vue « Emploi du temps ».
|
3. Utiliser la fonction *« Export iCal »* ou *« Exporter »*.
|
||||||
3. Utiliser la fonction « Export iCal » ou « Exporter ».
|
4. Pronote génère une URL contenant un jeton secret `icalsecurise`.
|
||||||
4. Pronote génère une URL contenant le paramètre `icalsecurise`.
|
5. **Copier cette URL** : elle constitue une crédentiale unique sensible qui doit être protégée comme un mot de passe.
|
||||||
5. Copier cette URL : elle constitue la crédentiale.
|
|
||||||
|
|
||||||
Cette URL doit être stockée de manière sécurisée (ex. : gestionnaire de secrets, variables protégées). **Elle ne doit jamais être versionnée ou partagée en clair.**
|
|
||||||
|
|
||||||
|
🔹 **Source** : 🔍 Observé localement dans les interfaces Pronote (version non spécifiée, hypothèse à valider).
|
||||||
|
⚠️ **Note** : Les étapes dépendent de l'instance et du type de compte. L'exemple ci-dessus est non fonctionnel et ne contient aucun secret.
|
||||||
|
|
||||||
### Format de l'URL
|
### Format de l'URL
|
||||||
L’URL suit la structure suivante :
|
|
||||||
```
|
```
|
||||||
https://{etablissement}.index-education.net/pronote/ical/Edt_{prenom}.ics?icalsecurise={jeton}&version={version}¶m={param}
|
https://{etablissement}.index-education.net/pronote/ical/Edt_{prenom}.ics?icalsecurise={jeton}&version={version}¶m={param}
|
||||||
```
|
```
|
||||||
Exemple masqué :
|
- `icalsecurise` : **Jeton secret** (crédentiale).
|
||||||
`https://XXXXXXX.index-education.net/pronote/ical/Edt_Alice.ics?icalsecurise=••••••••&version=2023¶m=...`
|
- `version` : Version de Pronote (ex. `2024`).
|
||||||
|
- `param` : Paramètres optionnels.
|
||||||
|
|
||||||
Paramètres de requête :
|
🔹 **Source** : 🔍 Analysé via `pronote_sync/sources/ical.py`.
|
||||||
- `icalsecurise` : jeton secret (crédentiale).
|
|
||||||
- `version` : version de Pronote.
|
|
||||||
- `param` : paramètres supplémentaires (optionnels).
|
|
||||||
|
|
||||||
|
|
||||||
### Flux d'authentification (protocole)
|
|
||||||
Le protocole d’authentification se résume ainsi :
|
|
||||||
|
|
||||||
1. **Préparation** :
|
|
||||||
Le client dispose de l’URL iCal sécurisée (obtenue comme décrit ci-dessus).
|
|
||||||
|
|
||||||
2. **Requête HTTP** :
|
|
||||||
Le client effectue une requête HTTP GET :
|
|
||||||
```
|
|
||||||
GET {ical-url}
|
|
||||||
Accept: text/calendar, */*;q=0.5
|
|
||||||
User-Agent: {identifiant-client}
|
|
||||||
```
|
|
||||||
- Délai d’attente : configurable (recommandé : 20 secondes).
|
|
||||||
|
|
||||||
3. **Validation de la réponse** :
|
|
||||||
- Si le code HTTP n’est pas 2xx → échec d’authentification ou erreur serveur.
|
|
||||||
- Si le corps de la réponse ne contient pas `BEGIN:VCALENDAR` → le jeton est probablement expiré ou l’URL est invalide. Le serveur peut retourner une page HTML d’erreur au lieu des données de calendrier.
|
|
||||||
|
|
||||||
4. **Traitement** :
|
|
||||||
Si la validation réussit, le corps de la réponse est une donnée iCalendar valide, prête à être analysée.
|
|
||||||
|
|
||||||
### Données échangées
|
|
||||||
|
|
||||||
- Le client envoie une seule requête HTTP GET vers l'URL iCal sécurisée.
|
|
||||||
- En-têtes de requête : `Accept: text/calendar, */*;q=0.5` et `User-Agent: <identifiant-client>`.
|
|
||||||
- Le serveur retourne une charge utile iCalendar (RFC 5545) si le jeton est valide.
|
|
||||||
- Si le jeton est invalide ou expiré, le serveur retourne une réponse non-calendrier (typiquement du HTML).
|
|
||||||
- Aucun cookie, jeton de session ou en-tête d'authentification n'est échangé — l'URL **est** la crédentiale.
|
|
||||||
|
|
||||||
### Identifiants et jetons
|
|
||||||
|
|
||||||
Le seul identifiant utilisé est le jeton `icalsecurise`, intégré directement dans l’URL. Ce jeton est :
|
|
||||||
- **Unique** par utilisateur et par établissement.
|
|
||||||
- **Sensible** : il équivaut à un mot de passe et doit être traité comme tel.
|
|
||||||
- **Transmis en clair** dans la chaîne de requête de l’URL, mais protégé en transit par HTTPS.
|
|
||||||
- **Autosuffisant** : aucune autre information (nom d’utilisateur, mot de passe, cookie de session ou jeton OAuth) n’est requise. L’URL **est** l’authentification.
|
|
||||||
|
|
||||||
### Cycle de vie
|
### Cycle de vie
|
||||||
|
- **Pérennité** : Le jeton reste valide jusqu'à :
|
||||||
|
- Révocation manuelle par l'utilisateur dans Pronote.
|
||||||
|
- Régénération par l'établissement (ex. à la rentrée scolaire).
|
||||||
|
⚠️ **Hypothèse à valider** : La durée exacte dépend des politiques de l'établissement (non documentée officiellement).
|
||||||
|
|
||||||
Le jeton est **pérenne** : il reste valide jusqu’à sa révocation manuelle par l’utilisateur dans les paramètres Pronote, ou jusqu’à sa régénération par l’établissement (généralement à la rentrée scolaire).
|
🔹 **Source** : ⚠️ Comportement variable selon les instances (à tester localement).
|
||||||
Aucun mécanisme de rafraîchissement automatique ou de rotation n’existe. En cas d’expiration ou de rotation, le serveur retourne une réponse non-iCalendar (souvent une page HTML mentionnant *« Session expirée »*). Le client détecte cette situation en vérifiant l’absence de `BEGIN:VCALENDAR` dans le corps de la réponse.
|
|
||||||
|
|
||||||
La récupération nécessite une **réextraction manuelle** d’une nouvelle URL iCal depuis l’interface Pronote.
|
|
||||||
**Bonnes pratiques** : tester l’URL avant chaque rentrée et la régénérer proactivement si l’établissement est connu pour rotater les jetons à cette période.
|
|
||||||
|
|
||||||
### Sécurité
|
### Sécurité
|
||||||
|
1. **Surface d'attaque** : Le jeton est encodé dans l'URL → risque d'exposition via :
|
||||||
|
- Logs serveur/proxy.
|
||||||
|
- En-tête `Referer`.
|
||||||
|
- Historique du navigateur.
|
||||||
|
2. **Recommandations** :
|
||||||
|
- **Ne jamais versionner** l'URL (ex. dans `.env` ou Git).
|
||||||
|
- Utiliser **HTTPS** (obligatoire).
|
||||||
|
- Masquer l'URL dans les logs (ex. via `redact_url()` dans `pronote-sync`).
|
||||||
|
|
||||||
1. **Surface d’attaque** : Le jeton est encodé dans l’URL. Il peut être exposé via les logs d’accès du serveur, les logs proxy, les en-têtes `Referer`, l’historique du navigateur ou une interception réseau (atténué par HTTPS).
|
🔹 **Source** : ⚠️ Recommandation du projet (inspirée des bonnes pratiques générales de sécurité).
|
||||||
2. **Exposition des identifiants** : En cas de fuite, le jeton accorde un accès en lecture à l’emploi du temps (et aux devoirs, si inclus) jusqu’à sa rotation par l’établissement.
|
|
||||||
3. **Résistance au rejeu** : Faible — absence de *nonce*, de validation temporelle ou de protection contre le *replay*. Toute entité disposant de l’URL peut récupérer les données à tout moment.
|
|
||||||
4. **Rotation** : Manuellement uniquement, via la régénération de l’URL dans Pronote. L’établissement contrôle les réinitialisations côté serveur.
|
|
||||||
5. **Recommandations** : Conserver l’URL comme un secret (jamais en contrôle de version). Utiliser HTTPS (par défaut). Limiter l’accès à l’URL en besoin d’en connaître. Rotater proactivement aux changements d’année scolaire. Masquer l’URL dans les messages d’erreur et les logs pour éviter les fuites accidentelles.
|
|
||||||
|
|
||||||
### Limitations
|
### Intégration dans `pronote-sync`
|
||||||
|
- **Paramètre** : `PRONOTE_ICAL_URL` (ex. `.env.example` ligne 2).
|
||||||
|
- **Comportement** :
|
||||||
|
- Prioritaire en mode `PRONOTE_AGENDA_SOURCE=auto`.
|
||||||
|
- Si l'URL est invalide ou expire, repli automatique vers `pronotepy` (si `PRONOTE_AGENDA_SOURCE=auto`).
|
||||||
|
- **Aucun repli** si `PRONOTE_AGENDA_SOURCE=ical` (échec explicite).
|
||||||
|
|
||||||
- **Périmètre des données** : limité à l’emploi du temps et, si inclus par l’établissement, aux devoirs (*cahier de textes*).
|
🔹 **Source** : 🔍 `pronote_sync/config/settings.py` + `pronote_sync/sources/ical.py`.
|
||||||
- **Accès en lecture seule** : aucune modification possible.
|
|
||||||
- **Exclusions** : notes, absences, messagerie, bulletins ou paramètres sont inaccessibles.
|
|
||||||
- **Latence** : l’export iCal peut présenter un délai de mise à jour (plusieurs heures) avant de refléter les modifications Pronote.
|
|
||||||
- **Rafraîchissement** : impossible par programmation — une intervention manuelle est toujours requise.
|
|
||||||
|
|
||||||
### Statut
|
---
|
||||||
|
|
||||||
**Officiel** — L’export iCal est une fonctionnalité supportée par Pronote, éditée par Index Éducation. Le mécanisme de jeton fait partie intégrante du produit, bien que son format interne et sa logique de génération ne soient pas documentés publiquement.
|
## Méthode 2 : Connexion directe (identifiant / mot de passe + ENT)
|
||||||
|
|
||||||
## Méthode 2 : Connexion directe (identifiant / mot de passe)
|
|
||||||
|
|
||||||
### Principe général
|
### Principe général
|
||||||
La connexion directe utilise un protocole propriétaire de type JSON sur HTTP(S), sécurisé par un chiffrement AES-256-CBC spécifique à la session et un mécanisme de défi-réponse. Il s’agit du protocole natif de Pronote, tel qu’utilisé par son interface web.
|
Connexion via le protocole propriétaire de Pronote (JSON sur HTTPS), avec **chiffrement AES-CBC utilisant une clé dérivée par MD5 (16 octets, soit AES-128) comme implémenté dans pronotepy 2.15.7** et mécanisme de défi-réponse. Utilisé par l'interface web.
|
||||||
|
|
||||||
### Flux détaillé
|
✅ **Statut** : **Observé dans le code local** (délégable à pronotepy via `ParentClient` ou `Client`).
|
||||||
|
|
||||||
**Étape 1 — Initialisation de la session (`GET /pronote/<espace>.html`)**
|
### Flux d'authentification
|
||||||
Le client effectue une requête GET vers l’espace cible (ex. `/pronote/eleve.html` pour un élève, `/pronote/parent.html` pour un parent). La réponse HTML contient un gestionnaire JavaScript `onload` avec les paramètres de session :
|
1. **Initialisation** : Requête GET vers `/pronote/{espace}.html` (ex. `parent.html`).
|
||||||
|
- Récupère `h` (ID de session), `a` (ID espace), `sCrA`/`sCoA` (drapeaux chiffrement/compression).
|
||||||
|
2. **Échange de clés** : Requête POST vers `/pronote/appelfonction/{a}/{h}/{numeroOrdre}`.
|
||||||
|
3. **Identification** : Soumission de `identifiant`, `genreConnexion=0`, `genreEspace={a}`.
|
||||||
|
4. **Résolution du défi** :
|
||||||
|
- Calcul de `mtp = MAJUSCULE(HEX(SHA256(alea + mot_de_passe)))`.
|
||||||
|
- Dérivation de `key_challenge = MD5(nom_utilisateur + mtp)`.
|
||||||
|
- Déchiffrement du `challenge` avec **AES-CBC utilisant une clé dérivée par MD5 (16 octets, soit AES-128)**.
|
||||||
|
5. **Authentification** : Soumission de la réponse au défi.
|
||||||
|
|
||||||
|
🔹 **Source** : 🔍 Observé dans le code local de `pronotepy` 2.15.7 (module `clients.py` et `pronoteAPI.py`, vérifié le 2026-09-12).
|
||||||
|
|
||||||
|
### Intégration dans `pronote-sync`
|
||||||
|
- **Paramètres** :
|
||||||
|
- `PRONOTE_URL` (ex. `.env.example` ligne 3).
|
||||||
|
- `PRONOTE_USERNAME`, `PRONOTE_PASSWORD`.
|
||||||
|
- `PRONOTE_ENT` (slug dans `_ENT_NAMES`).
|
||||||
|
- `PRONOTE_ACCOUNT_TYPE` (ex. `parent`).
|
||||||
|
- **Comportement** :
|
||||||
|
- Utilise `pronotepy.ParentClient` pour les comptes parents.
|
||||||
|
- **Repli** : Si iCal échoue en mode `auto`, `pronotepy` est utilisé.
|
||||||
|
- **Pas de repli** si `PRONOTE_AGENDA_SOURCE=pronotepy` (échec explicite).
|
||||||
|
|
||||||
|
🔹 **Source** : 🔍 `pronote_sync/sources/pronote/client.py` (méthode `_connect_password`).
|
||||||
|
|
||||||
|
### ENT supportés
|
||||||
|
Liste **fermée** des **30 ENT** résolubles (lignes 52–83 de `pronote_sync/sources/pronote/client.py`) :
|
||||||
|
```python
|
||||||
|
_monbureaunumerique, ent_elyco, bordeaux, ent_creuse, occitanie_montpellier, ...
|
||||||
```
|
```
|
||||||
Session initialization parameters:
|
|
||||||
h = <session_id>
|
|
||||||
a = <espace_id> (3 = Élève, 7 = Parent)
|
|
||||||
sCrA = <encryption_flag>
|
|
||||||
sCoA = <compression_flag>
|
|
||||||
```
|
|
||||||
- `h` : identifiant de session (chaîne ou nombre unique).
|
|
||||||
- `a` : identifiant de l’espace (`3` pour Élève, `7` pour Parent).
|
|
||||||
- `sCrA` / `sCoA` : indicateurs de chiffrement et de compression.
|
|
||||||
|
|
||||||
**Étape 2 — Échange de clés (`POST /pronote/appelfonction/<espace_id>/<session_id>/<numeroOrdre>`)**
|
⚠️ **Hypothèse à valider** : Les ENT non listés nécessitent une contribution à `pronotepy`.
|
||||||
Le client envoie une charge utile `FonctionParametres` contenant `donneesSec.donnees.Uuid` :
|
|
||||||
- En HTTPS : un IV AES de 16 octets encodé en base64.
|
|
||||||
- En HTTP non sécurisé : un IV chiffré avec RSA-1024.
|
|
||||||
- `numeroOrdre` est un compteur incrémental (début à 1).
|
|
||||||
- Pour la première requête, `numeroOrdre` est chiffré avec AES-256-CBC, une clé vide MD5 (`d41d8cd98f00b204e9800998ecf8427e`) et un IV nul.
|
|
||||||
- Pour les requêtes suivantes, l’IV de session (issu de `Uuid`) est utilisé.
|
|
||||||
|
|
||||||
**Étape 3 — Identification (`POST ... / Identification`)**
|
🔹 **Source** : 🔍 Code source local de `pronote-sync` (2026-09-12).
|
||||||
Le client soumet une charge utile JSON :
|
|
||||||
```
|
|
||||||
{
|
|
||||||
"nom": "Identification",
|
|
||||||
"session": "<session_id>",
|
|
||||||
"numeroOrdre": "<compteur_chiffré>",
|
|
||||||
"donneesSec": {
|
|
||||||
"donnees": {
|
|
||||||
"identifiant": "<nom_utilisateur>",
|
|
||||||
"genreConnexion": 0,
|
|
||||||
"genreEspace": <espace_id>,
|
|
||||||
"pourENT": false,
|
|
||||||
"enConnexionAuto": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Le serveur retourne : `alea` (sel aléatoire), `challenge` (chaîne hexadécimale), `modeCompLog` et `modeCompMdp` (indicateurs de normalisation de casse).
|
|
||||||
|
|
||||||
**Étape 4 — Résolution du défi**
|
---
|
||||||
Le client calcule le hachage du mot de passe :
|
|
||||||
```
|
|
||||||
mtp = MAJUSCULE(HEX(SHA256(alea + mot_de_passe_utilisateur)))
|
|
||||||
```
|
|
||||||
La clé de déchiffrement du défi est dérivée :
|
|
||||||
```
|
|
||||||
key_challenge = MD5(nom_utilisateur + mtp)
|
|
||||||
```
|
|
||||||
Le client déchiffre `challenge` avec AES-256-CBC, `key_challenge` et l’IV de session. Il supprime ensuite un caractère sur deux dans le texte en clair (ex. `abcdef` → `ace`), puis rechiffre la chaîne modifiée avec les mêmes paramètres AES et l’encode en hexadécimal.
|
|
||||||
|
|
||||||
**Étape 5 — Authentification (`POST ... / Authentification`)**
|
## Méthode 3 : QR Code + token mobile (pour `pronote-sync`)
|
||||||
Le client envoie la réponse au défi via la fonction `Authentification`. Le serveur retourne les métadonnées utilisateur et une chaîne `cle` (entiers séparés par des virgules). Le client déchiffre `cle`, analyse les octets et calcule `MD5(octets)` pour obtenir la clé principale de chiffrement de session pour toutes les appels API ultérieurs.
|
|
||||||
|
|
||||||
### Données échangées
|
|
||||||
Identifiant de session, identifiant d’espace, IV AES (base64 ou chiffré RSA), compteur `numeroOrdre`, sel `alea`, chaîne de défi `challenge`, clé de session (`cle`). Toutes les données sensibles sont chiffrées en transit (AES-256-CBC).
|
|
||||||
|
|
||||||
### Identifiants et jetons
|
|
||||||
- **Identifiants** : nom d’utilisateur Pronote et mot de passe attribués par l’établissement.
|
|
||||||
- **Jetons de session** : identifiant de session (`h`), compteur de séquence (`numeroOrdre`), clé symétrique AES-256 (dérivée de `cle`).
|
|
||||||
|
|
||||||
### Cycle de vie
|
|
||||||
- Les identifiants de session expirent après une courte période d’inactivité (généralement 15 à 30 minutes).
|
|
||||||
- La clé de session doit être utilisée pour tous les appels API ultérieurs dans la même session.
|
|
||||||
- Une réauthentification est nécessaire après expiration de la session.
|
|
||||||
|
|
||||||
### Sécurité
|
|
||||||
1. **Surface d’attaque** : protocole propriétaire avec cryptographie personnalisée. Le point de terminaison de connexion est accessible depuis Internet. Les attaques MITM sont atténuées par HTTPS (mais RSA-1024 est utilisé pour l’échange d’IV en HTTP non sécurisé, ce qui est faible selon les normes modernes).
|
|
||||||
2. **Exposition des identifiants** : le nom d’utilisateur est transmis dans la requête d’identification (chiffré). Le mot de passe n’est jamais transmis en clair : seule la réponse au défi est envoyée.
|
|
||||||
3. **Résistance au rejeu** : modérée. Le mécanisme de défi-réponse utilise un sel aléatoire (`alea`) par session, rendant difficile le rejou d’une réponse de défi capturée. Cependant, le compteur `numeroOrdre` doit être géré avec soin pour éviter toute manipulation de séquence.
|
|
||||||
4. **Rotation** : aucune rotation automatique des jetons. La rotation des mots de passe dépend de la politique de l’établissement. Les clés de session expirent avec la session.
|
|
||||||
5. **Recommandations** : utiliser systématiquement HTTPS. Implémenter une gestion rigoureuse de `numeroOrdre`. Stocker les identifiants de manière sécurisée. Noter que la cryptographie personnalisée n’est pas équivalente à une authentification TLS standard : s’appuyer sur HTTPS pour la sécurité du transport.
|
|
||||||
|
|
||||||
### Limitations
|
|
||||||
- La plupart des établissements secondaires français liés à un ENT ou à EduConnect bloquent les connexions directes par nom d’utilisateur/mot de passe et imposent le SSO.
|
|
||||||
- Le protocole propriétaire n’est pas officiellement documenté et peut changer sans préavis.
|
|
||||||
- La cryptographie personnalisée (AES-256-CBC avec des clés dérivées de MD5) est non standard et n’a pas fait l’objet d’un audit indépendant.
|
|
||||||
|
|
||||||
### Statut
|
|
||||||
**Rétro-conçu** — Index Éducation ne publie pas le protocole. Toutes les connaissances proviennent de l’analyse communautaire du client web Pronote en JavaScript.
|
|
||||||
|
|
||||||
## Méthode 3 : ENT (Espace Numérique de Travail)
|
|
||||||
|
|
||||||
### Principe général
|
### Principe général
|
||||||
La plupart des collèges et lycées français accèdent à Pronote via un ENT (Espace Numérique de Travail) régional ou départemental. L’ENT agit comme fournisseur d’identité (IdP) : l’utilisateur s’authentifie auprès de l’ENT, qui établit ensuite une session avec Pronote par SSO (Single Sign-On). Pronote reçoit des identifiants délégués sans gérer directement la connexion.
|
Mécanisme d'appairage par QR code pour les appareils mobiles, **contournant l'authentification ENT/EduConnect**. Le QR code est généré **depuis l'interface web Pronote, espace parent → paramètres → QR code**, puis utilisé avec un **PIN à 4 chiffres** pour obtenir un token dont la durée dépend de l'instance/serveur.
|
||||||
|
|
||||||
### Flux détaillé
|
✅ **Statut** : **Pris en charge** par `pronote-sync` (mode `PRONOTE_AUTH_MODE=qr_token`).
|
||||||
1. **Connexion ENT** : L’utilisateur soumet ses identifiants à l’endpoint de connexion spécifique à l’ENT. Chaque ENT utilise son propre mécanisme (formulaire, CAS, SAML, Keycloak, etc.).
|
|
||||||
|
|
||||||
2. **Redirection SSO vers Pronote** : L’ENT redirige la session authentifiée vers Pronote via un lien connecteur ou une URL proxy (ex. `/cas/proxySSO/...` ou un lien SAML direct). L’ENT valide la session et redirige vers l’instance Pronote de l’établissement avec des cookies ou assertions SAML valides.
|
### Procédure QR pour `pronote-sync`
|
||||||
|
|
||||||
3. **Handshake Pronote** : La réponse HTML initiale de Pronote contient des identifiants temporaires dans l’attribut `onload` du `<body>` :
|
#### Étape 1 : Génération du QR code (interface web)
|
||||||
|
1. Se connecter à Pronote via un navigateur (espace **Parent**).
|
||||||
|
2. Aller dans **Paramètres → Accès mobile / Application mobile**.
|
||||||
|
3. Définir un **PIN temporaire à 4 chiffres**.
|
||||||
|
4. Pronote affiche un **QR code** contenant un JSON avec les clés :
|
||||||
|
`login`, `jeton`, et `url` (ex. `https://[host]/pronote/mobile.parent.html`).
|
||||||
|
**Le fichier JSON réel contient des identifiants chiffrés et ne doit jamais être copié, partagé, ou committé.**
|
||||||
|
5. **Exporter le QR code** :
|
||||||
|
- Sauvegardez le fichier JSON localement ou scannez-le avec un appareil.
|
||||||
|
- **Ne jamais partager** le JSON ou le PIN.
|
||||||
|
- **Le fichier JSON du QR code contient des identifiants chiffrés : ne jamais le coller dans la documentation ni le committer.**
|
||||||
|
|
||||||
|
🔹 **Source** : 🔍 Observé localement dans l'interface web Pronote (version non spécifiée, hypothèse à valider).
|
||||||
|
|
||||||
|
#### Étape 2 : Configuration de `pronote-sync`
|
||||||
|
1. **Enregistrer le QR code** :
|
||||||
|
- Sauvegarder le JSON dans un fichier (ex. `/path/to/qr_code.json`).
|
||||||
|
- **Permissions** : `chmod 600 /path/to/qr_code.json`.
|
||||||
|
2. **Configurer `.env`** :
|
||||||
|
```ini
|
||||||
|
PRONOTE_AUTH_MODE=qr_token
|
||||||
|
PRONOTE_QR_CODE_FILE=/path/to/qr_code.json
|
||||||
|
PRONOTE_QR_PIN= # renseigner localement la valeur secrète du PIN (jamais committée)
|
||||||
```
|
```
|
||||||
Session initialization parameters:
|
**⚠️ Note** : `.env.example` ne doit **jamais** contenir de PIN concret.
|
||||||
e = <temp_login>
|
|
||||||
f = <temp_auth_token>
|
|
||||||
...
|
|
||||||
```
|
|
||||||
- `e` : chaîne de connexion temporaire (unique par session SSO).
|
|
||||||
- `f` : jeton d’authentification temporaire.
|
|
||||||
|
|
||||||
4. **Challenge de session** : Le client exécute la requête standard `Identification` de Pronote (comme en Méthode 2) avec `pourENT: true`. Le challenge est résolu via une dérivation simplifiée :
|
🔹 **Source** : 🔍 Observé dans `.env.example` (lignes 21–25) et `pronote_sync/sources/pronote/client.py`.
|
||||||
```
|
|
||||||
key_ENT = MD5(UPPERCASE(HEX(SHA256(f))))
|
|
||||||
```
|
|
||||||
Cela remplace la dérivation `MD5(username + mtp)` utilisée en connexion directe. Aucun mot de passe utilisateur n’est nécessaire : le jeton `f` émis par l’ENT sert de justificatif.
|
|
||||||
|
|
||||||
|
#### Étape 3 : Premier login (enrôlement)
|
||||||
### Architectures ENT prises en charge
|
1. `pronote-sync` lit `PRONOTE_QR_CODE_FILE` et `PRONOTE_QR_PIN`.
|
||||||
|
2. Appel à `pronotepy.ParentClient.qrcode_login(qr_code, pin, uuid)` :
|
||||||
| Architecture | Exemples | Mécanisme SSO |
|
- `qr_code` : JSON du fichier QR.
|
||||||
|---|---|---|
|
- `pin` : PIN à 4 chiffres.
|
||||||
| Open ENT NG / Open Digital Education | ent.iledefrance.fr, Paris Classe Numérique, Mon Collège Val d’Oise, L’Éduc de Normandie | SAML / redirection |
|
- `uuid` : UUID permanent généré par `pronote-sync` (ex. `pronote-sync-{uuid4()}`).
|
||||||
| Kosmos / Skolengo CAS | Mon Bureau Numérique, Mon-ENT-Occitanie, Cybercollèges42 | CAS |
|
3. **Déchiffrement** :
|
||||||
| Oze ENT | (divers) | Keycloak avec endpoints proxy `/v1/ozapps` |
|
- Algorithme : **AES-CBC utilisant une clé dérivée par MD5 (16 octets, soit AES-128)**.
|
||||||
| WAYF / Shibboleth | e-lyco (Pays de la Loire) | SAML / Shibboleth |
|
- Clé : `MD5(PIN)` (dérivée du PIN secret).
|
||||||
| Portails personnalisés | Atrium Sud, LaClasse Lyon | Formulaires simples |
|
- IV : 16 octets nuls.
|
||||||
|
|
||||||
|
|
||||||
### Données échangées
|
|
||||||
Cookies/assertions de session ENT → identifiants temporaires Pronote (`e`, `f`) → session Pronote (via challenge-response avec clé dérivée de l’ENT).
|
|
||||||
|
|
||||||
|
|
||||||
### Identifiants et jetons
|
|
||||||
- **Identifiants principaux** : nom d’utilisateur et mot de passe ENT (spécifiques à chaque plateforme ENT).
|
|
||||||
- **Identifiants délégués** : `e` (connexion temporaire) et `f` (jeton) émis par Pronote après la redirection SSO.
|
|
||||||
- **Jeton de session** : clé AES-256 standard de Pronote (identique à la Méthode 2, dérivée après résolution du challenge).
|
|
||||||
|
|
||||||
|
|
||||||
### Cycle de vie
|
|
||||||
- Les sessions ENT sont temporaires : leur durée dépend des politiques de chaque plateforme.
|
|
||||||
- La session Pronote établie via l’ENT suit le même cycle qu’une session en connexion directe (timeout d’inactivité ~15–30 min).
|
|
||||||
- Les tâches automatisées récurrentes doivent se réauthentifier régulièrement auprès de l’ENT.
|
|
||||||
|
|
||||||
|
|
||||||
### Sécurité
|
|
||||||
1. **Surface d’attaque** : Les chaînes de redirection multiples (ENT → Pronote) augmentent la surface d’attaque. Chaque redirection est une opportunité d’interception de jetons.
|
|
||||||
2. **Exposition des identifiants** : Les identifiants utilisateur n’atteignent jamais Pronote directement. L’ENT agit comme intermédiaire de confiance. Le jeton `f` est éphémère (valide uniquement pour l’établissement initial de la session).
|
|
||||||
3. **Résistance au rejeu** : Le mécanisme challenge-response (identique à la Méthode 2) offre une résistance au rejeu pour la session Pronote. Les jetons de redirection SSO sont à usage unique.
|
|
||||||
4. **Rotation** : Le cycle de vie de la session ENT contrôle la rotation des identifiants. La clé de session Pronote est rotative par session.
|
|
||||||
5. **Recommandations** : Valider les certificats SSL à chaque étape de redirection. Ne pas journaliser les jetons intermédiaires. Les formulaires de connexion ENT évoluent fréquemment, ce qui peut rompre les clients automatisés.
|
|
||||||
|
|
||||||
|
|
||||||
### Limitations
|
|
||||||
- Les modifications des formulaires web de connexion ENT, des endpoints SAML ou de l’authentification multifacteur (MFA) rompent souvent les clients automatisés sans interface.
|
|
||||||
- Chaque ENT possède un flux de connexion différent : aucune automatisation universelle n’est possible.
|
|
||||||
- Les sessions ENT sont temporaires ; les tâches automatisées récurrentes doivent se réauthentifier régulièrement.
|
|
||||||
- Certains ENT implémentent du MFA ou des CAPTCHA empêchant une automatisation complète.
|
|
||||||
|
|
||||||
|
|
||||||
### Statut
|
|
||||||
Implémentation SSO standardisée par Index Éducation et les éditeurs d’ENT, mais la consommation du protocole par des clients tiers est **reverse-engineered**.
|
|
||||||
|
|
||||||
## Méthode 4 : EduConnect
|
|
||||||
|
|
||||||
### Principe général
|
|
||||||
EduConnect est le service national d’authentification et de gestion des accès opéré par le Ministère de l’Éducation Nationale et de la Jeunesse (MENJ). Il agit comme fournisseur d’identité (IdP) pour les élèves et parents en France. L’authentification vers Pronote s’effectue selon deux modes, selon l’infrastructure de l’établissement.
|
|
||||||
|
|
||||||
### Flux détaillé
|
|
||||||
|
|
||||||
### Mode A : EduConnect via ENT régional
|
|
||||||
1. Le client initie une requête vers la page de connexion de l’ENT régional avec le paramètre `selection=EDU_parent_eleve`.
|
|
||||||
2. L’ENT redirige vers l’endpoint SAML2 d’EduConnect :
|
|
||||||
```
|
|
||||||
https://educonnect.education.gouv.fr/idp/profile/SAML2/Unsolicited/SSO
|
|
||||||
```
|
|
||||||
3. Le client soumet les identifiants à EduConnect :
|
|
||||||
- `j_username` : identifiant EduConnect,
|
|
||||||
- `j_password` : mot de passe EduConnect,
|
|
||||||
- `_eventId_proceed` : chaîne vide.
|
|
||||||
4. EduConnect retourne un formulaire `SAMLResponse` signé, posté vers le service de consommation d’assertions de l’ENT (ex. `/Shibboleth.sso/SAML2/POST`).
|
|
||||||
5. L’ENT établit des cookies de session et redirige vers Pronote (le flux ENT→Pronote suit alors la Méthode 3).
|
|
||||||
|
|
||||||
### Mode B : HubEduConnect direct (SSO Index Éducation Cloud)
|
|
||||||
Pour les établissements sans ENT régional :
|
|
||||||
1. Le client accède à la passerelle CAS centralisée d’Index Éducation :
|
|
||||||
```
|
|
||||||
https://hubeduconnect.index-education.net/EduConnect/cas/login?service=<URL_INSTANCE_PRONOTE>
|
|
||||||
```
|
|
||||||
2. La passerelle initie une requête SAML vers `educonnect.education.gouv.fr`.
|
|
||||||
3. L’utilisateur s’authentifie sur EduConnect (mêmes identifiants que le Mode A).
|
|
||||||
4. HubEduConnect reçoit l’assertion, la valide via une liste blanche, et redirige vers Pronote avec un ticket de service.
|
|
||||||
5. Pronote valide le ticket et établit la session (flux côté Pronote identique à la Méthode 2).
|
|
||||||
|
|
||||||
### Données échangées
|
|
||||||
- **Mode A** : Identifiants EduConnect → assertion SAML2 → cookies ENT → handshake SSO Pronote.
|
|
||||||
- **Mode B** : Identifiants EduConnect → assertion SAML2 → ticket CAS → session Pronote.
|
|
||||||
|
|
||||||
### Identifiants et jetons
|
|
||||||
- Identifiants principaux : identifiants nationaux EduConnect (gérés par le MENJ).
|
|
||||||
- Jetons intermédiaires : assertions SAML2 (Modes A/B), tickets de service CAS (Mode B).
|
|
||||||
- Jeton final : clé de session AES-256 Pronote (identique à la Méthode 2).
|
|
||||||
|
|
||||||
### Cycle de vie
|
|
||||||
- Les sessions EduConnect sont temporaires (durée définie par le MENJ).
|
|
||||||
- Le ticket CAS (Mode B) est à usage unique et consommé lors de la validation par Pronote.
|
|
||||||
- La session Pronote suit un timeout d’inactivité standard (~15–30 min).
|
|
||||||
|
|
||||||
### Sécurité
|
|
||||||
1. **Surface d’attaque** : Chaînes de redirections multiples (EduConnect → ENT/Hub → Pronote). Chaque redirection est un point d’interception. Les assertions SAML sont signées, réduisant les risques de contrefaçon.
|
|
||||||
2. **Exposition des identifiants** : Les identifiants EduConnect sont soumis directement à l’IdP EduConnect et ne transitent jamais vers Pronote ou l’ENT. Seule l’assertion SAML signée est transmise.
|
|
||||||
3. **Résistance au rejeu** : Les assertions SAML incluent des timestamps et sont à usage unique. Les tickets CAS le sont également.
|
|
||||||
4. **Rotation** : Gérée par le MENJ. Aucun mécanisme local.
|
|
||||||
5. **Recommandations** : Valider les signatures des assertions SAML. Ne pas mettre en cache les identifiants EduConnect. Les authentifications 2FA par SMS ou via FranceConnect ne sont pas gérables par des clients automatisés.
|
|
||||||
|
|
||||||
### Limitations
|
|
||||||
- Les authentifications 2FA par SMS ou FranceConnect ne sont pas contournables par des clients programmatiques.
|
|
||||||
- Le flux repose sur des redirections HTTP multiples, fragiles et sensibles à la gestion des cookies.
|
|
||||||
- Les identifiants nationaux sont hautement sensibles : leur compromission affecte tous les services éducatifs.
|
|
||||||
|
|
||||||
### Statut
|
|
||||||
**Service officiel (MENJ / Index Éducation)** — EduConnect est un service gouvernemental officiel. Cependant, l’interaction programmatique par des clients tiers relève du **reverse engineering**.
|
|
||||||
|
|
||||||
## Méthode 5 : CAS (Central Authentication Service)
|
|
||||||
|
|
||||||
### Principe général
|
|
||||||
CAS (Central Authentication Service) est le protocole SSO sous-jacent utilisé dans les réseaux scolaires propriétaires et les ENT régionaux pour autoriser l’accès à Pronote. Protocole standardisé (RFC 4520), il est implémenté côté serveur. Pronote délègue l’authentification au serveur CAS, sans gérer directement les identifiants.
|
|
||||||
|
|
||||||
### Flux détaillé
|
|
||||||
1. **Demande de ticket de service** :
|
|
||||||
Pronote redirige l’utilisateur vers le serveur CAS :
|
|
||||||
```
|
|
||||||
https://<cas-server>/login?service=https://<pronote-host>/pronote/<espace>.html
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Authentification CAS** :
|
|
||||||
L’utilisateur soumet ses identifiants (ou effectue une authentification fédérée via EduConnect) sur le formulaire CAS.
|
|
||||||
|
|
||||||
3. **Génération du ticket** :
|
|
||||||
CAS renvoie une redirection HTTP 302 vers Pronote avec un paramètre `ticket` :
|
|
||||||
```
|
|
||||||
https://<pronote-host>/pronote/<espace>.html?ticket=ST-XXXXX-cas
|
|
||||||
```
|
|
||||||
Le ticket, préfixé par `ST-` (Service Ticket), est à usage unique.
|
|
||||||
|
|
||||||
4. **Validation du ticket de service** :
|
|
||||||
Le backend Pronote contacte directement l’URL de validation CAS pour vérifier le ticket et obtenir les attributs utilisateur :
|
|
||||||
```
|
|
||||||
https://<cas-server>/serviceValidate?service=https://<pronote-host>/pronote/<espace>.html&ticket=ST-XXXXX-cas
|
|
||||||
```
|
|
||||||
Le serveur CAS retourne les attributs (ex. : code UAI de l’établissement, identifiant élève). Pronote génère la session active et injecte le contexte d’initialisation dans le HTML client (mécanisme `onload` identique aux Méthodes 2 et 3).
|
|
||||||
|
|
||||||
### Données échangées
|
|
||||||
Identifiants CAS → Validation serveur CAS → Ticket de service (`ST-XXXXX-cas`) → Réponse de validation (attributs utilisateur : UAI, identifiant élève) → Initialisation de la session Pronote.
|
|
||||||
|
|
||||||
### Identifiants et jetons
|
|
||||||
- **Identifiants principaux** : Nom d’utilisateur et mot de passe CAS (ou identifiants fédérés EduConnect).
|
|
||||||
- **Jeton** : Ticket de service CAS (`ST-`, à usage unique).
|
|
||||||
- **Jeton de session** : Clé de session AES-256 Pronote (identique à la Méthode 2).
|
|
||||||
|
|
||||||
### Cycle de vie
|
|
||||||
- Le ticket de service CAS est **à usage unique** : il est consommé lors de la validation et ne peut être réutilisé.
|
|
||||||
- La session Pronote résultante suit un timeout d’inactivité standard (~15–30 min).
|
|
||||||
- La durée de vie de la session CAS est régie par la politique de *Ticket-Granting Ticket* (TGT) du serveur CAS.
|
|
||||||
|
|
||||||
### Sécurité
|
|
||||||
1. **Surface d’attaque** : Protocole standardisé et documenté. La surface d’attaque concerne principalement le formulaire de connexion CAS et la transmission du ticket (protégée par HTTPS).
|
|
||||||
2. **Exposition des identifiants** : Les identifiants sont soumis uniquement au serveur CAS — Pronote ne les voit jamais. Seul le ticket de service est transmis à Pronote.
|
|
||||||
3. **Résistance au rejeu** : Élevée — les tickets de service sont à usage unique et liés à une URL de service spécifique.
|
|
||||||
4. **Rotation** : La rotation des TGT est gérée par la politique du serveur CAS. Les tickets de service expirent rapidement (généralement en quelques secondes ou minutes).
|
|
||||||
5. **Recommandations** : Utiliser systématiquement HTTPS. Valider le paramètre `service` pour éviter le vol de tickets via des URL de service malveillantes. Appliquer une gestion rigoureuse du cycle de vie des TGT.
|
|
||||||
|
|
||||||
### Limitations
|
|
||||||
- CAS est un protocole côté serveur : le serveur CAS doit être correctement configuré et accessible.
|
|
||||||
- L’appel `serviceValidate` s’effectue de serveur à serveur (backend Pronote → serveur CAS), nécessitant une connectivité réseau entre eux.
|
|
||||||
- Toutes les écoles n’utilisent pas CAS : certaines privilégient SAML ou des solutions SSO personnalisées.
|
|
||||||
|
|
||||||
### Statut
|
|
||||||
**Officiel** — CAS est un protocole standardisé (RFC 4520) officiellement implémenté côté backend Pronote et serveurs CAS.
|
|
||||||
|
|
||||||
## Méthode 6 : QR Code et jeton mobile
|
|
||||||
|
|
||||||
### Principe général
|
|
||||||
Pronote propose un mécanisme d’appairage par QR code pour connecter les appareils mobiles sans saisir les identifiants ENT complexes. L’utilisateur génère un QR code depuis l’interface web, définit un code PIN temporaire à 4 chiffres, puis le scanne avec l’application mobile. Le QR code contient des identifiants chiffrés qui, une fois déchiffrés, permettent un échange de jeton à longue durée de vie. Ce mécanisme contourne entièrement l’authentification ENT/EduConnect.
|
|
||||||
|
|
||||||
|
|
||||||
### Flux détaillé
|
|
||||||
|
|
||||||
**Phase 1 — Génération (interface web Pronote)**
|
|
||||||
1. Dans l’interface web, l’utilisateur accède à *Paramètres → Accès mobile / Application mobile*.
|
|
||||||
2. Il définit un code PIN temporaire à 4 chiffres.
|
|
||||||
3. Pronote affiche un QR code contenant un JSON chiffré :
|
|
||||||
```
|
|
||||||
{
|
|
||||||
"login": "<AES_HEX_encrypted_username>",
|
|
||||||
"jeton": "<AES_HEX_encrypted_token>",
|
|
||||||
"url": "https://<host>/pronote/mobile.eleve.html"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Phase 2 — Déchiffrement**
|
|
||||||
- Algorithme : **AES-256-CBC**.
|
|
||||||
- IV : 16 octets nuls (`0x00...`).
|
|
||||||
- Clé : `MD5(PIN_code_string)` (ex. `MD5("1234")`).
|
|
||||||
- Résultat : `login` et `jeton` en clair.
|
- Résultat : `login` et `jeton` en clair.
|
||||||
|
4. **Échange de token** : Pronote retourne un `jetonConnexionAppliMobile` (token dont la durée dépend de l'instance/serveur).
|
||||||
|
5. **Persistance** : `pronote-sync` sauvegarde les credentials via `export_credentials()` dans `.pronote_auth_state.json` (mode `0600`).
|
||||||
|
|
||||||
**Phase 3 — Échange d’appairage initial**
|
🔹 **Source** : 🔍 Observé dans le code local de `pronotepy` 2.15.7 (`clients.py` lignes 181–189) + `pronote_sync/sources/pronote/client.py` (méthode `_enroll_qr_code`).
|
||||||
1. Le client génère un UUID permanent (`uuidAppliMobile`).
|
|
||||||
2. Il envoie une requête à : `https://<host>/pronote/mobile.<espace>.html?login=true` (contourne la redirection ENT).
|
|
||||||
3. Requête `Identification` avec :
|
|
||||||
- `demandeConnexionAppliMobile: true`
|
|
||||||
- `demandeConnexionAppliMobileJeton: true`
|
|
||||||
- `uuidAppliMobile: "<DEVICE_UUID>"`
|
|
||||||
- `identifiant: "<decrypted_login>"`
|
|
||||||
4. Le défi est résolu avec `<decrypted_jeton>` comme mot de passe (mécanisme identique à la Méthode 2).
|
|
||||||
5. Le serveur retourne `jetonConnexionAppliMobile` (jeton à longue durée de vie).
|
|
||||||
|
|
||||||
**Phase 4 — Connexions ultérieures (auto-login)**
|
#### Étape 4 : Connexions ultérieures (auto-login)
|
||||||
- `identifiant: <decrypted_login>`
|
1. `pronote-sync` charge `.pronote_auth_state.json`.
|
||||||
- `uuidAppliMobile: <DEVICE_UUID>`
|
2. Appel à `pronotepy.ParentClient.token_login(**credentials)` :
|
||||||
- `enConnexionAppliMobile: true`
|
- `pronote_url`, `username`, `password` (token), `uuid`.
|
||||||
- Mot de passe pour le défi : `jetonConnexionAppliMobile`
|
3. **Rotation du token** : Le token est **remplacé uniquement si le serveur renvoie `jetonConnexionAppliMobile`** (observé dans `pronotepy` 2.15.7, `clients.py:382–387`).
|
||||||
- À chaque connexion réussie, Pronote retourne un nouveau `jetonConnexionAppliMobile` à conserver.
|
4. **Persistance** : Les credentials sont sauvegardés dans `.pronote_auth_state.json` après chaque opération réussie.
|
||||||
|
|
||||||
|
🔹 **Source** : 🔍 Observé dans le code local de `pronotepy` 2.15.7 (`clients.py` lignes 245–280) + `pronote_sync/sources/pronote/client.py` (méthode `_connect_qr_token`).
|
||||||
|
|
||||||
### Données échangées
|
### Cycle de vie des tokens
|
||||||
QR code JSON (login + jeton chiffrés + URL) → déchiffrement → `Identification` avec drapeaux mobiles → défi-réponse → `jetonConnexionAppliMobile`.
|
- **QR code** : Valide **~10 minutes** après génération (⚠️ **Hypothèse à valider** : cette durée n'est attestée que par un message d'exception dans `pronotepy` et n'est pas une garantie officielle/indépendante de l'instance).
|
||||||
|
- **`jetonConnexionAppliMobile`** :
|
||||||
|
- Durée **dépendante de l'instance/serveur** (observation du 2026-09-12, hypothèse : peut persister jusqu'à la fin de l'année scolaire, non garanti).
|
||||||
### Identifiants et jetons
|
- **Remplacé uniquement si le serveur renvoie `jetonConnexionAppliMobile`** (observé dans `pronotepy` 2.15.7, `clients.py:382–387`).
|
||||||
- **Initial** : Code PIN à 4 chiffres (temporaire, utilisé uniquement pour le déchiffrement du QR code).
|
- **Révocable** manuellement dans Pronote (Paramètres → Accès mobile).
|
||||||
- **Déchiffrés** : `login` et `jeton` extraits du QR code (usage unique pour l’appairage initial).
|
|
||||||
- **Persistants** : `uuidAppliMobile` (UUID de l’appareil, permanent) + `jetonConnexionAppliMobile` (jeton à longue durée de vie, rafraîchi à chaque connexion).
|
|
||||||
|
|
||||||
|
|
||||||
### Cycle de vie
|
|
||||||
- Le QR code est valide **10 minutes** après génération.
|
|
||||||
- Le `jetonConnexionAppliMobile` reste valide indéfiniment (souvent toute l’année scolaire), sauf :
|
|
||||||
- Révoqué par l’utilisateur dans les paramètres Pronote.
|
|
||||||
- Invalidé côté serveur.
|
|
||||||
- Le jeton est rafraîchi à chaque connexion réussie.
|
|
||||||
|
|
||||||
|
🔹 **Source** : ⚠️ Comportement variable selon les instances (à tester localement).
|
||||||
|
|
||||||
### Sécurité
|
### Sécurité
|
||||||
1. **Surface d’attaque** : Le QR code est affiché à l’écran (risque de *shoulder-surfing*). Le PIN à 4 chiffres offre 10 000 combinaisons. Le `jetonConnexionAppliMobile` est un identifiant à longue durée de vie stocké sur l’appareil.
|
1. **Fichiers sensibles** :
|
||||||
2. **Exposition des identifiants** : Le QR code contient des identifiants chiffrés. Si intercepté avant déchiffrement, l’attaquant a besoin du PIN. Une fois le `jetonConnexionAppliMobile` obtenu, aucun PIN ni mot de passe n’est requis.
|
- `.pronote_auth_state.json` : **Ne jamais versionner** (couvert par `.gitignore`).
|
||||||
3. **Résistance au rejeu** : Le `jetonConnexionAppliMobile` est un *bearer token* : toute partie en possession du jeton peut s’authentifier. Le `uuidAppliMobile` offre un lien faible avec l’appareil, mais non vérifié cryptographiquement.
|
- `PRONOTE_QR_CODE_FILE` : **Ne jamais committer** (ex. dans Git).
|
||||||
4. **Rotation** : Le `jetonConnexionAppliMobile` est rafraîchi à chaque connexion, mais l’ancien reste valide jusqu’à invalidation côté serveur. Aucune expiration automatique.
|
2. **Secrets** :
|
||||||
5. **Recommandations** : Utiliser un PIN robuste. Traiter le `jetonConnexionAppliMobile` comme un identifiant à longue durée de vie (stockage sécurisé). En cas de vol de l’appareil, révoquer l’accès mobile dans Pronote.
|
- Le PIN et le contenu du QR code sont **masqués** dans les logs (via `redact_secrets()`).
|
||||||
|
- Les exceptions sont **expurgées** (via `redact_exception()`).
|
||||||
|
3. **Recommandations** :
|
||||||
|
- Utiliser un **PIN robuste** (éviter les codes simples comme `0000` ou des séquences évidentes).
|
||||||
|
- **Révoquer** le token en cas de compromission (via Pronote web).
|
||||||
|
|
||||||
|
🔹 **Source** : 🔍 `pronote_sync/utils/redaction.py` + `pronote_sync/sources/pronote/client.py` (méthode `_collect_auth_secrets`).
|
||||||
|
|
||||||
### Limitations
|
### Erreurs et repli
|
||||||
- Le QR code expire après **10 minutes** : l’appairage doit être rapide.
|
- **`PronoteAuthRotationError`** : Levée si :
|
||||||
- Le PIN à 4 chiffres est faible selon les normes modernes.
|
- Le token persisté est **invalide/expiré**.
|
||||||
- Le `jetonConnexionAppliMobile` n’a pas d’expiration automatique : il persiste jusqu’à révocation manuelle.
|
- Le fichier QR ou le PIN est **manquant/invalide**.
|
||||||
- Le `uuidAppliMobile` n’est pas lié cryptographiquement à l’appareil : il peut être copié.
|
- **Action requise** :
|
||||||
|
1. Supprimer `.pronote_auth_state.json`.
|
||||||
|
2. Générer un **nouveau QR code depuis l'interface web Pronote, espace parent**.
|
||||||
|
3. Relancer `pronote-sync`.
|
||||||
|
|
||||||
|
🔹 **Source** : 🔍 Observé dans `pronote_sync/errors.py` + `pronote_sync/sources/pronote/client.py` (lignes 346–364).
|
||||||
|
|
||||||
### Statut
|
### Incompatibilités
|
||||||
Mécanisme **officiellement intégré** à l’application mobile Index Éducation. L’utilisation par des clients tiers repose sur de l’**ingénierie inverse**.
|
- **Mode `dry-run`** : **Incompatible** avec `PRONOTE_AUTH_MODE=qr_token` (risque de désynchronisation du token local).
|
||||||
|
- `pronote-sync --dry-run` **refuse** le mode `qr_token` avant toute connexion.
|
||||||
|
|
||||||
## Méthode 7 : API officielle et application mobile
|
🔹 **Source** : 🔍 `docs/exploitation.md` (ligne 65–66).
|
||||||
|
|
||||||
### Principe général
|
---
|
||||||
Index Éducation ne propose pas d’API publique pour Pronote. L’accès tiers aux données repose sur l’ingénierie inverse des mêmes endpoints JSON-over-HTTP(S) utilisés par les clients web et mobile officiels. L’application mobile officielle s’authentifie via le mécanisme d’appairage par QR Code (Méthode 6) et communique via le même protocole propriétaire que le client web.
|
|
||||||
|
|
||||||
### Flux détaillé
|
## Annexe A : Bibliothèques tierces
|
||||||
|
|
||||||
L'application mobile s'authentifie via le flux d'appairage par QR Code (Méthode 6) puis communique via le même protocole JSON-over-HTTP(S) que le client web, en utilisant les endpoints `/pronote/mobile.<espace>.html` et `/pronote/appelfonction/<espace_id>/<session_id>/<numeroOrdre>`.
|
| Bibliothèque | Langage | Dépôt | Méthodes supportées | Statut dans `pronote-sync` |
|
||||||
|
|--------------|---------|-------|---------------------|-----------------------------|
|
||||||
|
| **pronotepy** | Python | [bain3/pronotepy](https://github.com/bain3/pronotepy) | Connexion directe, QR code/token, **30 ENT** (liste fermée) | ✅ **Dépendance principale** (version **2.15.7** vérifiée). |
|
||||||
|
| **pronote-api** | TypeScript | [Litarvan/pronote-api](https://github.com/Litarvan/pronote-api) | Connexion directe, CAS, ENT | ❌ Non utilisée. |
|
||||||
|
| **pronote-qrcode-api** | JavaScript | [Androz2091/pronote-qrcode-api](https://github.com/Androz2091/pronote-qrcode-api) | Déchiffrement QR code | ❌ Non utilisée (intégration native via `pronotepy`). |
|
||||||
|
|
||||||
### Disponibilité d'une API développeur
|
🔹 **Source** : 🔍 Observé dans `pyproject.toml` (dépendances) + code source local (2026-09-12).
|
||||||
- **API publique** : Inexistante. Index Éducation ne propose ni API REST ni GraphQL pour les élèves, parents ou développeurs tiers.
|
|
||||||
- **API institutionnelle/entreprise** : Des services d’intégration propriétaires sont proposés pour les systèmes partenaires (ex. connecteurs UDTS, HYPERPLANNING, ENT officiels). Ceux-ci nécessitent des accords de partenariat signés et des licences serveurs institutionnelles.
|
|
||||||
|
|
||||||
### Authentification de l'application mobile officielle
|
---
|
||||||
L’application mobile officielle (iOS/Android) se connecte via les mêmes endpoints JSON-over-HTTP(S) que l’interface web mobile :
|
|
||||||
- Chemins de base : `/pronote/mobile.<espace>.html`
|
|
||||||
- Dispatcher de fonctions : `/pronote/appelfonction/<espace_id>/<session_id>/<numeroOrdre>`
|
|
||||||
- Authentification : Utilise le flux d’appairage par QR Code (Méthode 6) et le jeton mobile persistant (`jetonConnexionAppliMobile` + `uuidAppliMobile`).
|
|
||||||
|
|
||||||
### Données échangées
|
|
||||||
Mêmes charges utiles JSON chiffrées en AES-256-CBC que le client web (Méthode 2). La variante mobile (`mobile.<espace>.html`) contourne les redirections SSO ENT/EduConnect.
|
|
||||||
|
|
||||||
### Identifiants et jetons
|
|
||||||
- `jetonConnexionAppliMobile` : Jeton bearer à longue durée de vie (voir Méthode 6).
|
|
||||||
- `uuidAppliMobile` : UUID de l’appareil.
|
|
||||||
- Clé de session : Clé AES-256 (même dérivation que la Méthode 2).
|
|
||||||
|
|
||||||
### Cycle de vie
|
|
||||||
Le `jetonConnexionAppliMobile` est rafraîchi à chaque connexion. La durée de vie de la session suit le même délai d’inactivité (~15–30 min) que le client web.
|
|
||||||
|
|
||||||
### Sécurité
|
|
||||||
1. **Surface d’attaque** : Les endpoints mobiles sont accessibles publiquement. Aucune clé API ni enregistrement développeur n’existe — néanmoins, l’accès exige un matériel de session Pronote valide et, pour l’auto-login mobile, le matériel d’authentification correspondant (`login`, `uuidAppliMobile`, `jetonConnexionAppliMobile`) issu du flux d’appairage par QR Code (Méthode 6).
|
|
||||||
2. **Exposition des identifiants** : Le `jetonConnexionAppliMobile` est un jeton bearer stocké sur l’appareil. S’il est extrait, il accorde un accès complet jusqu’à révocation.
|
|
||||||
3. **Résistance au rejeu** : Faible — le jeton est basé sur un bearer. Le `uuidAppliMobile` offre un lien faible avec l’appareil, non appliqué cryptographiquement.
|
|
||||||
4. **Rotation** : Le jeton est rafraîchi à chaque connexion, mais les anciens restent valides. Aucune expiration automatique.
|
|
||||||
5. **Recommandations** : Évitez l’ingénierie inverse du protocole pour un usage en production sans comprendre les implications légales. Stockez les jetons mobiles de manière sécurisée. Soyez conscient que Index Éducation peut modifier le protocole à tout moment.
|
|
||||||
|
|
||||||
### Limitations
|
|
||||||
- Aucune documentation ou support officiel pour les développeurs tiers.
|
|
||||||
- Protocole propriétaire susceptible de changer sans préavis.
|
|
||||||
- Les implémentations basées sur l’ingénierie inverse peuvent cesser de fonctionner après les mises à jour de Pronote.
|
|
||||||
- Le statut légal de l’ingénierie inverse est incertain dans certaines juridictions.
|
|
||||||
|
|
||||||
### Statut
|
|
||||||
- API publique : **Inexistante**.
|
|
||||||
- Endpoints mobiles : **Ingénierie inverse** (même protocole que le client web, accessible via appairage QR Code).
|
|
||||||
|
|
||||||
## Annexe A : Bibliothèques open source tierces
|
|
||||||
|
|
||||||
Les bibliothèques open source ci-dessous implémentent les protocoles d'authentification Pronote décrits dans ce manuel. Ces projets sont maintenus par la communauté et ne sont pas affiliés à Index Éducation. Les fonctionnalités décrites reposent sur les informations publiques disponibles dans leurs dépôts et peuvent avoir évolué depuis la rédaction de ce document.
|
|
||||||
|
|
||||||
| Bibliothèque | Langage | Dépôt | Méthodes d'authentification prises en charge |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **pronotepy** | Python | `bain3/pronotepy` | Connexion directe, jeton mobile / QR code, CAS SSO, EduConnect (HubEduConnect direct et via 30+ ENT régionaux : Open ENT NG, Skolengo, Oze, Shibboleth/WAYF). |
|
|
||||||
| **pronote-api** | TypeScript / JS | `Litarvan/pronote-api` | Connexion directe, CAS SSO, redirections ENT, authentification par jeton. |
|
|
||||||
| **pronote-qrcode-api** | JavaScript | `Androz2091/pronote-qrcode-api` | Implémentation de référence pour le déchiffrement des QR codes Pronote et l'échange initial de jeton mobile. |
|
|
||||||
| **pawnote / Blocksnote** | TypeScript / JS | `BlocksHub/Blocksnote` | Clients TypeScript modernes implémentant le protocole Pronote complet (direct, ENT, QR code, jetons). |
|
|
||||||
|
|
||||||
Ces bibliothèques illustrent la faisabilité des méthodes d'authentification présentées. Leur utilisation en environnement de production comporte des risques : les modifications de protocole par Index Éducation peuvent rendre les implémentations obsolètes sans préavis, et le statut juridique de l'ingénierie inverse des protocoles propriétaires varie selon les juridictions.
|
|
||||||
|
|
||||||
## Annexe B : Tableau comparatif synthétique
|
## Annexe B : Tableau comparatif synthétique
|
||||||
|
|
||||||
Cette annexe consolide les caractéristiques clés des 7 méthodes d'authentification Pronote en un tableau de référence unique, incluant les dimensions d'analyse de sécurité.
|
| Méthode | Périmètre | Identifiants | Expiration | Complexité | Statut dans `pronote-sync` |
|
||||||
|
|---------|-----------|--------------|------------|-----------|-----------------------------|
|
||||||
| Méthode | Périmètre | Identifiants | Expiration | Complexité | Surface d'attaque | Exposition | Rejeu | Rotation | Recommandation | Statut |
|
| **URL iCal sécurisée** | EDT + devoirs (si inclus dans le flux) | Jeton URL | Longue durée (dépend de l'instance) | Très faible | ✅ Pris en charge |
|
||||||
|---------|-----------|--------------|------------|-----------|-------------------|-------------|-------|----------|----------------|--------|
|
| **Connexion directe (mot de passe + ENT)** | Agenda/EDT, devoirs, messages | Identifiant + mot de passe + ENT | Session dépendante de l'instance | Élevée | ✅ Pris en charge |
|
||||||
| **URL iCal sécurisée** | EDT + devoirs (si inclus) | Jeton URL | Longue durée | Très faible | Jeton dans URL (logs, referers) | Credential longue durée | Faible (pas de nonce) | Manuelle | Stocker en secret, HTTPS, rotate à la rentrée | Officiel |
|
| **QR Code + token mobile** | Agenda/EDT, devoirs, messages | PIN 4 chiffres → token + UUID | QR ~10 min (⚠️ hypothèse) ; token dépendant de l'instance | Modérée | ✅ Pris en charge |
|
||||||
| **Connexion directe** | Complet | Identifiant + mot de passe | Session ~15–30 min | Élevée | Endpoint public, crypto custom | Mot de passe non transmis (challenge) | Modérée (alea aléatoire) | Session seulement | HTTPS obligatoire, gérer `numeroOrdre` | Reverse-engineered |
|
| **SSO ENT (CAS/SAML)** | — | Identifiants ENT | Session ENT | Très élevée | ❌ Non implémenté (seuls les 30 ENT de la liste fermée `_ENT_NAMES` sont supportés via `pronotepy`) |
|
||||||
| **SSO ENT** | Complet | Identifiants ENT | Session ENT | Très élevée | Redirections multiples | Credentials via ENT (intermédiaire) | Jetons SSO single-use | Session ENT | Valider SSL à chaque redirection | Reverse-engineered |
|
| **EduConnect** | — | Identifiants nationaux | Session EduConnect | Très élevée | ❌ Non implémenté |
|
||||||
| **SSO EduConnect** | Complet | Identifiants nationaux | Session EduConnect | Très élevée | Redirections EduConnect→ENT/Hub | Credentials MENJ (très sensibles) | SAML single-use + timestamps | MENJ | Ne pas cacher les credentials, 2FA bloque automation | Reverse-engineered |
|
| **CAS direct** | — | Identifiants CAS | Ticket single-use | Modérée | ❌ Non implémenté |
|
||||||
| **CAS** | Complet | Identifiants CAS | Ticket single-use | Modérée | Login form CAS | Credentials via CAS uniquement | Ticket single-use | TGT (politique CAS) | Valider paramètre `service`, HTTPS | Officiel |
|
| **API publique** | N/A | N/A | N/A | N/A | ❌ Inexistante |
|
||||||
| **QR Code + jeton mobile** | Complet | PIN 4 chiffres → jeton + UUID | QR 10 min ; jeton longue durée | Modérée | QR écran, PIN faible, jeton bearer | Jeton longue durée stocké sur appareil | Faible (bearer) | À chaque login (ancien reste valide) | PIN fort, stockage sécurisé, révoquer si perte | Reverse-engineered |
|
|
||||||
| **API publique** | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | Inexistante |
|
|
||||||
|
|
||||||
**Légende** :
|
**Légende** :
|
||||||
- *Complet* : accès aux notes, emploi du temps, devoirs, absences, messagerie et paramètres.
|
- *Périmètre* : Les méthodes **iCal** sont en lecture seule (EDT + devoirs si inclus dans le flux). Les méthodes **Connexion directe** et **QR Code + token mobile** permettent les opérations `pronotepy` effectivement implémentées par ce projet (agenda, devoirs, messages ; informations ignorées en mode `qr_token`).
|
||||||
- *Reverse-engineered* : protocole non publié officiellement par Index Éducation, basé sur l'analyse communautaire.
|
- *QR ~10 min* : ⚠️ **Hypothèse non vérifiée** (observé dans `pronotepy` via un message d'exception, dépend de l'instance).
|
||||||
- *Officiel* : mécanisme fourni et pris en charge par Index Éducation.
|
- *Session dépendante de l'instance* : ⚠️ **Hypothèse non vérifiée** (nécessite un essai réel).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Annexe C : Écarts et notes de cohérence
|
||||||
|
|
||||||
|
### Alignement avec `.env.example`
|
||||||
|
| Paramètre | Document | Code | Statut |
|
||||||
|
|-----------|----------|------|--------|
|
||||||
|
| `PRONOTE_ICAL_URL` | ✅ Lignes 2, 42–50 | ✅ `sources/ical.py` | **Cohérent** |
|
||||||
|
| `PRONOTE_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** |
|
||||||
|
| `PRONOTE_QR_PIN` | ✅ Ligne 25 | ✅ `client.py` (ligne 388) | **Cohérent** |
|
||||||
|
| *« Le QR code se génère sur le site web »* | ✅ Ligne 21 | ✅ Section [Procédure QR](#procédure-qr-pour-pronote-sync) | **Cohérent** |
|
||||||
|
|
||||||
|
### Écarts avec `docs/exploitation.md`
|
||||||
|
| Élément | `exploitation.md` | Ce document | Action |
|
||||||
|
|---------|-------------------|-------------|--------|
|
||||||
|
| *« Le QR code expire ~10 minutes »* | Ligne 22 | ⚠️ **Hypothèse non vérifiée** (section [Cycle de vie](#cycle-de-vie-des-tokens)) | **Aucune** (hors périmètre). |
|
||||||
|
| *« Mode `qr_token` incompatible avec dry-run »* | Lignes 65–66 | ✅ Section [Incompatibilités](#incompatibilités) | **Cohérent** |
|
||||||
|
| *« Fichier `.pronote_auth_state.json` (mode 0600) »* | Lignes 70–75 | ✅ Section [Sécurité](#sécurité) | **Cohérent** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Glossaire
|
||||||
|
|
||||||
|
| Terme | Définition |
|
||||||
|
|-------|------------|
|
||||||
|
| **ENT** | Espace Numérique de Travail (ex. Mon Bureau Numérique, Paris Classe Numérique). |
|
||||||
|
| **Jeton `icalsecurise`** | Token secret intégré dans l'URL iCal, équivalent à un mot de passe. |
|
||||||
|
| **`jetonConnexionAppliMobile`** | Token obtenu après appairage QR code, dont la durée dépend de l'instance/du serveur et n'est pas garantie. |
|
||||||
|
| **UUID** | Identifiant unique permanent pour l'application (ex. `pronote-sync-{uuid4()}`). |
|
||||||
|
| **PIN** | Code à 4 chiffres défini lors de la génération du QR code. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Historique des révisions
|
||||||
|
|
||||||
|
| Date | Auteur | Modifications |
|
||||||
|
|------|--------|---------------|
|
||||||
|
| 2026-09-12 | Agent tech-writer | Refonte complète : qualification des affirmations, ajout des sources, tableau de compatibilité, procédure QR alignée sur pronotepy 2.15.7. |
|
||||||
|
| 2026-09-12 | Agent tech-writer | Corrections suite à revue indépendante (ticket #10) : précision cryptographie (AES-128), qualification des sources, alignement nombre d'ENT (30), clarification SSO/CAS, exemples non copiables, note d'essai réel. |
|
||||||
|
| 2026-09-12 | Agent tech-writer | Corrections suite à revue ticket #10 : suppression de tous les placeholders de secrets remplacés par de la prose descriptive ; clarification du périmètre réel des méthodes d'authentification (accès limité aux opérations implémentées) ; qualification de la durée du token `jetonConnexionAppliMobile` comme dépendante de l'instance/serveur et non garantie.
|
||||||
|
|||||||
Binary file not shown.
@@ -9,8 +9,8 @@
|
|||||||
%%
|
%%
|
||||||
%% INSTRUCTIONS DE COMPILATION :
|
%% INSTRUCTIONS DE COMPILATION :
|
||||||
%% Ce document est conçu pour être compilé avec LuaLaTeX :
|
%% Ce document est conçu pour être compilé avec LuaLaTeX :
|
||||||
%% lualatex -interaction=nonstopmode docs/pronote-auth.tex
|
%% TEXINPUTS="docs:" lualatex -interaction=nonstopmode -output-directory=docs docs/pronote-auth.tex
|
||||||
%% lualatex -interaction=nonstopmode docs/pronote-auth.tex
|
%% TEXINPUTS="docs:" lualatex -interaction=nonstopmode -output-directory=docs docs/pronote-auth.tex
|
||||||
%% (Deux passes nécessaires pour la génération de la table des matières,
|
%% (Deux passes nécessaires pour la génération de la table des matières,
|
||||||
%% des références croisées et des hyperliens).
|
%% des références croisées et des hyperliens).
|
||||||
%%
|
%%
|
||||||
@@ -52,5 +52,9 @@
|
|||||||
|
|
||||||
\include{annex-libraries}
|
\include{annex-libraries}
|
||||||
\include{annex-comparison}
|
\include{annex-comparison}
|
||||||
|
\include{annex-ecarts}
|
||||||
|
|
||||||
|
\include{glossaire}
|
||||||
|
\include{historique}
|
||||||
|
|
||||||
\end{document}
|
\end{document}
|
||||||
|
|||||||
+151
-33
@@ -36,6 +36,24 @@ logger = logging.getLogger(__name__)
|
|||||||
__all__ = ["XmppChannel", "SyncXmppChannel", "XmppMessage"]
|
__all__ = ["XmppChannel", "SyncXmppChannel", "XmppMessage"]
|
||||||
|
|
||||||
|
|
||||||
|
async def _cancel_pending(
|
||||||
|
*futures: asyncio.Future[bool],
|
||||||
|
) -> None:
|
||||||
|
"""Annule les futures/tâches encore en attente et supprime le bruit.
|
||||||
|
|
||||||
|
À appeler avant chaque retour anticipé de :meth:`XmppChannel.send_async`
|
||||||
|
afin qu'aucune tentative de connexion ne survive au retour de la méthode.
|
||||||
|
|
||||||
|
:param futures: Futures ou tâches à annuler (les déjà terminées sont
|
||||||
|
ignorées pour la cancellation mais attendues pour purger l'attente).
|
||||||
|
:rtype: None
|
||||||
|
"""
|
||||||
|
for future in futures:
|
||||||
|
if not future.done():
|
||||||
|
future.cancel()
|
||||||
|
await asyncio.gather(*futures, return_exceptions=True)
|
||||||
|
|
||||||
|
|
||||||
def _secret_values(settings: XmppSettings) -> tuple[SecretStr | str, ...]:
|
def _secret_values(settings: XmppSettings) -> tuple[SecretStr | str, ...]:
|
||||||
"""Rassemble les secrets du canal XMPP pour le masquage des logs.
|
"""Rassemble les secrets du canal XMPP pour le masquage des logs.
|
||||||
|
|
||||||
@@ -220,11 +238,14 @@ class XmppChannel:
|
|||||||
"""Exécute le flux asynchrone d'envoi XMPP (U2).
|
"""Exécute le flux asynchrone d'envoi XMPP (U2).
|
||||||
|
|
||||||
Connecte le client ``slixmpp`` avec un hôte et un port explicites,
|
Connecte le client ``slixmpp`` avec un hôte et un port explicites,
|
||||||
configure TLS avant la connexion, puis attend l'un des événements
|
configure TLS avant la connexion selon ``tls_mode`` (``direct``,
|
||||||
``session_start``, ``failed_auth`` ou ``disconnected`` sous un
|
``starttls`` ou ``disabled``), attend la connexion sous
|
||||||
timeout unique avant d'envoyer un message direct ``chat`` au
|
``connect_timeout`` puis l'un des événements ``session_start``,
|
||||||
|
``failed_auth``, ``connection_failed`` ou ``disconnected`` sous
|
||||||
|
``timeout`` avant d'envoyer un message direct ``chat`` au
|
||||||
destinataire configuré. La déconnexion est garantie par un bloc
|
destinataire configuré. La déconnexion est garantie par un bloc
|
||||||
``try/finally``. Aucun secret n'est journalisé (SEC-XMPP-02).
|
``try/finally`` borné par ``cleanup_timeout``. Aucun secret n'est
|
||||||
|
journalisé (SEC-XMPP-02).
|
||||||
|
|
||||||
:param message: Message final à envoyer.
|
:param message: Message final à envoyer.
|
||||||
:return: ``True`` si l'envoi a réussi (ou a été simulé en dry-run),
|
:return: ``True`` si l'envoi a réussi (ou a été simulé en dry-run),
|
||||||
@@ -244,55 +265,132 @@ class XmppChannel:
|
|||||||
logger.warning("Destinataire XMPP manquant.")
|
logger.warning("Destinataire XMPP manquant.")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Create typed client
|
# Client typed lazily: the construction is done inside the try block so that
|
||||||
client = ClientXMPP(
|
# any error is caught and converted to ``False`` (channel contract: never raise)
|
||||||
jid_str,
|
client: ClientXMPP | None = None
|
||||||
self.settings.password.get_secret_value() if self.settings.password else "",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Configure TLS BEFORE connect
|
|
||||||
if self.settings.use_tls:
|
|
||||||
# TLS direct (port 5223 typically)
|
|
||||||
client.enable_direct_tls = True
|
|
||||||
client.enable_starttls = False
|
|
||||||
else:
|
|
||||||
# STARTTLS (port 5222 typically)
|
|
||||||
client.enable_starttls = True
|
|
||||||
client.enable_direct_tls = False
|
|
||||||
|
|
||||||
# Register handlers
|
|
||||||
session_future: asyncio.Future[bool] = asyncio.get_event_loop().create_future()
|
session_future: asyncio.Future[bool] = asyncio.get_event_loop().create_future()
|
||||||
|
failure_kind = "disconnected"
|
||||||
|
# Declared before the ``try`` so the exception handlers (CancelledError and
|
||||||
|
# Exception) can cancel any task still pending from ``asyncio.wait()``
|
||||||
|
connect_future: asyncio.Future[bool] | None = None
|
||||||
|
session_task: asyncio.Future[bool] | None = None
|
||||||
|
|
||||||
def on_session_start(event: object) -> None:
|
def on_session_start(event: object) -> None:
|
||||||
if not session_future.done():
|
if not session_future.done():
|
||||||
session_future.set_result(True)
|
session_future.set_result(True)
|
||||||
|
|
||||||
def on_failed_auth(event: object) -> None:
|
def on_failed_auth(event: object) -> None:
|
||||||
|
nonlocal failure_kind
|
||||||
if not session_future.done():
|
if not session_future.done():
|
||||||
|
failure_kind = "failed_auth"
|
||||||
|
session_future.set_result(False)
|
||||||
|
|
||||||
|
def on_connection_failed(event: object) -> None:
|
||||||
|
nonlocal failure_kind
|
||||||
|
if not session_future.done():
|
||||||
|
failure_kind = "connection_failed"
|
||||||
session_future.set_result(False)
|
session_future.set_result(False)
|
||||||
|
|
||||||
def on_disconnected(event: object) -> None:
|
def on_disconnected(event: object) -> None:
|
||||||
if not session_future.done():
|
if not session_future.done():
|
||||||
session_future.set_result(False)
|
session_future.set_result(False)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create typed client
|
||||||
|
client = ClientXMPP(
|
||||||
|
jid_str,
|
||||||
|
self.settings.password.get_secret_value() if self.settings.password else "",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Configure TLS BEFORE connect (canonical tls_mode)
|
||||||
|
match self.settings.tls_mode:
|
||||||
|
case "direct":
|
||||||
|
client.enable_direct_tls = True
|
||||||
|
client.enable_starttls = False
|
||||||
|
case "starttls":
|
||||||
|
client.enable_direct_tls = False
|
||||||
|
client.enable_starttls = True
|
||||||
|
case "disabled":
|
||||||
|
client.enable_direct_tls = False
|
||||||
|
client.enable_starttls = False
|
||||||
|
|
||||||
|
# Register handlers
|
||||||
client.add_event_handler("session_start", on_session_start)
|
client.add_event_handler("session_start", on_session_start)
|
||||||
client.add_event_handler("failed_auth", on_failed_auth)
|
client.add_event_handler("failed_auth", on_failed_auth)
|
||||||
|
client.add_event_handler("connection_failed", on_connection_failed)
|
||||||
client.add_event_handler("disconnected", on_disconnected)
|
client.add_event_handler("disconnected", on_disconnected)
|
||||||
|
|
||||||
try:
|
|
||||||
# Connect with explicit host and port
|
# Connect with explicit host and port
|
||||||
connect_future = client.connect(self.settings.host, self.settings.port)
|
connect_future = asyncio.ensure_future(
|
||||||
await connect_future # connect() returns a Future, not a coroutine
|
client.connect(self.settings.host, self.settings.port)
|
||||||
|
)
|
||||||
|
|
||||||
# Wait for one of the three events under a single timeout
|
# Wrap the session future in a task so that cancelling pending tasks
|
||||||
try:
|
# during the concurrent wait never cancels ``session_future`` itself
|
||||||
success = await asyncio.wait_for(session_future, timeout=self.settings.timeout)
|
async def _await_session() -> bool:
|
||||||
except TimeoutError:
|
return await session_future
|
||||||
logger.warning("Délai d'attente de session XMPP dépassé.")
|
|
||||||
|
session_task = asyncio.ensure_future(_await_session())
|
||||||
|
|
||||||
|
# Wait for the connection and the session event concurrently, bounded by
|
||||||
|
# connect_timeout as the global time limit: a ``connection_failed`` event
|
||||||
|
# can thus trigger an early return before the connect timeout expires
|
||||||
|
done, _pending = await asyncio.wait(
|
||||||
|
{connect_future, session_task},
|
||||||
|
timeout=self.settings.connect_timeout,
|
||||||
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
|
)
|
||||||
|
|
||||||
|
if session_task in done:
|
||||||
|
if not session_future.result():
|
||||||
|
# ``connection_failed``/``failed_auth``/``disconnected`` fired
|
||||||
|
# before the connection was resolved: immediate failure (fail fast)
|
||||||
|
if failure_kind == "connection_failed":
|
||||||
|
logger.warning("Échec de connexion réseau XMPP.")
|
||||||
|
else:
|
||||||
|
logger.warning("Échec d'authentification ou déconnexion XMPP.")
|
||||||
|
await _cancel_pending(connect_future, session_task)
|
||||||
|
return False
|
||||||
|
# ``session_start`` fired: the connection succeeded even if the
|
||||||
|
# connect future is still pending; proceed to send the message
|
||||||
|
await _cancel_pending(connect_future, session_task)
|
||||||
|
elif connect_future in done:
|
||||||
|
# The connection resolved: surface a connect error (redacted) if any
|
||||||
|
if not connect_future.cancelled():
|
||||||
|
connect_exc = connect_future.exception()
|
||||||
|
if connect_exc is not None and isinstance(connect_exc, Exception):
|
||||||
|
logger.warning(
|
||||||
|
"Échec de connexion XMPP : %s",
|
||||||
|
redact_exception(
|
||||||
|
connect_exc,
|
||||||
|
extra_secrets=_secret_values(self.settings),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await _cancel_pending(connect_future, session_task)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# Connection established: wait for a session event under ``timeout``
|
||||||
|
try:
|
||||||
|
success = await asyncio.wait_for(
|
||||||
|
asyncio.shield(session_future), timeout=self.settings.timeout
|
||||||
|
)
|
||||||
|
except TimeoutError:
|
||||||
|
logger.warning("Délai d'attente de session XMPP dépassé.")
|
||||||
|
await _cancel_pending(connect_future, session_task)
|
||||||
|
return False
|
||||||
if not success:
|
if not success:
|
||||||
|
if failure_kind == "connection_failed":
|
||||||
|
logger.warning("Échec de connexion réseau XMPP.")
|
||||||
|
else:
|
||||||
logger.warning("Échec d'authentification ou déconnexion XMPP.")
|
logger.warning("Échec d'authentification ou déconnexion XMPP.")
|
||||||
|
await _cancel_pending(connect_future, session_task)
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
# connect_timeout expired: cancel everything and fail fast
|
||||||
|
logger.warning(
|
||||||
|
"Délai de connexion XMPP dépassé (%ss).", self.settings.connect_timeout
|
||||||
|
)
|
||||||
|
await _cancel_pending(connect_future, session_task)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Send the message
|
# Send the message
|
||||||
@@ -300,18 +398,38 @@ class XmppChannel:
|
|||||||
client.send_message(mto=JID(self.settings.to), mbody=formatted, mtype="chat")
|
client.send_message(mto=JID(self.settings.to), mbody=formatted, mtype="chat")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# Contrat du canal : toujours retourner un booléen, même en cas
|
||||||
|
# d'annulation de la tâche appelante (cleanup exécuté par le finally).
|
||||||
|
logger.debug("Envoi XMPP annulé avant la fin de l'opération.")
|
||||||
|
pending = [f for f in (connect_future, session_task) if f is not None]
|
||||||
|
if pending:
|
||||||
|
await _cancel_pending(*pending)
|
||||||
|
return False
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
redacted = redact_exception(exc)
|
redacted = redact_exception(exc, extra_secrets=_secret_values(self.settings))
|
||||||
extra = _secret_values(self.settings)
|
extra = _secret_values(self.settings)
|
||||||
logger.warning("Erreur XMPP: %s", redact_secrets(redacted, extra_secrets=extra))
|
logger.warning("Erreur XMPP: %s", redact_secrets(redacted, extra_secrets=extra))
|
||||||
|
pending = [f for f in (connect_future, session_task) if f is not None]
|
||||||
|
if pending:
|
||||||
|
await _cancel_pending(*pending)
|
||||||
return False
|
return False
|
||||||
finally:
|
finally:
|
||||||
|
if client is not None:
|
||||||
try:
|
try:
|
||||||
disconnect_future = client.disconnect()
|
disconnect_future = client.disconnect()
|
||||||
await disconnect_future
|
await asyncio.wait_for(disconnect_future, timeout=self.settings.cleanup_timeout)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.debug("Déconnexion XMPP annulée.")
|
||||||
|
except TimeoutError:
|
||||||
|
logger.debug(
|
||||||
|
"Délai de déconnexion XMPP dépassé (%ss), abandon.",
|
||||||
|
self.settings.cleanup_timeout,
|
||||||
|
)
|
||||||
except Exception as cleanup_exc:
|
except Exception as cleanup_exc:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Erreur lors de la déconnexion XMPP: %s", redact_exception(cleanup_exc)
|
"Erreur lors de la déconnexion XMPP : %s",
|
||||||
|
redact_exception(cleanup_exc, extra_secrets=_secret_values(self.settings)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -363,7 +481,7 @@ class SyncXmppChannel:
|
|||||||
try:
|
try:
|
||||||
return asyncio.run(self._channel.send_async(message))
|
return asyncio.run(self._channel.send_async(message))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
redacted = redact_exception(exc)
|
redacted = redact_exception(exc, extra_secrets=_secret_values(self.settings))
|
||||||
redacted = redact_secrets(redacted, extra_secrets=_secret_values(self.settings))
|
redacted = redact_secrets(redacted, extra_secrets=_secret_values(self.settings))
|
||||||
logger.warning("XMPP : erreur lors de l'envoi synchrone : %s", redacted)
|
logger.warning("XMPP : erreur lors de l'envoi synchrone : %s", redacted)
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from pydantic import SecretStr
|
|||||||
|
|
||||||
from pronote_sync.config.env import load_settings
|
from pronote_sync.config.env import load_settings
|
||||||
from pronote_sync.config.settings import Settings
|
from pronote_sync.config.settings import Settings
|
||||||
|
from pronote_sync.errors import ErrorSeverity, PipelineError
|
||||||
from pronote_sync.pipeline.run import PipelineRunner
|
from pronote_sync.pipeline.run import PipelineRunner
|
||||||
from pronote_sync.utils.logging import setup_logging
|
from pronote_sync.utils.logging import setup_logging
|
||||||
from pronote_sync.utils.redaction import redact_secrets
|
from pronote_sync.utils.redaction import redact_secrets
|
||||||
@@ -19,6 +20,26 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
_LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
|
_LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
|
||||||
|
|
||||||
|
# Contrat stable pour systemd et les outils de supervision.
|
||||||
|
EXIT_SUCCESS = 0
|
||||||
|
EXIT_CRITICAL = 1
|
||||||
|
EXIT_DEGRADED = 2
|
||||||
|
|
||||||
|
|
||||||
|
def _pipeline_exit_code(data: object | None, errors: Sequence[PipelineError]) -> int:
|
||||||
|
"""Convertit le résultat du pipeline en code de sortie supervisable.
|
||||||
|
|
||||||
|
:param data: Données normalisées produites, ou ``None`` en cas d'échec critique.
|
||||||
|
:param errors: Erreurs et avertissements de l'exécution.
|
||||||
|
:return: ``0`` si complet, ``2`` si dégradé, ``1`` si critique.
|
||||||
|
:rtype: int
|
||||||
|
"""
|
||||||
|
if data is None or any(error.severity == ErrorSeverity.CRITICAL for error in errors):
|
||||||
|
return EXIT_CRITICAL
|
||||||
|
if errors:
|
||||||
|
return EXIT_DEGRADED
|
||||||
|
return EXIT_SUCCESS
|
||||||
|
|
||||||
|
|
||||||
def _parse_arguments(arguments: Sequence[str] | None = None) -> argparse.Namespace:
|
def _parse_arguments(arguments: Sequence[str] | None = None) -> argparse.Namespace:
|
||||||
"""Analyse les options de lancement du programme.
|
"""Analyse les options de lancement du programme.
|
||||||
@@ -120,7 +141,7 @@ def main(arguments: Sequence[str] | None = None) -> int:
|
|||||||
bruts afin de préserver le diagnostic sans exposer de secret.
|
bruts afin de préserver le diagnostic sans exposer de secret.
|
||||||
|
|
||||||
:param arguments: Arguments optionnels, principalement utiles aux appels programmatiques.
|
:param arguments: Arguments optionnels, principalement utiles aux appels programmatiques.
|
||||||
:return: ``0`` en cas de succès, ``1`` sinon (après analyse des arguments).
|
:return: Code machine-readable : ``0`` complet, ``2`` dégradé, ``1`` critique.
|
||||||
:rtype: int
|
:rtype: int
|
||||||
:raises SystemExit: Si argparse rejette les arguments (code de sortie 2).
|
:raises SystemExit: Si argparse rejette les arguments (code de sortie 2).
|
||||||
"""
|
"""
|
||||||
@@ -147,9 +168,7 @@ def main(arguments: Sequence[str] | None = None) -> int:
|
|||||||
secrets = _settings_secrets(settings)
|
secrets = _settings_secrets(settings)
|
||||||
for error in errors:
|
for error in errors:
|
||||||
logger.error("%s", redact_secrets(error.message, extra_secrets=secrets))
|
logger.error("%s", redact_secrets(error.message, extra_secrets=secrets))
|
||||||
if data is None:
|
return _pipeline_exit_code(data, errors)
|
||||||
return 1
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ depuis les variables d'environnement (préfixées par groupe) et le fichier
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import warnings
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
@@ -18,6 +19,7 @@ from pydantic import (
|
|||||||
ValidationInfo,
|
ValidationInfo,
|
||||||
field_serializer,
|
field_serializer,
|
||||||
field_validator,
|
field_validator,
|
||||||
|
model_validator,
|
||||||
)
|
)
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
@@ -45,6 +47,7 @@ class PronoteSettings(BaseSettings):
|
|||||||
auth_mode: Literal["password", "qr_token"] = "password"
|
auth_mode: Literal["password", "qr_token"] = "password"
|
||||||
qr_code_file: str | None = None
|
qr_code_file: str | None = None
|
||||||
qr_pin: SecretStr | None = None
|
qr_pin: SecretStr | None = None
|
||||||
|
account_pin: SecretStr | None = None
|
||||||
|
|
||||||
@field_serializer("ical_url")
|
@field_serializer("ical_url")
|
||||||
def _serialize_ical_url(self, value: SecretStr | None) -> str | None:
|
def _serialize_ical_url(self, value: SecretStr | None) -> str | None:
|
||||||
@@ -70,6 +73,18 @@ class PronoteSettings(BaseSettings):
|
|||||||
return None
|
return None
|
||||||
return "**********"
|
return "**********"
|
||||||
|
|
||||||
|
@field_serializer("account_pin")
|
||||||
|
def _serialize_account_pin(self, value: SecretStr | None) -> str | None:
|
||||||
|
"""Masque le PIN du compte lors de la sérialisation.
|
||||||
|
|
||||||
|
:param value: Valeur du PIN de second facteur du compte.
|
||||||
|
:return: ``"**********"`` si la valeur est définie, ``None`` sinon.
|
||||||
|
:rtype: str | None
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return "**********"
|
||||||
|
|
||||||
|
|
||||||
class CalDAVSettings(BaseSettings):
|
class CalDAVSettings(BaseSettings):
|
||||||
"""Paramètres d'accès au serveur CalDAV de destination.
|
"""Paramètres d'accès au serveur CalDAV de destination.
|
||||||
@@ -158,10 +173,16 @@ class XmppSettings(BaseSettings):
|
|||||||
Contraintes de champs : ``port`` est borné entre 1 et 65535 et ``timeout``
|
Contraintes de champs : ``port`` est borné entre 1 et 65535 et ``timeout``
|
||||||
doit être strictement positif.
|
doit être strictement positif.
|
||||||
|
|
||||||
Politique TLS : la désactivation de TLS (``use_tls`` à ``False``) n'est
|
Politique TLS : le mode ``tls_mode`` détermine la négociation TLS
|
||||||
autorisée que sur un hôte de boucle locale (``localhost``, ``127.0.0.1``,
|
(``direct``, ``starttls`` ou ``disabled``). Le mode ``disabled`` n'est
|
||||||
``::1``). Dans tout autre cas, une erreur de validation est levée,
|
autorisé que sur un hôte de boucle locale (``localhost``, ``127.0.0.1``,
|
||||||
indépendamment de l'état du champ ``enabled``.
|
``::1``) ; ``starttls`` et ``direct`` sont permis pour tous les hôtes.
|
||||||
|
|
||||||
|
Compatibilité : le champ historique ``use_tls`` (booléen) est un alias
|
||||||
|
obsolète ; ``use_tls=True`` mappe vers ``tls_mode="direct"`` et
|
||||||
|
``use_tls=False`` vers ``tls_mode="starttls"``, avec un
|
||||||
|
:pyexc:`DeprecationWarning`. La valeur brute fournie reste lisible via
|
||||||
|
``use_tls`` (``None`` si non fournie).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
@@ -178,33 +199,61 @@ class XmppSettings(BaseSettings):
|
|||||||
port: int = Field(default=5222, ge=1, le=65535)
|
port: int = Field(default=5222, ge=1, le=65535)
|
||||||
to: str | None = None
|
to: str | None = None
|
||||||
resource: str = "pronote-sync"
|
resource: str = "pronote-sync"
|
||||||
use_tls: bool = True
|
tls_mode: Literal["direct", "starttls", "disabled"] = "starttls"
|
||||||
timeout: int = Field(default=30, gt=0)
|
use_tls: bool | None = Field(
|
||||||
|
default=None,
|
||||||
|
deprecated="Utiliser tls_mode à la place (XMPP_USE_TLS obsolète).",
|
||||||
|
)
|
||||||
|
timeout: float = Field(default=30, gt=0)
|
||||||
|
connect_timeout: float = Field(default=15, gt=0)
|
||||||
|
cleanup_timeout: float = Field(default=10, gt=0)
|
||||||
|
|
||||||
@field_validator("use_tls")
|
@model_validator(mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _validate_tls_policy(cls, v: bool, info: ValidationInfo) -> bool:
|
def _migrate_use_tls(cls, data: object) -> object:
|
||||||
"""Refuse la désactivation de TLS hors des hôtes de boucle locale.
|
"""Mappe l'alias obsolète ``use_tls`` vers le mode canonique ``tls_mode``.
|
||||||
|
|
||||||
La règle s'applique quel que soit l'état du champ ``enabled``. Le
|
``use_tls=True`` devient ``tls_mode="direct"`` et ``use_tls=False``
|
||||||
message d'erreur ne contient aucune valeur sensible (``jid``,
|
devient ``tls_mode="starttls"`` ; un :pyexc:`DeprecationWarning` est
|
||||||
|
émis à chaque usage explicite de l'alias. ``tls_mode`` fourni
|
||||||
|
explicitement prend le pas sur l'alias.
|
||||||
|
|
||||||
|
:param data: Données d'entrée du modèle (dict ou autre).
|
||||||
|
:return: Données d'entrée avec ``tls_mode`` dérivé de ``use_tls``.
|
||||||
|
:rtype: object
|
||||||
|
"""
|
||||||
|
if not isinstance(data, dict) or "use_tls" not in data:
|
||||||
|
return data
|
||||||
|
warnings.warn(
|
||||||
|
"XMPP_USE_TLS est obsolète : utiliser XMPP_TLS_MODE "
|
||||||
|
"('direct', 'starttls' ou 'disabled').",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
if data.get("tls_mode") is None:
|
||||||
|
data["tls_mode"] = "direct" if data["use_tls"] else "starttls"
|
||||||
|
return data
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _validate_tls_policy(self) -> XmppSettings:
|
||||||
|
"""Refuse le mode ``disabled`` hors des hôtes de boucle locale.
|
||||||
|
|
||||||
|
La règle s'applique quel que soit l'état du champ ``enabled``. Les
|
||||||
|
modes ``starttls`` et ``direct`` sont autorisés pour tous les hôtes.
|
||||||
|
Le message d'erreur ne contient aucune valeur sensible (``jid``,
|
||||||
``password``, ``to``).
|
``password``, ``to``).
|
||||||
|
|
||||||
:param v: Valeur du champ ``use_tls`` à valider.
|
:return: L'instance validée inchangée.
|
||||||
:param info: Contexte de validation (accès aux autres champs).
|
:rtype: XmppSettings
|
||||||
:return: La valeur validée inchangée.
|
:raises ValueError: Si ``tls_mode`` est ``disabled`` et que ``host``
|
||||||
:rtype: bool
|
|
||||||
:raises ValueError: Si ``use_tls`` est ``False`` et que ``host``
|
|
||||||
n'est pas un hôte de boucle locale.
|
n'est pas un hôte de boucle locale.
|
||||||
"""
|
"""
|
||||||
if v is False:
|
if self.tls_mode == "disabled" and self.host not in _XMPP_LOOPBACK_HOSTS:
|
||||||
host = info.data.get("host", "")
|
|
||||||
if host not in _XMPP_LOOPBACK_HOSTS:
|
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"TLS désactivé n'est autorisé que sur les hôtes de loopback "
|
"TLS désactivé n'est autorisé que sur les hôtes de loopback "
|
||||||
"(localhost, 127.0.0.1, ::1)."
|
"(localhost, 127.0.0.1, ::1)."
|
||||||
) from None
|
) from None
|
||||||
return v
|
return self
|
||||||
|
|
||||||
|
|
||||||
class AISettings(BaseSettings):
|
class AISettings(BaseSettings):
|
||||||
@@ -257,8 +306,8 @@ class AppSettings(BaseSettings):
|
|||||||
school_holidays_path: str | None = None
|
school_holidays_path: str | None = None
|
||||||
theoretical_week_anchor_date: date | None = None
|
theoretical_week_anchor_date: date | None = None
|
||||||
theoretical_week_anchor_type: Literal["even", "odd"] | None = None
|
theoretical_week_anchor_type: Literal["even", "odd"] | None = None
|
||||||
sync_past_days: int = 7
|
sync_past_days: int = Field(default=7, ge=0)
|
||||||
sync_future_days: int = 30
|
sync_future_days: int = Field(default=30, ge=0)
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
@@ -292,6 +341,7 @@ class Settings(BaseSettings):
|
|||||||
self.pronote.ical_url,
|
self.pronote.ical_url,
|
||||||
self.pronote.password,
|
self.pronote.password,
|
||||||
self.pronote.qr_pin,
|
self.pronote.qr_pin,
|
||||||
|
self.pronote.account_pin,
|
||||||
self.caldav.url,
|
self.caldav.url,
|
||||||
self.caldav.password,
|
self.caldav.password,
|
||||||
self.xmpp.password,
|
self.xmpp.password,
|
||||||
|
|||||||
@@ -38,6 +38,21 @@ class PronoteAuthRotationError(PronoteSyncError):
|
|||||||
super().__init__(message)
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
|
class PronoteAuthStateLockError(PronoteSyncError):
|
||||||
|
"""Erreur levée lorsqu'un autre processus détient l'état d'authentification.
|
||||||
|
|
||||||
|
Cette erreur indique qu'une opération QR code / token concurrente est en
|
||||||
|
cours. Son message ne contient ni chemin local sensible ni credential.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, message: str) -> None:
|
||||||
|
"""Initialise l'erreur de contention du verrou d'état.
|
||||||
|
|
||||||
|
:param message: Message actionnable expurgé décrivant la contention.
|
||||||
|
"""
|
||||||
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
class ErrorSeverity(StrEnum):
|
class ErrorSeverity(StrEnum):
|
||||||
"""Niveau de gravité d'une erreur produite par le pipeline."""
|
"""Niveau de gravité d'une erreur produite par le pipeline."""
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ class Lesson(BaseModel):
|
|||||||
teachers: tuple[str, ...] = Field(default=(), description="Liste des professeurs")
|
teachers: tuple[str, ...] = Field(default=(), description="Liste des professeurs")
|
||||||
rooms: tuple[str, ...] = Field(default=(), description="Liste des salles")
|
rooms: tuple[str, ...] = Field(default=(), description="Liste des salles")
|
||||||
group: str | None = Field(None, description="Groupe (ex: Classe entière)")
|
group: str | None = Field(None, description="Groupe (ex: Classe entière)")
|
||||||
|
class_part: str | None = None
|
||||||
status: LessonStatus = Field(default=LessonStatus.NORMAL, description="Statut du cours")
|
status: LessonStatus = Field(default=LessonStatus.NORMAL, description="Statut du cours")
|
||||||
content: str | None = Field(None, description="Contenu pédagogique")
|
content: str | None = Field(None, description="Contenu pédagogique")
|
||||||
homework_blocks: tuple[HomeworkBlock, ...] = Field(
|
homework_blocks: tuple[HomeworkBlock, ...] = Field(
|
||||||
|
|||||||
@@ -230,11 +230,13 @@ class PipelineRunner:
|
|||||||
data = normalize_step(fetched, generated_at=now)
|
data = normalize_step(fetched, generated_at=now)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
blog_articles = fetch_blog_step(self._blog_client, self._blog_state)
|
blog_result = fetch_blog_step(self._blog_client, self._blog_state)
|
||||||
|
blog_articles = list(blog_result.articles)
|
||||||
except PipelineCriticalError:
|
except PipelineCriticalError:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._warn("fetch_blog", self._redact(exc))
|
self._warn("fetch_blog", self._redact(exc))
|
||||||
|
blog_result = None
|
||||||
blog_articles = []
|
blog_articles = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -290,8 +292,11 @@ class PipelineRunner:
|
|||||||
)
|
)
|
||||||
if self._channel is not None and not self._dry_run:
|
if self._channel is not None and not self._dry_run:
|
||||||
try:
|
try:
|
||||||
if not send_step(self._channel, message):
|
delivered = send_step(self._channel, message)
|
||||||
|
if not delivered:
|
||||||
self._warn("send", "Le canal XMPP a refusé l'envoi")
|
self._warn("send", "Le canal XMPP a refusé l'envoi")
|
||||||
|
elif blog_result is not None and self._blog_state is not None:
|
||||||
|
self._blog_state.acknowledge(blog_result)
|
||||||
except PipelineCriticalError:
|
except PipelineCriticalError:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -2,16 +2,19 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
from pronote_sync.errors import PipelineCriticalError, PipelineWarning, PronoteAuthRotationError
|
from pronote_sync.errors import PipelineCriticalError, PipelineWarning, PronoteAuthRotationError
|
||||||
from pronote_sync.models.agenda import Lesson, SchoolEvent
|
from pronote_sync.models.agenda import Lesson, LessonStatus, SchoolEvent
|
||||||
from pronote_sync.models.homework import Homework
|
from pronote_sync.models.homework import Homework
|
||||||
from pronote_sync.models.message import Message
|
from pronote_sync.models.message import Message
|
||||||
from pronote_sync.sources.pronote.fallback import PronoteFetcherProtocol
|
from pronote_sync.sources.pronote.fallback import PronoteFetcherProtocol
|
||||||
from pronote_sync.utils.redaction import redact_exception
|
from pronote_sync.utils.redaction import redact_exception
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class FetchedPronoteData:
|
class FetchedPronoteData:
|
||||||
@@ -36,27 +39,41 @@ def resolve_target_date(
|
|||||||
) -> date:
|
) -> date:
|
||||||
"""Détermine la date cible du digest à partir de l'agenda disponible.
|
"""Détermine la date cible du digest à partir de l'agenda disponible.
|
||||||
|
|
||||||
La règle privilégie J+1 lorsqu'il contient des cours. Si la journée en
|
La règle privilégie J+1 lorsqu'il contient un cours non annulé. Sinon,
|
||||||
cours contient des cours mais pas J+1, le prochain cours connu est choisi.
|
elle choisit le prochain jour possédant un cours effectif dans la fenêtre.
|
||||||
Sans cours correspondant, J+1 est conservé, y compris pendant les vacances.
|
Si aucune reprise n'est visible, la fin exclusive d'un événement scolaire
|
||||||
|
couvrant J+1 est utilisée ; à défaut, J+1 reste le meilleur choix connu.
|
||||||
|
|
||||||
:param today: Date de référence de l'exécution.
|
:param today: Date de référence de l'exécution.
|
||||||
:param lessons: Cours récupérés pour la fenêtre de synchronisation.
|
:param lessons: Cours récupérés pour la fenêtre de synchronisation.
|
||||||
:param school_events: Événements scolaires récupérés (réservés aux évolutions
|
:param school_events: Événements scolaires récupérés, avec borne de fin exclusive.
|
||||||
du libellé de jour sans cours).
|
|
||||||
:return: Date cible du digest.
|
:return: Date cible du digest.
|
||||||
:rtype: date
|
:rtype: date
|
||||||
"""
|
"""
|
||||||
del school_events
|
|
||||||
tomorrow = date.fromordinal(today.toordinal() + 1)
|
tomorrow = date.fromordinal(today.toordinal() + 1)
|
||||||
lesson_dates = {lesson.start.date() for lesson in lessons}
|
effective_dates = {
|
||||||
if tomorrow in lesson_dates:
|
lesson.start.date()
|
||||||
return tomorrow
|
for lesson in lessons
|
||||||
if today in lesson_dates:
|
if lesson.status != LessonStatus.CANCELLED and lesson.start.date() > today
|
||||||
future_dates = sorted(day for day in lesson_dates if day > today)
|
}
|
||||||
if future_dates:
|
if tomorrow in effective_dates:
|
||||||
return future_dates[0]
|
reason = "cours effectif demain"
|
||||||
return tomorrow
|
target = tomorrow
|
||||||
|
elif effective_dates:
|
||||||
|
reason = "prochain cours effectif connu"
|
||||||
|
target = min(effective_dates)
|
||||||
|
else:
|
||||||
|
event_ends = [
|
||||||
|
event.to_date for event in school_events if event.from_date <= tomorrow < event.to_date
|
||||||
|
]
|
||||||
|
if event_ends:
|
||||||
|
reason = "fin exclusive de l'événement couvrant demain"
|
||||||
|
target = max(event_ends)
|
||||||
|
else:
|
||||||
|
reason = "aucune reprise connue"
|
||||||
|
target = tomorrow
|
||||||
|
logger.info("Date cible %s : %s", target.isoformat(), reason)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
def _fetch_optional_messages(
|
def _fetch_optional_messages(
|
||||||
@@ -104,6 +121,7 @@ def fetch_step(
|
|||||||
:raises PronoteAuthRotationError: Si une rotation du token d'authentification
|
:raises PronoteAuthRotationError: Si une rotation du token d'authentification
|
||||||
pronotepy est nécessaire : propagée telle quelle jusqu'au pipeline.
|
pronotepy est nécessaire : propagée telle quelle jusqu'au pipeline.
|
||||||
"""
|
"""
|
||||||
|
critical_error: PipelineCriticalError | None = None
|
||||||
try:
|
try:
|
||||||
lessons, school_events = fetcher.fetch_agenda()
|
lessons, school_events = fetcher.fetch_agenda()
|
||||||
target_date = resolve_target_date(today or date.today(), lessons, school_events)
|
target_date = resolve_target_date(today or date.today(), lessons, school_events)
|
||||||
@@ -113,9 +131,11 @@ def fetch_step(
|
|||||||
except PronoteAuthRotationError:
|
except PronoteAuthRotationError:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise PipelineCriticalError(
|
critical_error = PipelineCriticalError(
|
||||||
f"Récupération Pronote impossible : {redact_exception(exc)}", step="fetch"
|
f"Récupération Pronote impossible : {redact_exception(exc)}", step="fetch"
|
||||||
) from None
|
)
|
||||||
|
if critical_error is not None:
|
||||||
|
raise critical_error from None
|
||||||
|
|
||||||
messages, warnings = _fetch_optional_messages(fetcher)
|
messages, warnings = _fetch_optional_messages(fetcher)
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,33 +2,40 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pronote_sync.models.blog import BlogArticle
|
from pronote_sync.sources.blog.result import BlogRSSFetchResult
|
||||||
from pronote_sync.sources.blog.rss import BlogRSSClient
|
from pronote_sync.sources.blog.rss import BlogRSSClient
|
||||||
from pronote_sync.sources.blog.state import BlogRSSState
|
from pronote_sync.sources.blog.state import BlogRSSState
|
||||||
from pronote_sync.utils.redaction import redact_exception
|
from pronote_sync.utils.redaction import redact_exception, redact_secrets
|
||||||
|
|
||||||
|
|
||||||
def fetch_blog_step(client: BlogRSSClient | None, state: BlogRSSState | None) -> list[BlogArticle]:
|
def fetch_blog_step(client: BlogRSSClient | None, state: BlogRSSState | None) -> BlogRSSFetchResult:
|
||||||
"""Récupère les articles RSS nouveaux en conservant l'état du client.
|
"""Récupère les articles RSS nouveaux sans les acquitter.
|
||||||
|
|
||||||
|
L'état des GUID est acquitté séparément par le pipeline après confirmation
|
||||||
|
de la livraison XMPP. Les en-têtes de cache d'une réponse sans article
|
||||||
|
peuvent être conservés immédiatement, car aucune livraison n'est alors en
|
||||||
|
attente.
|
||||||
|
|
||||||
:param client: Client RSS configuré, ou ``None`` lorsque le blog est désactivé.
|
:param client: Client RSS configuré, ou ``None`` lorsque le blog est désactivé.
|
||||||
:param state: État de déduplication et de cache HTTP associé au run.
|
:param state: État de déduplication et de cache HTTP associé au run.
|
||||||
:return: Nouveaux articles du blog.
|
:return: Résultat de récupération, incluant les métadonnées de cache.
|
||||||
:rtype: list[BlogArticle]
|
:rtype: BlogRSSFetchResult
|
||||||
:raises RuntimeError: Si la récupération RSS injectée échoue.
|
:raises RuntimeError: Si la récupération RSS injectée échoue.
|
||||||
"""
|
"""
|
||||||
if client is None or state is None:
|
if client is None or state is None:
|
||||||
return []
|
return BlogRSSFetchResult()
|
||||||
|
error_message: str | None = None
|
||||||
try:
|
try:
|
||||||
etag, last_modified = state.get_cache_headers()
|
etag, last_modified = state.get_cache_headers()
|
||||||
result = client.fetch_and_parse(
|
result = client.fetch_and_parse(
|
||||||
known_guids=state.get_known_guids(), etag=etag, last_modified=last_modified
|
known_guids=state.get_known_guids(), etag=etag, last_modified=last_modified
|
||||||
)
|
)
|
||||||
if result.error is not None:
|
if result.error is not None:
|
||||||
raise RuntimeError(result.error) from None
|
error_message = f"Récupération du blog échouée : {redact_secrets(result.error)}"
|
||||||
if not result.not_modified:
|
elif not result.not_modified and not result.articles:
|
||||||
state.add_guids(article.id for article in result.articles)
|
|
||||||
state.update_cache_headers(result.etag, result.last_modified)
|
state.update_cache_headers(result.etag, result.last_modified)
|
||||||
return list(result.articles)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise RuntimeError(f"Récupération du blog échouée : {redact_exception(exc)}") from None
|
error_message = f"Récupération du blog échouée : {redact_exception(exc)}"
|
||||||
|
if error_message is not None:
|
||||||
|
raise RuntimeError(error_message) from None
|
||||||
|
return result
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import logging
|
|||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pronote_sync.sources.blog.result import BlogRSSFetchResult
|
||||||
from pronote_sync.utils.redaction import redact_exception, redact_secrets
|
from pronote_sync.utils.redaction import redact_exception, redact_secrets
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -96,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é
|
||||||
@@ -106,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.",
|
||||||
@@ -133,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.
|
||||||
@@ -156,6 +171,25 @@ class BlogRSSState:
|
|||||||
self._known_guids.update(new_guids)
|
self._known_guids.update(new_guids)
|
||||||
self._save()
|
self._save()
|
||||||
|
|
||||||
|
def acknowledge(self, result: BlogRSSFetchResult) -> None:
|
||||||
|
"""Acquitte une récupération RSS après sa livraison confirmée.
|
||||||
|
|
||||||
|
Les GUID et les en-têtes de cache sont enregistrés ensemble afin qu'un
|
||||||
|
article dont la livraison a échoué reste récupérable à l'exécution
|
||||||
|
suivante. Une réponse ``304 Not Modified`` n'a rien à acquitter.
|
||||||
|
|
||||||
|
:param result: Résultat RSS livré avec succès.
|
||||||
|
"""
|
||||||
|
if result.not_modified:
|
||||||
|
return
|
||||||
|
new_state = (
|
||||||
|
self._known_guids | {article.id for article in result.articles},
|
||||||
|
result.etag,
|
||||||
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -14,10 +14,13 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from collections.abc import Generator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from fcntl import LOCK_EX, LOCK_NB, LOCK_UN, flock
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pronote_sync.errors import PronoteSyncError
|
from pronote_sync.errors import PronoteAuthStateLockError, PronoteSyncError
|
||||||
from pronote_sync.utils.redaction import redact_exception, redact_secrets
|
from pronote_sync.utils.redaction import redact_exception, redact_secrets
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -124,6 +127,69 @@ class PronoteAuthState:
|
|||||||
credentials[key] = value
|
credentials[key] = value
|
||||||
return credentials
|
return credentials
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def lock(self) -> Generator[None]:
|
||||||
|
"""Protège une opération d'état par un verrou POSIX non bloquant.
|
||||||
|
|
||||||
|
Le verrou est conservé dans le fichier frère ``<state_file>.lock`` afin
|
||||||
|
de survivre à l'écriture atomique du fichier d'état. Le fichier de
|
||||||
|
verrou reste présent après libération et est créé en ``0600`` pour ne
|
||||||
|
pas élargir l'accès aux métadonnées de l'état sensible.
|
||||||
|
|
||||||
|
:return: Un gestionnaire de contexte qui tient le verrou exclusif.
|
||||||
|
:rtype: collections.abc.Generator[None, None, None]
|
||||||
|
:raises PronoteAuthStateLockError: Si un autre processus détient déjà
|
||||||
|
le verrou ou si son acquisition échoue.
|
||||||
|
"""
|
||||||
|
lock_file = self._state_file.with_name(f"{self._state_file.name}.lock")
|
||||||
|
descriptor: int | None = None
|
||||||
|
try:
|
||||||
|
descriptor = os.open(
|
||||||
|
str(lock_file),
|
||||||
|
os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW,
|
||||||
|
0o600,
|
||||||
|
)
|
||||||
|
os.fchmod(descriptor, 0o600)
|
||||||
|
except OSError:
|
||||||
|
logger.error("Impossible d'ouvrir le verrou d'état d'authentification Pronote.")
|
||||||
|
if descriptor is not None:
|
||||||
|
os.close(descriptor)
|
||||||
|
|
||||||
|
if descriptor is None:
|
||||||
|
raise PronoteAuthStateLockError(
|
||||||
|
"Impossible d'acquérir le verrou d'état d'authentification Pronote."
|
||||||
|
) from None
|
||||||
|
|
||||||
|
is_contended = False
|
||||||
|
lock_acquisition_failed = False
|
||||||
|
try:
|
||||||
|
flock(descriptor, LOCK_EX | LOCK_NB)
|
||||||
|
except BlockingIOError:
|
||||||
|
is_contended = True
|
||||||
|
except OSError:
|
||||||
|
logger.error("Impossible d'acquérir le verrou d'état d'authentification Pronote.")
|
||||||
|
os.close(descriptor)
|
||||||
|
lock_acquisition_failed = True
|
||||||
|
|
||||||
|
if lock_acquisition_failed:
|
||||||
|
raise PronoteAuthStateLockError(
|
||||||
|
"Impossible d'acquérir le verrou d'état d'authentification Pronote."
|
||||||
|
) from None
|
||||||
|
|
||||||
|
if is_contended:
|
||||||
|
os.close(descriptor)
|
||||||
|
raise PronoteAuthStateLockError(
|
||||||
|
"Une autre opération d'authentification Pronote est déjà en cours."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
flock(descriptor, LOCK_UN)
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
|
||||||
def save(self, credentials: dict[str, str]) -> None:
|
def save(self, credentials: dict[str, str]) -> None:
|
||||||
"""Sauvegarde les credentials dans le fichier d'état, de manière atomique.
|
"""Sauvegarde les credentials dans le fichier d'état, de manière atomique.
|
||||||
|
|
||||||
@@ -152,6 +218,7 @@ class PronoteAuthState:
|
|||||||
}
|
}
|
||||||
tmp_file = self._state_file.with_suffix(".tmp")
|
tmp_file = self._state_file.with_suffix(".tmp")
|
||||||
fd: int | None = None
|
fd: int | None = None
|
||||||
|
write_error: PronoteSyncError | None = None
|
||||||
try:
|
try:
|
||||||
# Nettoie un éventuel fichier temporaire stale laissé par une exécution interrompue.
|
# Nettoie un éventuel fichier temporaire stale laissé par une exécution interrompue.
|
||||||
if tmp_file.exists():
|
if tmp_file.exists():
|
||||||
@@ -193,10 +260,12 @@ class PronoteAuthState:
|
|||||||
"Nettoyage du fichier temporaire d'état d'authentification Pronote échoué : %s",
|
"Nettoyage du fichier temporaire d'état d'authentification Pronote échoué : %s",
|
||||||
redact_exception(cleanup_exc),
|
redact_exception(cleanup_exc),
|
||||||
)
|
)
|
||||||
raise PronoteSyncError(
|
write_error = PronoteSyncError(
|
||||||
f"Impossible d'écrire le fichier d'état d'authentification Pronote "
|
f"Impossible d'écrire le fichier d'état d'authentification Pronote "
|
||||||
f"{redact_secrets(str(self._state_file))}."
|
f"{redact_secrets(str(self._state_file))}."
|
||||||
) from None
|
)
|
||||||
|
if write_error is not None:
|
||||||
|
raise write_error from None
|
||||||
|
|
||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
"""Supprime le fichier d'état d'authentification.
|
"""Supprime le fichier d'état d'authentification.
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from collections.abc import Generator
|
||||||
|
from contextlib import contextmanager
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Protocol
|
from typing import Any, Protocol
|
||||||
@@ -23,10 +25,11 @@ import requests
|
|||||||
|
|
||||||
from pronote_sync.config.settings import PronoteSettings
|
from pronote_sync.config.settings import PronoteSettings
|
||||||
from pronote_sync.errors import PronoteAuthRotationError
|
from pronote_sync.errors import PronoteAuthRotationError
|
||||||
from pronote_sync.models.agenda import Lesson, LessonStatus
|
from pronote_sync.models.agenda import Lesson
|
||||||
from pronote_sync.models.homework import Homework
|
from pronote_sync.models.homework import Homework
|
||||||
from pronote_sync.models.message import Message, MessageType
|
from pronote_sync.models.message import Message, MessageType
|
||||||
from pronote_sync.sources.pronote.auth_state import PronoteAuthState
|
from pronote_sync.sources.pronote.auth_state import PronoteAuthState
|
||||||
|
from pronote_sync.sources.pronote.lessons import collapse_replaced_lessons, lesson_status
|
||||||
from pronote_sync.utils.redaction import redact_exception, redact_secrets
|
from pronote_sync.utils.redaction import redact_exception, redact_secrets
|
||||||
from pronote_sync.utils.uid import generate_deterministic_uid, normalize_pronote_uid
|
from pronote_sync.utils.uid import generate_deterministic_uid, normalize_pronote_uid
|
||||||
|
|
||||||
@@ -116,6 +119,9 @@ def _collect_auth_secrets(client: PronoteClient) -> list[str]:
|
|||||||
# PIN QR
|
# PIN QR
|
||||||
if settings.qr_pin is not None:
|
if settings.qr_pin is not None:
|
||||||
secrets.append(settings.qr_pin.get_secret_value())
|
secrets.append(settings.qr_pin.get_secret_value())
|
||||||
|
# PIN de second facteur du compte
|
||||||
|
if settings.account_pin is not None:
|
||||||
|
secrets.append(settings.account_pin.get_secret_value())
|
||||||
# Contenu du fichier QR (jeton, login, url)
|
# Contenu du fichier QR (jeton, login, url)
|
||||||
if settings.qr_code_file is not None:
|
if settings.qr_code_file is not None:
|
||||||
try:
|
try:
|
||||||
@@ -252,6 +258,26 @@ class PronoteClient:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("Échec de la persistance des credentials : %s", redact_exception(exc))
|
logger.debug("Échec de la persistance des credentials : %s", redact_exception(exc))
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _qr_token_operation_lock(self) -> Generator[None]:
|
||||||
|
"""Verrouille un cycle d'authentification et de récupération QR/token.
|
||||||
|
|
||||||
|
Le verrou englobe le chargement du token, le login, l'opération de
|
||||||
|
données et la persistance qui suit. Il est volontairement absent du
|
||||||
|
mode ``password``, qui ne partage pas de fichier d'état de token.
|
||||||
|
|
||||||
|
:return: Un gestionnaire de contexte protégeant le cycle QR/token.
|
||||||
|
:rtype: collections.abc.Generator[None, None, None]
|
||||||
|
:raises PronoteAuthStateLockError: Si l'état QR/token est déjà utilisé
|
||||||
|
par une autre opération.
|
||||||
|
"""
|
||||||
|
if self._settings.auth_mode != "qr_token" or self._auth_state is None:
|
||||||
|
yield
|
||||||
|
return
|
||||||
|
|
||||||
|
with self._auth_state.lock():
|
||||||
|
yield
|
||||||
|
|
||||||
def _connect_password(self) -> pronotepy.Client:
|
def _connect_password(self) -> pronotepy.Client:
|
||||||
"""Connecte le client ``pronotepy`` en mode ``password``.
|
"""Connecte le client ``pronotepy`` en mode ``password``.
|
||||||
|
|
||||||
@@ -313,8 +339,14 @@ class PronoteClient:
|
|||||||
if self._auth_state is not None:
|
if self._auth_state is not None:
|
||||||
creds = self._auth_state.load()
|
creds = self._auth_state.load()
|
||||||
if creds is not None:
|
if creds is not None:
|
||||||
|
rotation_error: PronoteAuthRotationError | None = None
|
||||||
try:
|
try:
|
||||||
client = client_class.token_login(**creds)
|
account_pin = (
|
||||||
|
self._settings.account_pin.get_secret_value()
|
||||||
|
if self._settings.account_pin is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
client = client_class.token_login(**creds, account_pin=account_pin)
|
||||||
if client.logged_in:
|
if client.logged_in:
|
||||||
self._client = client
|
self._client = client
|
||||||
self._persist_credentials()
|
self._persist_credentials()
|
||||||
@@ -333,12 +365,16 @@ class PronoteClient:
|
|||||||
redact_exception(exc, extra_secrets=_collect_auth_secrets(self)),
|
redact_exception(exc, extra_secrets=_collect_auth_secrets(self)),
|
||||||
)
|
)
|
||||||
# Token expiré/invalide — pas de repli vers l'enrôlement QR
|
# Token expiré/invalide — pas de repli vers l'enrôlement QR
|
||||||
raise PronoteAuthRotationError(
|
rotation_error = PronoteAuthRotationError(
|
||||||
"Le token d'authentification Pronote est expiré ou invalide. "
|
"Le token d'authentification Pronote est expiré ou invalide. "
|
||||||
"Action requise : supprimez le fichier .pronote_auth_state.json "
|
"Action requise : supprimez le fichier .pronote_auth_state.json "
|
||||||
"et relancez avec un nouveau QR code (PRONOTE_QR_CODE_FILE + "
|
"et relancez avec un nouveau QR code (PRONOTE_QR_CODE_FILE + "
|
||||||
"PRONOTE_QR_PIN)."
|
"PRONOTE_QR_PIN)."
|
||||||
) from None
|
)
|
||||||
|
if rotation_error is not None:
|
||||||
|
# Lever hors du bloc ``except`` évite de conserver l'erreur
|
||||||
|
# externe dans ``__context__``.
|
||||||
|
raise rotation_error from None
|
||||||
|
|
||||||
# Enrôlement : premier login via QR code (aucun credential persisté)
|
# Enrôlement : premier login via QR code (aucun credential persisté)
|
||||||
client = self._enroll_qr_code(client_class)
|
client = self._enroll_qr_code(client_class)
|
||||||
@@ -373,6 +409,7 @@ class PronoteClient:
|
|||||||
) from None
|
) from None
|
||||||
|
|
||||||
# Read and validate QR code JSON
|
# Read and validate QR code JSON
|
||||||
|
read_error: PronoteAuthRotationError | None = None
|
||||||
try:
|
try:
|
||||||
qr_path = Path(qr_file)
|
qr_path = Path(qr_file)
|
||||||
qr_data: Any = json.loads(qr_path.read_text(encoding="utf-8"))
|
qr_data: Any = json.loads(qr_path.read_text(encoding="utf-8"))
|
||||||
@@ -382,10 +419,14 @@ class PronoteClient:
|
|||||||
redact_secrets(qr_file, extra_secrets=_collect_auth_secrets(self)),
|
redact_secrets(qr_file, extra_secrets=_collect_auth_secrets(self)),
|
||||||
redact_exception(exc, extra_secrets=_collect_auth_secrets(self)),
|
redact_exception(exc, extra_secrets=_collect_auth_secrets(self)),
|
||||||
)
|
)
|
||||||
raise PronoteAuthRotationError(
|
read_error = PronoteAuthRotationError(
|
||||||
"Impossible de lire le fichier QR code : "
|
"Impossible de lire le fichier QR code : "
|
||||||
f"{redact_secrets(qr_file, extra_secrets=_collect_auth_secrets(self))}"
|
f"{redact_secrets(qr_file, extra_secrets=_collect_auth_secrets(self))}"
|
||||||
) from None
|
)
|
||||||
|
if read_error is not None:
|
||||||
|
# Lever hors du bloc ``except`` évite de conserver l'erreur
|
||||||
|
# externe dans ``__context__``.
|
||||||
|
raise read_error from None
|
||||||
|
|
||||||
# Validate required keys
|
# Validate required keys
|
||||||
for key in ("login", "jeton", "url"):
|
for key in ("login", "jeton", "url"):
|
||||||
@@ -397,22 +438,33 @@ class PronoteClient:
|
|||||||
pin_value = qr_pin.get_secret_value()
|
pin_value = qr_pin.get_secret_value()
|
||||||
app_uuid = f"pronote-sync-{uuid4().hex}"
|
app_uuid = f"pronote-sync-{uuid4().hex}"
|
||||||
|
|
||||||
|
enrollment_error: PronoteAuthRotationError | None = None
|
||||||
try:
|
try:
|
||||||
|
account_pin = (
|
||||||
|
self._settings.account_pin.get_secret_value()
|
||||||
|
if self._settings.account_pin is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
client = client_class.qrcode_login(
|
client = client_class.qrcode_login(
|
||||||
qr_code=qr_data,
|
qr_code=qr_data,
|
||||||
pin=pin_value,
|
pin=pin_value,
|
||||||
uuid=app_uuid,
|
uuid=app_uuid,
|
||||||
|
account_pin=account_pin,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Échec de l'enrôlement QR : %s",
|
"Échec de l'enrôlement QR : %s",
|
||||||
redact_exception(exc, extra_secrets=_collect_auth_secrets(self)),
|
redact_exception(exc, extra_secrets=_collect_auth_secrets(self)),
|
||||||
)
|
)
|
||||||
raise PronoteAuthRotationError(
|
enrollment_error = PronoteAuthRotationError(
|
||||||
"Échec de l'enrôlement par QR code : PIN invalide ou QR code expiré. "
|
"Échec de l'enrôlement par QR code : PIN invalide ou QR code expiré. "
|
||||||
"Générez un nouveau QR code dans l'application Pronote et mettez à "
|
"Générez un nouveau QR code dans l'application Pronote et mettez à "
|
||||||
"jour PRONOTE_QR_CODE_FILE."
|
"jour PRONOTE_QR_CODE_FILE."
|
||||||
) from None
|
)
|
||||||
|
if enrollment_error is not None:
|
||||||
|
# Lever hors du bloc ``except`` évite de conserver l'erreur
|
||||||
|
# externe dans ``__context__``.
|
||||||
|
raise enrollment_error from None
|
||||||
|
|
||||||
return client
|
return client
|
||||||
|
|
||||||
@@ -426,6 +478,7 @@ class PronoteClient:
|
|||||||
:return: Liste des messages des professeurs ; vide en cas d'erreur.
|
:return: Liste des messages des professeurs ; vide en cas d'erreur.
|
||||||
:rtype: list[Message]
|
:rtype: list[Message]
|
||||||
"""
|
"""
|
||||||
|
with self._qr_token_operation_lock():
|
||||||
try:
|
try:
|
||||||
client = self._connect()
|
client = self._connect()
|
||||||
messages: list[Message] = []
|
messages: list[Message] = []
|
||||||
@@ -464,9 +517,24 @@ class PronoteClient:
|
|||||||
Chaque entrée est mappée sur un modèle :class:`Message` de type
|
Chaque entrée est mappée sur un modèle :class:`Message` de type
|
||||||
``SURVEY`` si c'est un sondage, ``INFORMATION`` sinon.
|
``SURVEY`` si c'est un sondage, ``INFORMATION`` sinon.
|
||||||
|
|
||||||
:return: Liste des informations et sondages ; vide en cas d'erreur.
|
En mode ``qr_token``, la récupération est ignorée sans connexion ni
|
||||||
|
appel réseau : l'endpoint ``PageActualites`` renvoie une erreur
|
||||||
|
pronotepy 20 sur les instances HubEduConnect testées, provoquant un
|
||||||
|
refresh redondant du token. La méthode retourne alors immédiatement
|
||||||
|
une liste vide et journalise un message INFO unique ; ce comportement
|
||||||
|
n'est pas configurable.
|
||||||
|
|
||||||
|
:return: Liste des informations et sondages ; vide en cas d'erreur ou
|
||||||
|
en mode ``qr_token``.
|
||||||
:rtype: list[Message]
|
:rtype: list[Message]
|
||||||
"""
|
"""
|
||||||
|
if self._settings.auth_mode == "qr_token":
|
||||||
|
logger.info(
|
||||||
|
"Récupération des informations Pronote ignorée : endpoint "
|
||||||
|
"PageActualites indisponible en mode d'authentification qr_token."
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
with self._qr_token_operation_lock():
|
||||||
try:
|
try:
|
||||||
client = self._connect()
|
client = self._connect()
|
||||||
messages: list[Message] = []
|
messages: list[Message] = []
|
||||||
@@ -523,6 +591,7 @@ class PronoteClient:
|
|||||||
:raises ConnectionError: Si la connexion réseau échoue.
|
:raises ConnectionError: Si la connexion réseau échoue.
|
||||||
:raises TimeoutError: Si la requête réseau expire.
|
:raises TimeoutError: Si la requête réseau expire.
|
||||||
"""
|
"""
|
||||||
|
with self._qr_token_operation_lock():
|
||||||
client = self._connect()
|
client = self._connect()
|
||||||
lessons: list[Lesson] = []
|
lessons: list[Lesson] = []
|
||||||
for lesson in client.lessons(start, end):
|
for lesson in client.lessons(start, end):
|
||||||
@@ -548,12 +617,12 @@ class PronoteClient:
|
|||||||
teachers=tuple(lesson.teacher_names or ()),
|
teachers=tuple(lesson.teacher_names or ()),
|
||||||
rooms=tuple(lesson.classrooms or ()),
|
rooms=tuple(lesson.classrooms or ()),
|
||||||
group=lesson.group_name,
|
group=lesson.group_name,
|
||||||
status=(LessonStatus.CANCELLED if lesson.canceled else LessonStatus.NORMAL),
|
status=lesson_status(getattr(lesson, "status", None), lesson.canceled),
|
||||||
content=content.description if content is not None else None,
|
content=content.description if content is not None else None,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self._persist_credentials()
|
self._persist_credentials()
|
||||||
return lessons
|
return collapse_replaced_lessons(lessons)
|
||||||
|
|
||||||
def get_homeworks(self, start: date, end: date) -> list[Homework]:
|
def get_homeworks(self, start: date, end: date) -> list[Homework]:
|
||||||
"""Récupère les devoirs via ``pronotepy``.
|
"""Récupère les devoirs via ``pronotepy``.
|
||||||
@@ -576,6 +645,7 @@ class PronoteClient:
|
|||||||
:raises ConnectionError: Si la connexion réseau échoue.
|
:raises ConnectionError: Si la connexion réseau échoue.
|
||||||
:raises TimeoutError: Si la requête réseau expire.
|
:raises TimeoutError: Si la requête réseau expire.
|
||||||
"""
|
"""
|
||||||
|
with self._qr_token_operation_lock():
|
||||||
client = self._connect()
|
client = self._connect()
|
||||||
homeworks: list[Homework] = []
|
homeworks: list[Homework] = []
|
||||||
for hw in client.homework(start, end):
|
for hw in client.homework(start, end):
|
||||||
|
|||||||
@@ -274,8 +274,9 @@ class PronoteFetcher:
|
|||||||
f"Impossible de récupérer l'agenda : la source {primary} a échoué"
|
f"Impossible de récupérer l'agenda : la source {primary} a échoué"
|
||||||
) from None
|
) from None
|
||||||
logger.info("Repli sur %s pour l'agenda.", fallback)
|
logger.info("Repli sur %s pour l'agenda.", fallback)
|
||||||
|
fallback_result: tuple[list[Lesson], list[SchoolEvent]] | None = None
|
||||||
try:
|
try:
|
||||||
lessons, school_events = self._fetch_agenda_source(fallback)
|
fallback_result = self._fetch_agenda_source(fallback)
|
||||||
except PronoteAuthRotationError:
|
except PronoteAuthRotationError:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -284,10 +285,11 @@ class PronoteFetcher:
|
|||||||
fallback,
|
fallback,
|
||||||
redact_exception(exc),
|
redact_exception(exc),
|
||||||
)
|
)
|
||||||
|
if fallback_result is None:
|
||||||
raise PipelineCriticalError(
|
raise PipelineCriticalError(
|
||||||
f"Impossible de récupérer l'agenda : les sources {primary}"
|
f"Impossible de récupérer l'agenda : les sources {primary} et {fallback} ont échoué"
|
||||||
f" et {fallback} ont échoué"
|
|
||||||
) from None
|
) from None
|
||||||
|
lessons, school_events = fallback_result
|
||||||
if not lessons:
|
if not lessons:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Le repli %s pour l'agenda a retourné un résultat vide après l'échec "
|
"Le repli %s pour l'agenda a retourné un résultat vide après l'échec "
|
||||||
@@ -401,8 +403,9 @@ class PronoteFetcher:
|
|||||||
f"Impossible de récupérer les devoirs : la source {primary} a échoué"
|
f"Impossible de récupérer les devoirs : la source {primary} a échoué"
|
||||||
) from None
|
) from None
|
||||||
logger.info("Repli sur %s pour les devoirs.", fallback)
|
logger.info("Repli sur %s pour les devoirs.", fallback)
|
||||||
|
fallback_result: list[Homework] | None = None
|
||||||
try:
|
try:
|
||||||
homeworks = self._fetch_homework_source(fallback, target_date)
|
fallback_result = self._fetch_homework_source(fallback, target_date)
|
||||||
except PronoteAuthRotationError:
|
except PronoteAuthRotationError:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -411,10 +414,12 @@ class PronoteFetcher:
|
|||||||
fallback,
|
fallback,
|
||||||
redact_exception(exc),
|
redact_exception(exc),
|
||||||
)
|
)
|
||||||
|
if fallback_result is None:
|
||||||
raise PipelineCriticalError(
|
raise PipelineCriticalError(
|
||||||
f"Impossible de récupérer les devoirs : les sources {primary}"
|
f"Impossible de récupérer les devoirs : les sources {primary}"
|
||||||
f" et {fallback} ont échoué"
|
f" et {fallback} ont échoué"
|
||||||
) from None
|
) from None
|
||||||
|
homeworks = fallback_result
|
||||||
if not homeworks:
|
if not homeworks:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Le repli %s pour les devoirs a retourné un résultat vide après "
|
"Le repli %s pour les devoirs a retourné un résultat vide après "
|
||||||
|
|||||||
@@ -14,13 +14,15 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import re
|
import re
|
||||||
|
import unicodedata
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from html import unescape
|
from html import unescape
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TypedDict
|
from typing import NamedTuple, TypedDict
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
from icalendar import Calendar
|
from icalendar import Calendar
|
||||||
|
|
||||||
from ...models.agenda import (
|
from ...models.agenda import (
|
||||||
@@ -33,22 +35,15 @@ from ...models.agenda import (
|
|||||||
from ...models.homework import Homework
|
from ...models.homework import Homework
|
||||||
from ...utils.redaction import redact_exception, redact_url
|
from ...utils.redaction import redact_exception, redact_url
|
||||||
from ...utils.uid import generate_deterministic_uid, normalize_pronote_uid
|
from ...utils.uid import generate_deterministic_uid, normalize_pronote_uid
|
||||||
|
from .lessons import collapse_replaced_lessons
|
||||||
|
|
||||||
_HEADER_LABEL_PATTERN = re.compile(r"\b(Matière|Professeurs?|Salles?|Groupe)\s*:\s*")
|
_HEADER_LABEL_PATTERN = re.compile(
|
||||||
|
r"(?P<label>Mati(?:ère|ere)|Professeur(?:s|\(s\))?|Salle(?:s|\(s\))?"
|
||||||
|
r"|Groupe|Partie(?:s|\(s\))?\s+de\s+classe)\s*:\s*",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
_CALNAME_PATTERN = re.compile(r"^X-WR-CALNAME(?:;[^:]*)?:([^\r\n]*)", re.MULTILINE)
|
_CALNAME_PATTERN = re.compile(r"^X-WR-CALNAME(?:;[^:]*)?:([^\r\n]*)", re.MULTILINE)
|
||||||
_TAG_PATTERN = re.compile(r"<[^>]+>")
|
_STRONG_PATTERN = re.compile(r"<strong\b[^>]*>(?P<label>.*?)</strong>", re.IGNORECASE | re.DOTALL)
|
||||||
_CONTENT_PATTERN = re.compile(
|
|
||||||
r"<strong>Contenu pédagogique\s*:\s*</strong>(.*?)(?=<strong>|</div>\s*$|\Z)",
|
|
||||||
re.DOTALL,
|
|
||||||
)
|
|
||||||
_DUE_PATTERN = re.compile(
|
|
||||||
r"<strong>Pour le (\d{2}/\d{2}/\d{4})\s*:\s*</strong>(.*?)(?=<strong>|</div>\s*$|\Z)",
|
|
||||||
re.DOTALL,
|
|
||||||
)
|
|
||||||
_ASSIGNED_PATTERN = re.compile(
|
|
||||||
r"<strong>Donné le (\d{2}/\d{2}/\d{4})\s*:\s*</strong>(.*?)(?=<strong>|</div>\s*$|\Z)",
|
|
||||||
re.DOTALL,
|
|
||||||
)
|
|
||||||
|
|
||||||
_HEADERS = {
|
_HEADERS = {
|
||||||
"accept": "text/calendar",
|
"accept": "text/calendar",
|
||||||
@@ -63,6 +58,15 @@ class HeaderInfo(TypedDict):
|
|||||||
teachers: list[str]
|
teachers: list[str]
|
||||||
rooms: list[str]
|
rooms: list[str]
|
||||||
group: str | None
|
group: str | None
|
||||||
|
class_part: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class ParsedHomeworkBlock(NamedTuple):
|
||||||
|
"""Bloc de devoir parsé avec son texte nettoyé et son HTML sûr."""
|
||||||
|
|
||||||
|
date: date
|
||||||
|
text: str
|
||||||
|
html: str
|
||||||
|
|
||||||
|
|
||||||
def fetch_ical(url: str, timeout: int = 20) -> str:
|
def fetch_ical(url: str, timeout: int = 20) -> str:
|
||||||
@@ -156,11 +160,11 @@ def split_header_and_body(description: str) -> tuple[str, str]:
|
|||||||
:return: Tuple ``(en-tête, corps)`` ; le corps est vide si aucun ``<strong>``.
|
:return: Tuple ``(en-tête, corps)`` ; le corps est vide si aucun ``<strong>``.
|
||||||
:rtype: tuple[str, str]
|
:rtype: tuple[str, str]
|
||||||
"""
|
"""
|
||||||
strong_start = description.find("<strong>")
|
strong_match = _STRONG_PATTERN.search(description)
|
||||||
if strong_start == -1:
|
if strong_match is None:
|
||||||
return description.strip(), ""
|
return description.strip(), ""
|
||||||
header = description[:strong_start].strip()
|
header = description[: strong_match.start()].strip()
|
||||||
body = description[strong_start:]
|
body = description[strong_match.start() :]
|
||||||
return header, body
|
return header, body
|
||||||
|
|
||||||
|
|
||||||
@@ -168,7 +172,7 @@ def parse_header(header: str) -> HeaderInfo:
|
|||||||
"""Parse l'en-tête texte pour extraire les métadonnées du cours.
|
"""Parse l'en-tête texte pour extraire les métadonnées du cours.
|
||||||
|
|
||||||
Les labels reconnus sont : ``Matière :``, ``Professeur(s) :``,
|
Les labels reconnus sont : ``Matière :``, ``Professeur(s) :``,
|
||||||
``Salle(s) :`` et ``Groupe :``. La recherche se fait par position
|
``Salle(s) :``, ``Groupe :`` et ``Partie(s) de classe :``. La recherche se fait par position
|
||||||
des labels, ce qui supporte aussi bien un en-tête multi-lignes
|
des labels, ce qui supporte aussi bien un en-tête multi-lignes
|
||||||
qu'un en-tête dont les lignes sont jointes sur une seule ligne.
|
qu'un en-tête dont les lignes sont jointes sur une seule ligne.
|
||||||
|
|
||||||
@@ -176,14 +180,21 @@ def parse_header(header: str) -> HeaderInfo:
|
|||||||
:return: Dictionnaire typé avec les champs subject, teachers, rooms, group.
|
:return: Dictionnaire typé avec les champs subject, teachers, rooms, group.
|
||||||
:rtype: HeaderInfo
|
:rtype: HeaderInfo
|
||||||
"""
|
"""
|
||||||
info: HeaderInfo = {"subject": "", "teachers": [], "rooms": [], "group": None}
|
info: HeaderInfo = {
|
||||||
matches = list(_HEADER_LABEL_PATTERN.finditer(header))
|
"subject": "",
|
||||||
|
"teachers": [],
|
||||||
|
"rooms": [],
|
||||||
|
"group": None,
|
||||||
|
"class_part": None,
|
||||||
|
}
|
||||||
|
header_text = _strip_html(header)
|
||||||
|
matches = list(_HEADER_LABEL_PATTERN.finditer(header_text))
|
||||||
for index, match in enumerate(matches):
|
for index, match in enumerate(matches):
|
||||||
value_start = match.end()
|
value_start = match.end()
|
||||||
value_end = matches[index + 1].start() if index + 1 < len(matches) else len(header)
|
value_end = matches[index + 1].start() if index + 1 < len(matches) else len(header_text)
|
||||||
value = unescape(header[value_start:value_end].strip())
|
value = unescape(header_text[value_start:value_end].strip())
|
||||||
label = match.group(1).lower()
|
label = _normalize_label(match.group("label")).replace("(s)", "s")
|
||||||
if label == "matière":
|
if label == "matiere":
|
||||||
info["subject"] = value
|
info["subject"] = value
|
||||||
elif label in ("professeur", "professeurs"):
|
elif label in ("professeur", "professeurs"):
|
||||||
info["teachers"] = [part.strip() for part in value.split(",") if part.strip()]
|
info["teachers"] = [part.strip() for part in value.split(",") if part.strip()]
|
||||||
@@ -191,9 +202,44 @@ def parse_header(header: str) -> HeaderInfo:
|
|||||||
info["rooms"] = [part.strip() for part in value.split(",") if part.strip()]
|
info["rooms"] = [part.strip() for part in value.split(",") if part.strip()]
|
||||||
elif label == "groupe":
|
elif label == "groupe":
|
||||||
info["group"] = value
|
info["group"] = value
|
||||||
|
elif label in ("partie de classe", "parties de classe"):
|
||||||
|
info["class_part"] = value
|
||||||
return info
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_label(value: str) -> str:
|
||||||
|
"""Normalise un libellé iCal pour comparer des variantes contrôlées.
|
||||||
|
|
||||||
|
:param value: Libellé à normaliser.
|
||||||
|
:return: Libellé minuscule sans accents et avec des espaces unifiés.
|
||||||
|
:rtype: str
|
||||||
|
"""
|
||||||
|
decomposed = unicodedata.normalize("NFKD", value)
|
||||||
|
without_accents = "".join(char for char in decomposed if not unicodedata.combining(char))
|
||||||
|
return re.sub(r"\s+", " ", without_accents).strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_html(fragment: str) -> str:
|
||||||
|
"""Nettoie un fragment HTML de description sans exécuter de contenu.
|
||||||
|
|
||||||
|
:param fragment: Fragment HTML extrait d'une section de devoir.
|
||||||
|
:return: HTML conservé sans scripts, styles ni attributs exécutables.
|
||||||
|
:rtype: str
|
||||||
|
"""
|
||||||
|
soup = BeautifulSoup(fragment, "html.parser")
|
||||||
|
for tag in soup.find_all(("script", "style")):
|
||||||
|
tag.decompose()
|
||||||
|
for tag in soup.find_all(True):
|
||||||
|
for attribute in list(tag.attrs):
|
||||||
|
lowered = attribute.lower()
|
||||||
|
value = tag.attrs[attribute]
|
||||||
|
if lowered.startswith("on") or (
|
||||||
|
lowered in ("href", "src") and str(value).lower().strip().startswith("javascript:")
|
||||||
|
):
|
||||||
|
del tag.attrs[attribute]
|
||||||
|
return soup.decode_contents().strip()
|
||||||
|
|
||||||
|
|
||||||
def _strip_html(text: str) -> str:
|
def _strip_html(text: str) -> str:
|
||||||
"""Retire les balises HTML d'un texte et nettoie les espaces.
|
"""Retire les balises HTML d'un texte et nettoie les espaces.
|
||||||
|
|
||||||
@@ -201,8 +247,10 @@ def _strip_html(text: str) -> str:
|
|||||||
:return: Texte brut sans balises, entités HTML décodées.
|
:return: Texte brut sans balises, entités HTML décodées.
|
||||||
:rtype: str
|
:rtype: str
|
||||||
"""
|
"""
|
||||||
cleaned = _TAG_PATTERN.sub("", text)
|
soup = BeautifulSoup(text, "html.parser")
|
||||||
return unescape(cleaned).strip()
|
for tag in soup.find_all(("script", "style")):
|
||||||
|
tag.decompose()
|
||||||
|
return " ".join(unescape(soup.get_text(" ", strip=True)).split())
|
||||||
|
|
||||||
|
|
||||||
def _parse_french_date(value: str) -> date | None:
|
def _parse_french_date(value: str) -> date | None:
|
||||||
@@ -218,44 +266,57 @@ def _parse_french_date(value: str) -> date | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_body(body: str) -> tuple[str | None, list[tuple[date, str]], list[tuple[date, str]]]:
|
def parse_body(
|
||||||
|
body: str,
|
||||||
|
) -> tuple[str | None, list[ParsedHomeworkBlock], list[ParsedHomeworkBlock]]:
|
||||||
"""Parse le corps HTML pour extraire contenu pédagogique et devoirs.
|
"""Parse le corps HTML pour extraire contenu pédagogique et devoirs.
|
||||||
|
|
||||||
Le contenu est extrait de la section ``<strong>Contenu pédagogique :</strong>``.
|
Le contenu est extrait de la section ``<strong>Contenu pédagogique :</strong>``.
|
||||||
Les devoirs à faire sont extraits des sections ``<strong>Pour le JJ/MM/AAAA :</strong>``
|
Les devoirs à faire sont extraits des sections ``<strong>Pour le JJ/MM/AAAA :</strong>``
|
||||||
(liste de tuples ``(date, texte)`` dans l'ordre du flux) et les devoirs donnés
|
et les devoirs donnés des sections ``<strong>Donné le JJ/MM/AAAA :</strong>``.
|
||||||
des sections ``<strong>Donné le JJ/MM/AAAA :</strong>`` (liste de tuples
|
Les listes préservent tous les blocs, même lorsque plusieurs sections partagent
|
||||||
``(date, texte)``). Les listes préservent tous les blocs, même lorsque plusieurs
|
la même date, avec le texte nettoyé et le HTML sûr de chaque bloc.
|
||||||
sections partagent la même date.
|
|
||||||
|
|
||||||
:param body: Corps HTML (à partir du premier ``<strong>``).
|
:param body: Corps HTML (à partir du premier ``<strong>``).
|
||||||
:return: Tuple ``(contenu pédagogique, devoirs dus, devoirs donnés)``.
|
:return: Tuple ``(contenu pédagogique, devoirs dus, devoirs donnés)``.
|
||||||
:rtype: tuple[str | None, list[tuple[date, str]], list[tuple[date, str]]]
|
:rtype: tuple[str | None, list[ParsedHomeworkBlock], list[ParsedHomeworkBlock]]
|
||||||
"""
|
"""
|
||||||
content: str | None = None
|
content: str | None = None
|
||||||
due_blocks: list[tuple[date, str]] = []
|
due_blocks: list[ParsedHomeworkBlock] = []
|
||||||
assigned_blocks: list[tuple[date, str]] = []
|
assigned_blocks: list[ParsedHomeworkBlock] = []
|
||||||
|
|
||||||
content_match = _CONTENT_PATTERN.search(body)
|
matches = list(_STRONG_PATTERN.finditer(body))
|
||||||
if content_match is not None:
|
for index, match in enumerate(matches):
|
||||||
content = _strip_html(content_match.group(1))
|
next_start = matches[index + 1].start() if index + 1 < len(matches) else len(body)
|
||||||
|
heading = _strip_html(match.group("label")).rstrip(":").strip()
|
||||||
|
fragment = body[match.end() : next_start]
|
||||||
|
safe_html = _sanitize_html(fragment)
|
||||||
|
text = _strip_html(fragment)
|
||||||
|
|
||||||
for match in _DUE_PATTERN.finditer(body):
|
normalized_heading = _normalize_label(heading)
|
||||||
due_date = _parse_french_date(match.group(1))
|
if normalized_heading == "contenu pedagogique":
|
||||||
|
content = text
|
||||||
|
continue
|
||||||
|
|
||||||
|
due_match = re.fullmatch(r"Pour\s+le\s+(\d{2}/\d{2}/\d{4})", heading, re.IGNORECASE)
|
||||||
|
assigned_match = re.fullmatch(
|
||||||
|
r"Donne\s+le\s+(\d{2}/\d{2}/\d{4})", normalized_heading, re.IGNORECASE
|
||||||
|
)
|
||||||
|
if due_match is not None:
|
||||||
|
due_date = _parse_french_date(due_match.group(1))
|
||||||
if due_date is not None:
|
if due_date is not None:
|
||||||
due_blocks.append((due_date, _strip_html(match.group(2))))
|
due_blocks.append(ParsedHomeworkBlock(due_date, text, safe_html))
|
||||||
|
elif assigned_match is not None:
|
||||||
for match in _ASSIGNED_PATTERN.finditer(body):
|
assigned_date = _parse_french_date(assigned_match.group(1))
|
||||||
assigned_date = _parse_french_date(match.group(1))
|
|
||||||
if assigned_date is not None:
|
if assigned_date is not None:
|
||||||
assigned_blocks.append((assigned_date, _strip_html(match.group(2))))
|
assigned_blocks.append(ParsedHomeworkBlock(assigned_date, text, safe_html))
|
||||||
|
|
||||||
return content, due_blocks, assigned_blocks
|
return content, due_blocks, assigned_blocks
|
||||||
|
|
||||||
|
|
||||||
def parse_homework_blocks(
|
def parse_homework_blocks(
|
||||||
due_blocks: list[tuple[date, str]],
|
due_blocks: list[ParsedHomeworkBlock],
|
||||||
assigned_blocks: list[tuple[date, str]],
|
assigned_blocks: list[ParsedHomeworkBlock],
|
||||||
) -> tuple[HomeworkBlock, ...]:
|
) -> tuple[HomeworkBlock, ...]:
|
||||||
"""Construit les :class:`HomeworkBlock` depuis les listes de devoirs.
|
"""Construit les :class:`HomeworkBlock` depuis les listes de devoirs.
|
||||||
|
|
||||||
@@ -269,10 +330,12 @@ def parse_homework_blocks(
|
|||||||
:rtype: tuple[HomeworkBlock, ...]
|
:rtype: tuple[HomeworkBlock, ...]
|
||||||
"""
|
"""
|
||||||
blocks: list[HomeworkBlock] = []
|
blocks: list[HomeworkBlock] = []
|
||||||
for due_date, text in due_blocks:
|
for block in due_blocks:
|
||||||
blocks.append(HomeworkBlock(kind="due", date=due_date, text=text, html=text))
|
blocks.append(HomeworkBlock(kind="due", date=block.date, text=block.text, html=block.html))
|
||||||
for assigned_date, text in assigned_blocks:
|
for block in assigned_blocks:
|
||||||
blocks.append(HomeworkBlock(kind="assigned", date=assigned_date, text=text, html=text))
|
blocks.append(
|
||||||
|
HomeworkBlock(kind="assigned", date=block.date, text=block.text, html=block.html)
|
||||||
|
)
|
||||||
return tuple(blocks)
|
return tuple(blocks)
|
||||||
|
|
||||||
|
|
||||||
@@ -286,24 +349,32 @@ def normalize_homework_text(text: str) -> str:
|
|||||||
:return: Texte normalisé.
|
:return: Texte normalisé.
|
||||||
:rtype: str
|
:rtype: str
|
||||||
"""
|
"""
|
||||||
normalized = re.sub(r"\s+", " ", text)
|
return _strip_html(text).casefold()
|
||||||
normalized = _TAG_PATTERN.sub("", normalized)
|
|
||||||
return normalized.strip().lower()
|
|
||||||
|
|
||||||
|
|
||||||
def generate_homework_id(due_on: date, normalized_text: str) -> str:
|
def generate_homework_id(
|
||||||
|
due_on: date,
|
||||||
|
normalized_text: str,
|
||||||
|
subject: str = "",
|
||||||
|
teachers: tuple[str, ...] = (),
|
||||||
|
) -> str:
|
||||||
"""Génère un ID stable pour un devoir.
|
"""Génère un ID stable pour un devoir.
|
||||||
|
|
||||||
L'ID est la clé ``AAAA-MM-JJ|texte_normalisé`` hachée en SHA-1 dont
|
L'ID est la clé ``AAAA-MM-JJ|matière|enseignants|texte_normalisé`` hachée en SHA-1 dont
|
||||||
on garde les 12 premiers caractères hexadécimaux. Le hachage n'est
|
on garde les 12 premiers caractères hexadécimaux. Le hachage n'est
|
||||||
pas utilisé à des fins de sécurité (``usedforsecurity=False``).
|
pas utilisé à des fins de sécurité (``usedforsecurity=False``).
|
||||||
|
|
||||||
:param due_on: Date d'échéance du devoir.
|
:param due_on: Date d'échéance du devoir.
|
||||||
:param normalized_text: Texte normalisé du devoir.
|
:param normalized_text: Texte normalisé du devoir.
|
||||||
|
:param subject: Matière du devoir, utile pour distinguer les homonymes.
|
||||||
|
:param teachers: Enseignants du devoir, triés pour garantir la stabilité.
|
||||||
:return: ID stable (12 caractères hexadécimaux).
|
:return: ID stable (12 caractères hexadécimaux).
|
||||||
:rtype: str
|
:rtype: str
|
||||||
"""
|
"""
|
||||||
payload = f"{due_on.isoformat()}|{normalized_text}".encode()
|
payload = (
|
||||||
|
f"{due_on.isoformat()}|{subject.casefold()}|"
|
||||||
|
f"{','.join(sorted(teacher.casefold() for teacher in teachers))}|{normalized_text}".encode()
|
||||||
|
)
|
||||||
return hashlib.sha1(payload, usedforsecurity=False).hexdigest()[:12]
|
return hashlib.sha1(payload, usedforsecurity=False).hexdigest()[:12]
|
||||||
|
|
||||||
|
|
||||||
@@ -313,7 +384,7 @@ def collect_homeworks(lessons: list[Lesson], target_date: date) -> list[Homework
|
|||||||
Passe 1 : les blocs ``due`` (devoirs à faire pour ``target_date``)
|
Passe 1 : les blocs ``due`` (devoirs à faire pour ``target_date``)
|
||||||
de tous les cours. Passe 2 : les blocs ``assigned`` (devoirs donnés
|
de tous les cours. Passe 2 : les blocs ``assigned`` (devoirs donnés
|
||||||
le jour cible) des cours du jour ``target_date``. La déduplication
|
le jour cible) des cours du jour ``target_date``. La déduplication
|
||||||
se fait par texte normalisé (premier venu, premier servi) et le
|
se fait par texte, matière et enseignants normalisés (premier venu, premier servi) et le
|
||||||
résultat est trié par matière puis texte.
|
résultat est trié par matière puis texte.
|
||||||
|
|
||||||
:param lessons: Liste de tous les cours (VEVENT) parsés.
|
:param lessons: Liste de tous les cours (VEVENT) parsés.
|
||||||
@@ -321,15 +392,22 @@ def collect_homeworks(lessons: list[Lesson], target_date: date) -> list[Homework
|
|||||||
:return: Liste unique de devoirs, triée par matière puis texte.
|
:return: Liste unique de devoirs, triée par matière puis texte.
|
||||||
:rtype: list[Homework]
|
:rtype: list[Homework]
|
||||||
"""
|
"""
|
||||||
by_text: dict[str, Homework] = {}
|
by_context: dict[tuple[str, tuple[str, ...], str], Homework] = {}
|
||||||
|
|
||||||
for lesson in lessons:
|
for lesson in lessons:
|
||||||
for block in lesson.homework_blocks:
|
for block in lesson.homework_blocks:
|
||||||
if block.kind == "due" and block.date == target_date:
|
if block.kind == "due" and block.date == target_date:
|
||||||
key = normalize_homework_text(block.text)
|
normalized_text = normalize_homework_text(block.text)
|
||||||
if key not in by_text:
|
key = (
|
||||||
by_text[key] = Homework(
|
lesson.subject.casefold(),
|
||||||
id=generate_homework_id(target_date, key),
|
tuple(sorted(teacher.casefold() for teacher in lesson.teachers)),
|
||||||
|
normalized_text,
|
||||||
|
)
|
||||||
|
if key not in by_context:
|
||||||
|
by_context[key] = Homework(
|
||||||
|
id=generate_homework_id(
|
||||||
|
target_date, normalized_text, lesson.subject, lesson.teachers
|
||||||
|
),
|
||||||
subject=lesson.subject,
|
subject=lesson.subject,
|
||||||
teachers=lesson.teachers,
|
teachers=lesson.teachers,
|
||||||
assigned_on=lesson.start.date(),
|
assigned_on=lesson.start.date(),
|
||||||
@@ -343,10 +421,17 @@ def collect_homeworks(lessons: list[Lesson], target_date: date) -> list[Homework
|
|||||||
continue
|
continue
|
||||||
for block in lesson.homework_blocks:
|
for block in lesson.homework_blocks:
|
||||||
if block.kind == "assigned":
|
if block.kind == "assigned":
|
||||||
key = normalize_homework_text(block.text)
|
normalized_text = normalize_homework_text(block.text)
|
||||||
if key not in by_text:
|
key = (
|
||||||
by_text[key] = Homework(
|
lesson.subject.casefold(),
|
||||||
id=generate_homework_id(target_date, key),
|
tuple(sorted(teacher.casefold() for teacher in lesson.teachers)),
|
||||||
|
normalized_text,
|
||||||
|
)
|
||||||
|
if key not in by_context:
|
||||||
|
by_context[key] = Homework(
|
||||||
|
id=generate_homework_id(
|
||||||
|
target_date, normalized_text, lesson.subject, lesson.teachers
|
||||||
|
),
|
||||||
subject=lesson.subject,
|
subject=lesson.subject,
|
||||||
teachers=lesson.teachers,
|
teachers=lesson.teachers,
|
||||||
assigned_on=block.date,
|
assigned_on=block.date,
|
||||||
@@ -355,7 +440,7 @@ def collect_homeworks(lessons: list[Lesson], target_date: date) -> list[Homework
|
|||||||
html=block.html,
|
html=block.html,
|
||||||
)
|
)
|
||||||
|
|
||||||
return sorted(by_text.values(), key=lambda hw: (hw.subject.lower(), hw.text.lower()))
|
return sorted(by_context.values(), key=lambda hw: (hw.subject.lower(), hw.text.lower()))
|
||||||
|
|
||||||
|
|
||||||
def parse_ical(raw_ical: str) -> tuple[list[Lesson], list[Homework], list[SchoolEvent]]:
|
def parse_ical(raw_ical: str) -> tuple[list[Lesson], list[Homework], list[SchoolEvent]]:
|
||||||
@@ -399,15 +484,28 @@ def parse_ical(raw_ical: str) -> tuple[list[Lesson], list[Homework], list[School
|
|||||||
else:
|
else:
|
||||||
categories = [str(category) for category in categories_obj.cats]
|
categories = [str(category) for category in categories_obj.cats]
|
||||||
|
|
||||||
# Événements de type vacances/congés (tout le jour).
|
normalized_categories = [_normalize_label(category) for category in categories]
|
||||||
if any(cat in ("Congés", "Vacances") for cat in categories):
|
holiday_kind: SchoolEventKind | None = None
|
||||||
|
if any(
|
||||||
|
category in ("conges", "vacances", "vacances scolaires")
|
||||||
|
for category in normalized_categories
|
||||||
|
):
|
||||||
|
holiday_kind = SchoolEventKind.HOLIDAY
|
||||||
|
elif any(
|
||||||
|
category in ("jour ferie", "jours feries", "ferie", "feries")
|
||||||
|
for category in normalized_categories
|
||||||
|
):
|
||||||
|
holiday_kind = SchoolEventKind.PUBLIC_HOLIDAY
|
||||||
|
|
||||||
|
# Événements de type vacances/congés/jours fériés (tout le jour).
|
||||||
|
if holiday_kind is not None:
|
||||||
from_date = start.date() if isinstance(start, datetime) else start
|
from_date = start.date() if isinstance(start, datetime) else start
|
||||||
to_date = end.date() if isinstance(end, datetime) else end
|
to_date = end.date() if isinstance(end, datetime) else end
|
||||||
summary = component.get("summary")
|
summary = component.get("summary")
|
||||||
label = str(summary) if summary is not None else ""
|
label = str(summary) if summary is not None else ""
|
||||||
school_events.append(
|
school_events.append(
|
||||||
SchoolEvent(
|
SchoolEvent(
|
||||||
kind=SchoolEventKind.HOLIDAY,
|
kind=holiday_kind,
|
||||||
label=label,
|
label=label,
|
||||||
from_date=from_date,
|
from_date=from_date,
|
||||||
to_date=to_date,
|
to_date=to_date,
|
||||||
@@ -422,9 +520,15 @@ def parse_ical(raw_ical: str) -> tuple[list[Lesson], list[Homework], list[School
|
|||||||
|
|
||||||
status_obj = component.get("status")
|
status_obj = component.get("status")
|
||||||
status_value = str(status_obj).strip().upper() if status_obj is not None else ""
|
status_value = str(status_obj).strip().upper() if status_obj is not None else ""
|
||||||
if status_value == "CANCELLED" or "Cours - Cours annulé" in categories:
|
normalized_status_categories = set(normalized_categories)
|
||||||
|
if status_value == "CANCELLED" or any(
|
||||||
|
"annul" in category for category in normalized_status_categories
|
||||||
|
):
|
||||||
status = LessonStatus.CANCELLED
|
status = LessonStatus.CANCELLED
|
||||||
elif "Cours - Cours déplacé" in categories:
|
elif any(
|
||||||
|
any(token in category for token in ("deplac", "changement de salle", "modifi"))
|
||||||
|
for category in normalized_status_categories
|
||||||
|
):
|
||||||
status = LessonStatus.MOVED
|
status = LessonStatus.MOVED
|
||||||
else:
|
else:
|
||||||
status = LessonStatus.NORMAL
|
status = LessonStatus.NORMAL
|
||||||
@@ -458,10 +562,11 @@ def parse_ical(raw_ical: str) -> tuple[list[Lesson], list[Homework], list[School
|
|||||||
teachers=tuple(lesson_data["teachers"]),
|
teachers=tuple(lesson_data["teachers"]),
|
||||||
rooms=tuple(lesson_data["rooms"]),
|
rooms=tuple(lesson_data["rooms"]),
|
||||||
group=lesson_data["group"],
|
group=lesson_data["group"],
|
||||||
|
class_part=lesson_data["class_part"],
|
||||||
status=status,
|
status=status,
|
||||||
content=content,
|
content=content,
|
||||||
homework_blocks=homework_blocks,
|
homework_blocks=homework_blocks,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
return lessons, homeworks, school_events
|
return collapse_replaced_lessons(lessons), homeworks, school_events
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Règles communes de statut et de consolidation des cours Pronote."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unicodedata
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from ...models.agenda import Lesson, LessonStatus
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_status(value: str) -> str:
|
||||||
|
"""Normalise un statut Pronote pour une comparaison tolérante.
|
||||||
|
|
||||||
|
:param value: Statut brut fourni par Pronote.
|
||||||
|
:return: Statut minuscule sans accents.
|
||||||
|
:rtype: str
|
||||||
|
"""
|
||||||
|
decomposed = unicodedata.normalize("NFKD", value)
|
||||||
|
without_accents = "".join(char for char in decomposed if not unicodedata.combining(char))
|
||||||
|
return " ".join(without_accents.casefold().split())
|
||||||
|
|
||||||
|
|
||||||
|
def lesson_status(raw_status: object, canceled: bool = False) -> LessonStatus:
|
||||||
|
"""Convertit le statut Pronote en statut métier canonique.
|
||||||
|
|
||||||
|
L'indicateur d'annulation et les libellés annulé sont prioritaires sur un
|
||||||
|
éventuel libellé de déplacement. Les libellés inconnus restent normaux.
|
||||||
|
|
||||||
|
:param raw_status: Valeur du champ ``Lesson.status`` de pronotepy.
|
||||||
|
:param canceled: Indicateur ``Lesson.canceled`` de pronotepy.
|
||||||
|
:return: Statut métier canonique.
|
||||||
|
:rtype: LessonStatus
|
||||||
|
"""
|
||||||
|
status = _normalize_status(raw_status) if isinstance(raw_status, str) else ""
|
||||||
|
if canceled or "annul" in status:
|
||||||
|
return LessonStatus.CANCELLED
|
||||||
|
if any(token in status for token in ("deplac", "changement de salle", "modifi")):
|
||||||
|
return LessonStatus.MOVED
|
||||||
|
return LessonStatus.NORMAL
|
||||||
|
|
||||||
|
|
||||||
|
def collapse_replaced_lessons(lessons: list[Lesson]) -> list[Lesson]:
|
||||||
|
"""Supprime l'ancien exemplaire annulé lorsqu'un cours déplacé est présent.
|
||||||
|
|
||||||
|
Deux cours sont considérés comme les exemplaires d'une même modification
|
||||||
|
lorsqu'ils partagent les dates, la matière, les enseignants et le groupe,
|
||||||
|
et qu'une seule annulation et un seul déplacement se distinguent par leur
|
||||||
|
salle. Une annulation isolée ou ambiguë est toujours conservée.
|
||||||
|
|
||||||
|
:param lessons: Cours issus d'une source Pronote.
|
||||||
|
:return: Cours consolidés dans leur ordre d'origine.
|
||||||
|
:rtype: list[Lesson]
|
||||||
|
"""
|
||||||
|
grouped: dict[tuple[object, ...], list[tuple[int, Lesson]]] = defaultdict(list)
|
||||||
|
for index, lesson in enumerate(lessons):
|
||||||
|
key = (
|
||||||
|
lesson.start,
|
||||||
|
lesson.end,
|
||||||
|
lesson.subject.casefold(),
|
||||||
|
tuple(teacher.casefold() for teacher in lesson.teachers),
|
||||||
|
(lesson.group or "").casefold(),
|
||||||
|
)
|
||||||
|
grouped[key].append((index, lesson))
|
||||||
|
|
||||||
|
replaced_indexes: set[int] = set()
|
||||||
|
for siblings in grouped.values():
|
||||||
|
cancelled = [item for item in siblings if item[1].status == LessonStatus.CANCELLED]
|
||||||
|
moved = [item for item in siblings if item[1].status == LessonStatus.MOVED]
|
||||||
|
if len(cancelled) == 1 and len(moved) == 1 and cancelled[0][1].rooms != moved[0][1].rooms:
|
||||||
|
replaced_indexes.add(cancelled[0][0])
|
||||||
|
|
||||||
|
return [lesson for index, lesson in enumerate(lessons) if index not in replaced_indexes]
|
||||||
@@ -85,6 +85,7 @@ class JsonTheoreticalAgendaProvider:
|
|||||||
self._file_path: str = file_path
|
self._file_path: str = file_path
|
||||||
self._parity_service: WeekParityService | None = parity_service
|
self._parity_service: WeekParityService | None = parity_service
|
||||||
self._holiday_calendar: SchoolHolidayCalendar | None = holiday_calendar
|
self._holiday_calendar: SchoolHolidayCalendar | None = holiday_calendar
|
||||||
|
load_error: PronoteSyncError | None = None
|
||||||
try:
|
try:
|
||||||
content = Path(file_path).read_text(encoding="utf-8")
|
content = Path(file_path).read_text(encoding="utf-8")
|
||||||
parsed = TheoreticalAgendaFile.model_validate_json(content)
|
parsed = TheoreticalAgendaFile.model_validate_json(content)
|
||||||
@@ -94,9 +95,11 @@ class JsonTheoreticalAgendaProvider:
|
|||||||
redact_secrets(str(file_path)),
|
redact_secrets(str(file_path)),
|
||||||
redact_exception(exc),
|
redact_exception(exc),
|
||||||
)
|
)
|
||||||
raise PronoteSyncError(
|
load_error = PronoteSyncError(
|
||||||
f"Le fichier d'agenda théorique est invalide : {redact_secrets(str(file_path))}"
|
f"Le fichier d'agenda théorique est invalide : {redact_secrets(str(file_path))}"
|
||||||
) from None
|
)
|
||||||
|
if load_error is not None:
|
||||||
|
raise load_error from None
|
||||||
self._lessons: tuple[TheoreticalLessonEntry, ...] = parsed.lessons
|
self._lessons: tuple[TheoreticalLessonEntry, ...] = parsed.lessons
|
||||||
if self._parity_service is None and any(
|
if self._parity_service is None and any(
|
||||||
entry.week in ("even", "odd") for entry in self._lessons
|
entry.week in ("even", "odd") for entry in self._lessons
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ class SchoolHolidayCalendar:
|
|||||||
raise PronoteSyncError(
|
raise PronoteSyncError(
|
||||||
f"Le fichier de vacances scolaires est introuvable : {redact_secrets(str(path))}"
|
f"Le fichier de vacances scolaires est introuvable : {redact_secrets(str(path))}"
|
||||||
) from None
|
) from None
|
||||||
|
load_error: PronoteSyncError | None = None
|
||||||
try:
|
try:
|
||||||
data: Any = json.loads(path.read_text(encoding="utf-8"))
|
data: Any = json.loads(path.read_text(encoding="utf-8"))
|
||||||
file_model: SchoolHolidayFile = SchoolHolidayFile.model_validate(data)
|
file_model: SchoolHolidayFile = SchoolHolidayFile.model_validate(data)
|
||||||
@@ -86,9 +87,11 @@ class SchoolHolidayCalendar:
|
|||||||
redact_secrets(str(path)),
|
redact_secrets(str(path)),
|
||||||
redact_exception(exc),
|
redact_exception(exc),
|
||||||
)
|
)
|
||||||
raise PronoteSyncError(
|
load_error = PronoteSyncError(
|
||||||
f"Le fichier de vacances scolaires est invalide : {redact_secrets(str(path))}"
|
f"Le fichier de vacances scolaires est invalide : {redact_secrets(str(path))}"
|
||||||
) from None
|
)
|
||||||
|
if load_error is not None:
|
||||||
|
raise load_error from None
|
||||||
self._periods = file_model.periods
|
self._periods = file_model.periods
|
||||||
|
|
||||||
def is_holiday(self, target_date: date) -> bool:
|
def is_holiday(self, target_date: date) -> bool:
|
||||||
|
|||||||
@@ -191,7 +191,11 @@ class CalDAVGateway:
|
|||||||
for vevent in component.walk("VEVENT"):
|
for vevent in component.walk("VEVENT"):
|
||||||
managed = vevent.get(MANAGED_PROPERTY)
|
managed = vevent.get(MANAGED_PROPERTY)
|
||||||
if managed is not None and str(managed) == MANAGED_VALUE:
|
if managed is not None and str(managed) == MANAGED_VALUE:
|
||||||
raw_uid = str(vevent.get("UID"))
|
raw_uid_value = vevent.get("UID")
|
||||||
|
if raw_uid_value is None or not str(raw_uid_value).strip():
|
||||||
|
logger.warning("Événement CalDAV géré sans UID ignoré.")
|
||||||
|
continue
|
||||||
|
raw_uid = str(raw_uid_value)
|
||||||
canonical_uid = normalize_pronote_uid(raw_uid)
|
canonical_uid = normalize_pronote_uid(raw_uid)
|
||||||
result.append((raw_uid, canonical_uid, vevent))
|
result.append((raw_uid, canonical_uid, vevent))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ l'outil et de ne jamais toucher aux événements étrangers du calendrier.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, time
|
from datetime import datetime, timedelta
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
from icalendar import Calendar, Component, Event, vDate, vDatetime
|
from icalendar import Calendar, Component, Event, vDate, vDatetime
|
||||||
@@ -33,7 +33,15 @@ MANAGED_VALUE = "v1"
|
|||||||
PRODID = "-//pronote-sync//NONSGML v1.0//EN"
|
PRODID = "-//pronote-sync//NONSGML v1.0//EN"
|
||||||
|
|
||||||
#: Propriétés prises en compte dans la signature sémantique d'un composant.
|
#: Propriétés prises en compte dans la signature sémantique d'un composant.
|
||||||
_SIGNATURE_KEYS: tuple[str, ...] = ("UID", "SUMMARY", "DTSTART", "DTEND", "STATUS", "DESCRIPTION")
|
_SIGNATURE_KEYS: tuple[str, ...] = (
|
||||||
|
"UID",
|
||||||
|
"SUMMARY",
|
||||||
|
"DTSTART",
|
||||||
|
"DTEND",
|
||||||
|
"STATUS",
|
||||||
|
"TRANSP",
|
||||||
|
"DESCRIPTION",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def lesson_to_vevent(lesson: Lesson) -> Event:
|
def lesson_to_vevent(lesson: Lesson) -> Event:
|
||||||
@@ -61,6 +69,10 @@ def lesson_to_vevent(lesson: Lesson) -> Event:
|
|||||||
parts.append(f"Professeur(s): {', '.join(lesson.teachers)}")
|
parts.append(f"Professeur(s): {', '.join(lesson.teachers)}")
|
||||||
if lesson.rooms:
|
if lesson.rooms:
|
||||||
parts.append(f"Salle(s): {', '.join(lesson.rooms)}")
|
parts.append(f"Salle(s): {', '.join(lesson.rooms)}")
|
||||||
|
if lesson.group:
|
||||||
|
parts.append(f"Groupe: {lesson.group}")
|
||||||
|
if lesson.class_part:
|
||||||
|
parts.append(f"Partie(s) de classe: {lesson.class_part}")
|
||||||
if lesson.content:
|
if lesson.content:
|
||||||
parts.append(f"Contenu: {lesson.content}")
|
parts.append(f"Contenu: {lesson.content}")
|
||||||
event.add("description", "\n".join(parts))
|
event.add("description", "\n".join(parts))
|
||||||
@@ -84,8 +96,9 @@ def lesson_to_vevent(lesson: Lesson) -> Event:
|
|||||||
def homework_to_vevent(homework: Homework) -> Event:
|
def homework_to_vevent(homework: Homework) -> Event:
|
||||||
"""Convertit un devoir Pronote en composant VEVENT iCalendar.
|
"""Convertit un devoir Pronote en composant VEVENT iCalendar.
|
||||||
|
|
||||||
Le devoir est représenté comme une tâche (``STATUS:NEEDS-ACTION``) sur la
|
Le devoir est représenté comme un événement toute la journée à la date
|
||||||
journée d'échéance, entre 08:00 et 18:00.
|
d'échéance. Il est transparent pour ne pas bloquer les disponibilités ;
|
||||||
|
aucun statut de tâche ``VTODO`` n'est ajouté à ce ``VEVENT``.
|
||||||
|
|
||||||
:param homework: Devoir Pronote à sérialiser.
|
:param homework: Devoir Pronote à sérialiser.
|
||||||
:return: Composant :class:`icalendar.Event` marqué comme géré par l'outil.
|
:return: Composant :class:`icalendar.Event` marqué comme géré par l'outil.
|
||||||
@@ -94,10 +107,10 @@ def homework_to_vevent(homework: Homework) -> Event:
|
|||||||
event = Event()
|
event = Event()
|
||||||
event.add("uid", f"homework-{homework.id}")
|
event.add("uid", f"homework-{homework.id}")
|
||||||
event.add("summary", f"Devoir: {homework.subject}")
|
event.add("summary", f"Devoir: {homework.subject}")
|
||||||
event.add("dtstart", vDatetime(datetime.combine(homework.due_on, time(8, 0))))
|
event.add("dtstart", vDate(homework.due_on))
|
||||||
event.add("dtend", vDatetime(datetime.combine(homework.due_on, time(18, 0))))
|
event.add("dtend", vDate(homework.due_on + timedelta(days=1)))
|
||||||
event.add("description", homework.text)
|
event.add("description", homework.text)
|
||||||
event.add("status", "NEEDS-ACTION")
|
event.add("transp", "TRANSPARENT")
|
||||||
event.add("categories", ["Pronote", "Devoir"])
|
event.add("categories", ["Pronote", "Devoir"])
|
||||||
event.add(MANAGED_PROPERTY, MANAGED_VALUE)
|
event.add(MANAGED_PROPERTY, MANAGED_VALUE)
|
||||||
return event
|
return event
|
||||||
|
|||||||
@@ -15,20 +15,83 @@ logger = logging.getLogger(__name__)
|
|||||||
__all__ = ["get_synthesis_provider", "SynthesisProvider", "OpenAISynthesisProvider"]
|
__all__ = ["get_synthesis_provider", "SynthesisProvider", "OpenAISynthesisProvider"]
|
||||||
|
|
||||||
|
|
||||||
|
_SENSITIVE_QUERY_PARAMS = {"token", "key", "api_key", "secret", "password", "auth"}
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_base_url(provider: str, url: str, allow_insecure_http: bool) -> str | None:
|
||||||
|
"""Valide structurellement une URL de base IA, partagée entre providers.
|
||||||
|
|
||||||
|
Applique les règles structurelles identiques aux trois providers
|
||||||
|
(``openai``, ``litellm`` et ``openai-compatible``) : URL parsable par
|
||||||
|
``urlparse`` (``ValueError`` rejeté), hostname non vide, schéma limité
|
||||||
|
à ``http``/``https`` (HTTP refusé sauf si ``allow_insecure_http`` vaut
|
||||||
|
``True``), absence d'identifiants dans le netloc et de paramètres
|
||||||
|
sensibles dans la requête (y compris les paramètres sans valeur). L'URL
|
||||||
|
est retournée strictement inchangée : aucune manipulation automatique
|
||||||
|
du suffixe ``/v1`` n'est effectuée. En cas de violation, un
|
||||||
|
avertissement est journalisé (l'URL est toujours masquée via
|
||||||
|
:func:`redact_url`) et ``None`` est retourné ; la fonction ne lève
|
||||||
|
jamais d'exception et n'effectue aucun appel réseau.
|
||||||
|
|
||||||
|
:param provider: Nom du provider (utilisé pour le message d'avertissement).
|
||||||
|
:param url: URL de base à valider (non vide).
|
||||||
|
:param allow_insecure_http: Autorise ou non les URLs en HTTP.
|
||||||
|
:return: L'URL validée, strictement inchangée, ou ``None`` si invalide.
|
||||||
|
:rtype: str | None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if not parsed.scheme:
|
||||||
|
logger.warning("URL invalide pour le provider %s : %s", provider, redact_url(url))
|
||||||
|
return None
|
||||||
|
if not parsed.hostname:
|
||||||
|
logger.warning("URL sans hostname pour le provider %s : %s", provider, redact_url(url))
|
||||||
|
return None
|
||||||
|
if parsed.scheme not in ("http", "https"):
|
||||||
|
logger.warning(
|
||||||
|
"Schéma d'URL non supporté pour le provider %s : %s", provider, redact_url(url)
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
if parsed.scheme == "http" and not allow_insecure_http:
|
||||||
|
logger.warning(
|
||||||
|
"URL HTTP non autorisée sans AI_ALLOW_INSECURE_HTTP=true pour le provider %s : %s",
|
||||||
|
provider,
|
||||||
|
redact_url(url),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
if parsed.username is not None or parsed.password is not None:
|
||||||
|
logger.warning(
|
||||||
|
"Credentials dans l'URL refusés pour le provider %s : %s",
|
||||||
|
provider,
|
||||||
|
redact_url(url),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
param_names = [name.lower() for name, _ in parse_qsl(parsed.query, keep_blank_values=True)]
|
||||||
|
if any(name in _SENSITIVE_QUERY_PARAMS for name in param_names):
|
||||||
|
logger.warning(
|
||||||
|
"Paramètres sensibles dans l'URL refusés pour le provider %s : %s",
|
||||||
|
provider,
|
||||||
|
redact_url(url),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
# Accéder à parsed.port peut lever ValueError (port invalide/hors bornes).
|
||||||
|
parsed.port # noqa: B018
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("URL invalide pour le provider %s : %s", provider, redact_url(url))
|
||||||
|
return None
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
def _validate_openai_compatible_config(
|
def _validate_openai_compatible_config(
|
||||||
url: str | None, model: str | None, allow_insecure_http: bool
|
url: str | None, model: str | None, allow_insecure_http: bool
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Valide la configuration du provider ``openai-compatible``.
|
"""Valide la configuration du provider ``openai-compatible``.
|
||||||
|
|
||||||
Vérifie la présence de l'URL de base et du modèle, le schéma de l'URL
|
Vérifie d'abord la présence de l'URL de base et du modèle (spécifique
|
||||||
(HTTPS obligatoire, HTTP accepté uniquement si ``allow_insecure_http``
|
à ``openai-compatible``), puis délègue les règles structurelles
|
||||||
vaut ``True``), la présence d'un hostname non vide, l'absence
|
partagées à :func:`_validate_base_url`. En cas d'échec, un
|
||||||
d'identifiants dans le netloc et de paramètres sensibles dans la
|
avertissement est journalisé et ``None`` est retourné : la synthèse IA
|
||||||
requête (y compris les paramètres sans valeur). Une URL malformée
|
se dégrade silencieusement, sans jamais lever d'exception.
|
||||||
(``ValueError`` levé par ``urlparse``) est également rejetée. En cas
|
|
||||||
d'échec, un avertissement est journalisé (l'URL est toujours masquée
|
|
||||||
via :func:`redact_url`) et ``None`` est retourné : la synthèse IA se
|
|
||||||
dégrade silencieusement, sans jamais lever d'exception.
|
|
||||||
|
|
||||||
:param url: URL de base de l'API compatible OpenAI.
|
:param url: URL de base de l'API compatible OpenAI.
|
||||||
:param model: Identifiant du modèle à utiliser.
|
:param model: Identifiant du modèle à utiliser.
|
||||||
@@ -43,42 +106,7 @@ def _validate_openai_compatible_config(
|
|||||||
if not model:
|
if not model:
|
||||||
logger.warning("Modèle requis pour le provider openai-compatible")
|
logger.warning("Modèle requis pour le provider openai-compatible")
|
||||||
return None
|
return None
|
||||||
|
return _validate_base_url("openai-compatible", url, allow_insecure_http)
|
||||||
try:
|
|
||||||
parsed = urlparse(url)
|
|
||||||
except ValueError:
|
|
||||||
logger.warning(
|
|
||||||
"URL invalide pour le provider openai-compatible : %s",
|
|
||||||
redact_url(url),
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
if not parsed.hostname:
|
|
||||||
logger.warning(
|
|
||||||
"URL sans hostname pour le provider openai-compatible : %s",
|
|
||||||
redact_url(url),
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
if parsed.scheme not in ("http", "https"):
|
|
||||||
logger.warning(
|
|
||||||
"Schéma d'URL non supporté pour le provider openai-compatible : %s",
|
|
||||||
redact_url(url),
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
if parsed.scheme == "http" and not allow_insecure_http:
|
|
||||||
logger.warning(
|
|
||||||
"URL HTTP non autorisée sans AI_ALLOW_INSECURE_HTTP=true : %s",
|
|
||||||
redact_url(url),
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
if parsed.username is not None or parsed.password is not None:
|
|
||||||
logger.warning("Credentials dans l'URL refusés : %s", redact_url(url))
|
|
||||||
return None
|
|
||||||
sensitive_names = {"token", "key", "api_key", "secret", "password", "auth"}
|
|
||||||
param_names = [name.lower() for name, _ in parse_qsl(parsed.query, keep_blank_values=True)]
|
|
||||||
if any(name in sensitive_names for name in param_names):
|
|
||||||
logger.warning("Paramètres sensibles dans l'URL refusés : %s", redact_url(url))
|
|
||||||
return None
|
|
||||||
return url
|
|
||||||
|
|
||||||
|
|
||||||
def get_synthesis_provider(settings: AISettings) -> SynthesisProvider | None:
|
def get_synthesis_provider(settings: AISettings) -> SynthesisProvider | None:
|
||||||
@@ -87,14 +115,15 @@ def get_synthesis_provider(settings: AISettings) -> SynthesisProvider | None:
|
|||||||
Retourne ``None`` lorsque la synthèse IA est désactivée ou qu'aucune clé
|
Retourne ``None`` lorsque la synthèse IA est désactivée ou qu'aucune clé
|
||||||
API n'est configurée. Pour le provider ``litellm``, le paquet ``litellm``
|
API n'est configurée. Pour le provider ``litellm``, le paquet ``litellm``
|
||||||
(extra ``ai-litellm``) est requis : s'il est absent, un avertissement est
|
(extra ``ai-litellm``) est requis : s'il est absent, un avertissement est
|
||||||
journalisé et ``None`` est retourné. Pour le provider
|
journalisé et ``None`` est retourné. Pour ``openai`` et ``litellm``,
|
||||||
``openai-compatible``, la configuration (URL de base et modèle) est
|
une ``base_url`` éventuelle est validée par :func:`_validate_base_url` ;
|
||||||
validée par :func:`_validate_openai_compatible_config` ; en cas de
|
pour le provider ``openai-compatible``, la configuration (URL de base
|
||||||
rejet, ``None`` est retourné avec un avertissement.
|
et modèle) est validée par :func:`_validate_openai_compatible_config` ;
|
||||||
|
en cas de rejet, ``None`` est retourné avec un avertissement.
|
||||||
|
|
||||||
:param settings: Paramètres IA.
|
:param settings: Paramètres IA.
|
||||||
:return: Le fournisseur configuré, ou ``None`` si désactivé, sans clé API
|
:return: Le fournisseur configuré, ou ``None`` si désactivé, sans clé API
|
||||||
ou avec une configuration ``openai-compatible`` invalide.
|
ou avec une configuration invalide.
|
||||||
:rtype: SynthesisProvider | None
|
:rtype: SynthesisProvider | None
|
||||||
"""
|
"""
|
||||||
if not settings.enabled:
|
if not settings.enabled:
|
||||||
@@ -111,6 +140,10 @@ def get_synthesis_provider(settings: AISettings) -> SynthesisProvider | None:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
logger.warning("Extra 'ai-litellm' requis pour le provider litellm")
|
logger.warning("Extra 'ai-litellm' requis pour le provider litellm")
|
||||||
return None
|
return None
|
||||||
|
if base_url is not None and (
|
||||||
|
_validate_base_url("litellm", base_url, settings.allow_insecure_http) is None
|
||||||
|
):
|
||||||
|
return None
|
||||||
return LiteLLMSynthesisProvider(api_key=settings.api_key, base_url=base_url, model=model)
|
return LiteLLMSynthesisProvider(api_key=settings.api_key, base_url=base_url, model=model)
|
||||||
|
|
||||||
if settings.provider == "openai-compatible":
|
if settings.provider == "openai-compatible":
|
||||||
@@ -121,4 +154,8 @@ def get_synthesis_provider(settings: AISettings) -> SynthesisProvider | None:
|
|||||||
return None
|
return None
|
||||||
return OpenAISynthesisProvider(api_key=settings.api_key, base_url=url, model=model)
|
return OpenAISynthesisProvider(api_key=settings.api_key, base_url=url, model=model)
|
||||||
|
|
||||||
|
if base_url is not None and (
|
||||||
|
_validate_base_url("openai", base_url, settings.allow_insecure_http) is None
|
||||||
|
):
|
||||||
|
return None
|
||||||
return OpenAISynthesisProvider(api_key=settings.api_key, base_url=base_url, model=model)
|
return OpenAISynthesisProvider(api_key=settings.api_key, base_url=base_url, model=model)
|
||||||
|
|||||||
+6
-5
@@ -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 = ["."]
|
||||||
|
|||||||
@@ -26,18 +26,34 @@ _TEXT_SUFFIXES = frozenset(
|
|||||||
{".conf", ".ini", ".json", ".md", ".py", ".service", ".timer", ".toml", ".txt", ".yaml", ".yml"}
|
{".conf", ".ini", ".json", ".md", ".py", ".service", ".timer", ".toml", ".txt", ".yaml", ".yml"}
|
||||||
)
|
)
|
||||||
_LITERAL_SECRET_RE = re.compile(
|
_LITERAL_SECRET_RE = re.compile(
|
||||||
r"(?ix)\b[a-z0-9_]*(?:api[_-]?key|access[_-]?token|auth(?:orization)?|icalsecurise|password|secret|token)"
|
r"(?ix)\b[a-z0-9_]*(?:api[_-]?key|access[_-]?token|auth(?:orization)?|icalsecurise|password|pin|secret|token)"
|
||||||
r"\s*[:=]\s*['\"][^'\"\r\n]{3,}['\"]"
|
r"\s*[:=]\s*['\"](?P<value>[^'\"\r\n]{3,})['\"]"
|
||||||
)
|
)
|
||||||
_UNQUOTED_SECRET_RE = re.compile(
|
_UNQUOTED_SECRET_RE = re.compile(
|
||||||
r"(?ix)\b[a-z0-9_]*(?:api[_-]?key|access[_-]?token|auth(?:orization)?|icalsecurise|password|secret|token)"
|
r"(?ix)\b[a-z0-9_]*(?:api[_-]?key|access[_-]?token|auth(?:orization)?|icalsecurise|password|pin|secret|token)"
|
||||||
r"\s*[:=]\s*[a-z0-9][a-z0-9._~+/-]{2,}"
|
r"\s*[:=]\s*(?P<value>[a-z0-9][a-z0-9._~+/-]{2,})"
|
||||||
)
|
)
|
||||||
_URL_SECRET_RE = re.compile(
|
_URL_SECRET_RE = re.compile(
|
||||||
r"(?ix)[?&](?:api[_-]?key|access[_-]?token|auth(?:orization)?|icalsecurise|password|secret|token)"
|
r"(?ix)[?&](?:api[_-]?key|access[_-]?token|auth(?:orization)?|icalsecurise|password|secret|token)"
|
||||||
r"=([^&#\s]{3,})"
|
r"=([^&#\s]{3,})"
|
||||||
)
|
)
|
||||||
|
_URL_PLACEHOLDER_RE = re.compile(
|
||||||
|
r"(?ix)^(?:"
|
||||||
|
r"\{(?:token|jeton|secret|valeur|value|param(?:etre)?|version|prenom|"
|
||||||
|
r"etablissement|hote|host)\}|"
|
||||||
|
r"<(?:token|jeton|secret|valeur|value|param(?:etre)?|version|prenom|"
|
||||||
|
r"etablissement|hote|host)>|"
|
||||||
|
r"\.{3,}|[•…]+"
|
||||||
|
r")$"
|
||||||
|
)
|
||||||
_EXTRA_NAMES = frozenset({"pronote_sync"})
|
_EXTRA_NAMES = frozenset({"pronote_sync"})
|
||||||
|
_ASSIGNMENT_PLACEHOLDER_RE = re.compile(
|
||||||
|
r"(?ix)^(?:"
|
||||||
|
r"<(?:pin|secret|valeur|value|token|jeton)>|"
|
||||||
|
r"(?:change|replace|your)[_-]?(?:me|here|value|valeur|pin|password|secret)|"
|
||||||
|
r"(?:placeholder|example|local-not-required)"
|
||||||
|
r")$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -59,6 +75,16 @@ CommandRunner = Callable[..., subprocess.CompletedProcess[str]]
|
|||||||
ContentProvider = Callable[[Path], str | None]
|
ContentProvider = Callable[[Path], str | None]
|
||||||
|
|
||||||
|
|
||||||
|
def _is_assignment_placeholder(value: str) -> bool:
|
||||||
|
"""Indique si une valeur d'affectation est un placeholder documentaire.
|
||||||
|
|
||||||
|
:param value: Valeur extraite d'une affectation sensible.
|
||||||
|
:return: ``True`` si la valeur ne représente pas un secret réel.
|
||||||
|
:rtype: bool
|
||||||
|
"""
|
||||||
|
return _ASSIGNMENT_PLACEHOLDER_RE.fullmatch(value.strip()) is not None
|
||||||
|
|
||||||
|
|
||||||
def _is_candidate(path: Path) -> bool:
|
def _is_candidate(path: Path) -> bool:
|
||||||
"""Indique si un chemin peut être analysé comme fichier texte.
|
"""Indique si un chemin peut être analysé comme fichier texte.
|
||||||
|
|
||||||
@@ -179,13 +205,23 @@ def find_secrets(
|
|||||||
for number, line in enumerate(content.splitlines(), start=1):
|
for number, line in enumerate(content.splitlines(), start=1):
|
||||||
if _ALLOWLIST_MARKER in line:
|
if _ALLOWLIST_MARKER in line:
|
||||||
continue
|
continue
|
||||||
is_literal_secret = _LITERAL_SECRET_RE.search(line) or (
|
literal_match = _LITERAL_SECRET_RE.search(line)
|
||||||
relative_path.suffix in _UNQUOTED_CONFIG_SUFFIXES
|
unquoted_match = (
|
||||||
and _UNQUOTED_SECRET_RE.search(line)
|
_UNQUOTED_SECRET_RE.search(line)
|
||||||
|
if relative_path.suffix in _UNQUOTED_CONFIG_SUFFIXES
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
is_literal_secret = any(
|
||||||
|
match is not None and not _is_assignment_placeholder(match.group("value"))
|
||||||
|
for match in (literal_match, unquoted_match)
|
||||||
)
|
)
|
||||||
if is_literal_secret:
|
if is_literal_secret:
|
||||||
findings.append(SecretFinding(relative_path, number, "affectation-litterale"))
|
findings.append(SecretFinding(relative_path, number, "affectation-litterale"))
|
||||||
if _URL_SECRET_RE.search(line):
|
has_real_url_secret = any(
|
||||||
|
not _URL_PLACEHOLDER_RE.fullmatch(match.group(1))
|
||||||
|
for match in _URL_SECRET_RE.finditer(line)
|
||||||
|
)
|
||||||
|
if has_real_url_secret:
|
||||||
findings.append(SecretFinding(relative_path, number, "parametre-url"))
|
findings.append(SecretFinding(relative_path, number, "parametre-url"))
|
||||||
return sorted(findings, key=lambda finding: (str(finding.path), finding.line, finding.rule))
|
return sorted(findings, key=lambda finding: (str(finding.path), finding.line, finding.rule))
|
||||||
|
|
||||||
|
|||||||
+23
-3
@@ -52,10 +52,10 @@ def test_main_runs_composition_root_in_dry_run_with_requested_log_level(
|
|||||||
runner.run.assert_called_once_with()
|
runner.run.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
def test_main_preserves_configured_dry_run_and_returns_success_with_warnings(
|
def test_main_preserves_configured_dry_run_and_returns_degraded_with_warnings(
|
||||||
mocker: MockerFixture,
|
mocker: MockerFixture,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Sans option, la CLI préserve le dry-run configuré et accepte les avertissements."""
|
"""Sans option, la CLI préserve le dry-run configuré et signale l'état dégradé."""
|
||||||
from pronote_sync.cli.main import main
|
from pronote_sync.cli.main import main
|
||||||
|
|
||||||
settings = Settings(app=AppSettings(dry_run=True, log_level="WARNING"))
|
settings = Settings(app=AppSettings(dry_run=True, log_level="WARNING"))
|
||||||
@@ -72,12 +72,32 @@ def test_main_preserves_configured_dry_run_and_returns_success_with_warnings(
|
|||||||
|
|
||||||
exit_code = main([])
|
exit_code = main([])
|
||||||
|
|
||||||
assert exit_code == 0
|
assert exit_code == 2
|
||||||
assert setup_logging.call_args_list == [mocker.call("INFO"), mocker.call("WARNING")]
|
assert setup_logging.call_args_list == [mocker.call("INFO"), mocker.call("WARNING")]
|
||||||
composition_root.assert_called_once_with(settings, dry_run=None)
|
composition_root.assert_called_once_with(settings, dry_run=None)
|
||||||
runner.run.assert_called_once_with()
|
runner.run.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("step", ["caldav_sync", "send"])
|
||||||
|
def test_main_returns_degraded_code_for_caldav_or_xmpp_failure(
|
||||||
|
mocker: MockerFixture,
|
||||||
|
step: str,
|
||||||
|
) -> None:
|
||||||
|
"""Les échecs récupérables CalDAV et XMPP sont observables par le code 2."""
|
||||||
|
from pronote_sync.cli.main import main
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
mocker.patch("pronote_sync.cli.main.load_settings", return_value=settings)
|
||||||
|
runner = mocker.Mock()
|
||||||
|
runner.run.return_value = (
|
||||||
|
mocker.Mock(spec=PronoteData),
|
||||||
|
[PipelineWarning(f"Échec récupérable de {step}", step=step)],
|
||||||
|
)
|
||||||
|
mocker.patch("pronote_sync.cli.main.PipelineRunner.from_settings", return_value=runner)
|
||||||
|
|
||||||
|
assert main([]) == 2
|
||||||
|
|
||||||
|
|
||||||
def test_main_returns_failure_and_redacts_pipeline_secrets_at_debug_level(
|
def test_main_returns_failure_and_redacts_pipeline_secrets_at_debug_level(
|
||||||
mocker: MockerFixture,
|
mocker: MockerFixture,
|
||||||
capsys: pytest.CaptureFixture[str],
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
|||||||
Vendored
+53
@@ -0,0 +1,53 @@
|
|||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//pronote-sync tests//FR
|
||||||
|
X-WR-CALNAME:Fixture anonymisée
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:variant-normal
|
||||||
|
DTSTART:20260910T080000Z
|
||||||
|
DTEND:20260910T090000Z
|
||||||
|
SUMMARY:Mathématiques
|
||||||
|
CATEGORIES:Cours
|
||||||
|
DESCRIPTION:<div>Matière : Mathématiques Professeur(s) : Enseignant A Salle(s) : 101 Groupe : Classe entière Partie(s) de classe : Groupe 1 <strong data-section="content">Contenu pédagogique :</strong><p>Leçon & exemple</p><strong>Pour le 15/09/2026 :</strong><p>Lire le chapitre</p><strong>Donné le 10/09/2026 :</strong><p>Lire le chapitre</p></div>
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:variant-duplicate
|
||||||
|
DTSTART:20260910T100000Z
|
||||||
|
DTEND:20260910T110000Z
|
||||||
|
SUMMARY:Mathématiques
|
||||||
|
CATEGORIES:Cours
|
||||||
|
DESCRIPTION:<div>Matière : Mathématiques Professeur(s) : Enseignant A Salle(s) : 102 <strong>Pour le 15/09/2026 :</strong><p>Lire le chapitre</p></div>
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:variant-same-text-other-context
|
||||||
|
DTSTART:20260910T120000Z
|
||||||
|
DTEND:20260910T130000Z
|
||||||
|
SUMMARY:Sciences
|
||||||
|
CATEGORIES:Cours
|
||||||
|
DESCRIPTION:<div>Matière : Sciences Professeur(s) : Enseignant B Salle(s) : 103 <strong>Pour le 15/09/2026 :</strong><p>Lire le chapitre</p></div>
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:variant-moved
|
||||||
|
DTSTART:20260911T080000Z
|
||||||
|
DTEND:20260911T090000Z
|
||||||
|
SUMMARY:Mathématiques
|
||||||
|
CATEGORIES:Cours - Cours modifié
|
||||||
|
DESCRIPTION:<div>Matière : Mathématiques Professeur : Enseignant A Salle : 204</div>
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:variant-public-holiday
|
||||||
|
DTSTART;VALUE=DATE:20260914
|
||||||
|
DTEND;VALUE=DATE:20260915
|
||||||
|
SUMMARY:Journée fériée anonymisée
|
||||||
|
CATEGORIES:Jours fériés
|
||||||
|
DESCRIPTION:Journée sans cours.
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:variant-holiday
|
||||||
|
DTSTART;VALUE=DATE:20261020
|
||||||
|
DTEND;VALUE=DATE:20261022
|
||||||
|
SUMMARY:Vacances anonymisées
|
||||||
|
CATEGORIES:Congés
|
||||||
|
DESCRIPTION:Période de vacances.
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"version": "fixture-1",
|
||||||
|
"source": "ical",
|
||||||
|
"pronote_version": "2026-unknown",
|
||||||
|
"anonymized": true,
|
||||||
|
"scenarios": {
|
||||||
|
"normal_and_headers": "variant-normal",
|
||||||
|
"duplicate_homework_same_context": ["variant-normal", "variant-duplicate"],
|
||||||
|
"same_text_distinct_context": "variant-same-text-other-context",
|
||||||
|
"moved_lesson": "variant-moved",
|
||||||
|
"public_holiday": "variant-public-holiday",
|
||||||
|
"holiday_exclusive_end": "variant-holiday"
|
||||||
|
},
|
||||||
|
"invariants": [
|
||||||
|
"Aucune valeur d'authentification ou identité réelle n'est présente.",
|
||||||
|
"Les bornes de date d'un événement scolaire sont début inclusif et fin exclusive.",
|
||||||
|
"Les devoirs sont vérifiés après parsing dans les modèles métier finaux."
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ ajouts/mises à jour/suppressions, et la préservation des événements non gér
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import date, datetime
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -122,10 +122,16 @@ class FakeCalendar:
|
|||||||
event_start = raw_start.dt
|
event_start = raw_start.dt
|
||||||
event_end = raw_end.dt if raw_end is not None else event_start
|
event_end = raw_end.dt if raw_end is not None else event_start
|
||||||
overlaps = True
|
overlaps = True
|
||||||
|
if isinstance(event_start, datetime):
|
||||||
if start is not None:
|
if start is not None:
|
||||||
overlaps = overlaps and event_end > start
|
overlaps = overlaps and event_end > start
|
||||||
if end is not None:
|
if end is not None:
|
||||||
overlaps = overlaps and event_start < end
|
overlaps = overlaps and event_start < end
|
||||||
|
else:
|
||||||
|
if start is not None:
|
||||||
|
overlaps = overlaps and event_end > start.date()
|
||||||
|
if end is not None:
|
||||||
|
overlaps = overlaps and event_start < end.date()
|
||||||
if overlaps:
|
if overlaps:
|
||||||
results.append(FakeCalendarEvent(ical_text, uid=uid, server=self._server))
|
results.append(FakeCalendarEvent(ical_text, uid=uid, server=self._server))
|
||||||
break
|
break
|
||||||
@@ -686,6 +692,80 @@ class TestCalDAVSynchronize:
|
|||||||
assert result.added == 1
|
assert result.added == 1
|
||||||
assert len(fake_caldav_server.get_events()) == 1
|
assert len(fake_caldav_server.get_events()) == 1
|
||||||
|
|
||||||
|
def test_legacy_homework_vevent_is_migrated_idempotently_and_removed(
|
||||||
|
self,
|
||||||
|
fake_caldav_server: FakeCalDAVServer,
|
||||||
|
full_settings: Settings,
|
||||||
|
) -> None:
|
||||||
|
"""Migre un ancien devoir puis vérifie l'idempotence et la suppression."""
|
||||||
|
fake_caldav_server._events["homework-hw-legacy"] = _create_vevent_text(
|
||||||
|
uid="homework-hw-legacy",
|
||||||
|
summary="Devoir: Histoire",
|
||||||
|
start=datetime(2026, 1, 20, 8, 0),
|
||||||
|
end=datetime(2026, 1, 20, 18, 0),
|
||||||
|
status="NEEDS-ACTION",
|
||||||
|
managed=True,
|
||||||
|
)
|
||||||
|
homework = Homework(
|
||||||
|
id="hw-legacy",
|
||||||
|
subject="Histoire",
|
||||||
|
assigned_on=None,
|
||||||
|
due_on=date(2026, 1, 20),
|
||||||
|
text="Lire le chapitre 5",
|
||||||
|
)
|
||||||
|
|
||||||
|
def make_data(homeworks: list[Homework]) -> PronoteData:
|
||||||
|
"""Construit les données de synchronisation du scénario."""
|
||||||
|
return PronoteData(
|
||||||
|
lessons=[],
|
||||||
|
homeworks=homeworks,
|
||||||
|
school_events=[],
|
||||||
|
messages=[],
|
||||||
|
target_date=date(2026, 1, 20),
|
||||||
|
generated_at=datetime(2026, 1, 14, 0, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
first = synchronize(
|
||||||
|
pronote_data=make_data([homework]),
|
||||||
|
settings=full_settings,
|
||||||
|
client_factory=fake_caldav_server.client_factory,
|
||||||
|
now=datetime(2026, 1, 14, 12, 0),
|
||||||
|
)
|
||||||
|
assert first.status == CalDAVSyncStatus.SUCCESS
|
||||||
|
assert first.added == 0
|
||||||
|
assert first.updated == 1
|
||||||
|
assert first.removed == 0
|
||||||
|
|
||||||
|
migrated = Calendar.from_ical(fake_caldav_server.get_events()["homework-hw-legacy"])
|
||||||
|
event = migrated.walk("VEVENT")[0]
|
||||||
|
assert event.get("DTSTART").dt == date(2026, 1, 20)
|
||||||
|
assert event.get("DTEND").dt == date(2026, 1, 21)
|
||||||
|
assert event.get("STATUS") is None
|
||||||
|
assert str(event.get("TRANSP")) == "TRANSPARENT"
|
||||||
|
|
||||||
|
second = synchronize(
|
||||||
|
pronote_data=make_data([homework]),
|
||||||
|
settings=full_settings,
|
||||||
|
client_factory=fake_caldav_server.client_factory,
|
||||||
|
now=datetime(2026, 1, 14, 12, 0),
|
||||||
|
)
|
||||||
|
assert second.status == CalDAVSyncStatus.SKIPPED
|
||||||
|
assert second.added == 0
|
||||||
|
assert second.updated == 0
|
||||||
|
assert second.removed == 0
|
||||||
|
|
||||||
|
removed = synchronize(
|
||||||
|
pronote_data=make_data([]),
|
||||||
|
settings=full_settings,
|
||||||
|
client_factory=fake_caldav_server.client_factory,
|
||||||
|
now=datetime(2026, 1, 14, 12, 0),
|
||||||
|
)
|
||||||
|
assert removed.status == CalDAVSyncStatus.SUCCESS
|
||||||
|
assert removed.added == 0
|
||||||
|
assert removed.updated == 0
|
||||||
|
assert removed.removed == 1
|
||||||
|
assert fake_caldav_server.get_events() == {}
|
||||||
|
|
||||||
def test_school_event_sync(
|
def test_school_event_sync(
|
||||||
self,
|
self,
|
||||||
fake_caldav_server: FakeCalDAVServer,
|
fake_caldav_server: FakeCalDAVServer,
|
||||||
|
|||||||
@@ -1040,6 +1040,110 @@ def test_runner_blog_success_delivers_articles_into_xmpp_message_external_info(
|
|||||||
assert xmpp_message.external_info.blog_articles[0].title == "Test Article"
|
assert xmpp_message.external_info.blog_articles[0].title == "Test Article"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("channel_kind", "dry_run", "should_acknowledge"),
|
||||||
|
[
|
||||||
|
("success", False, True),
|
||||||
|
("false", False, False),
|
||||||
|
("exception", False, False),
|
||||||
|
("none", False, False),
|
||||||
|
("success", True, False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_runner_acknowledges_blog_only_after_confirmed_xmpp_delivery(
|
||||||
|
pipeline_inputs: tuple[Lesson, Homework],
|
||||||
|
tmp_path: Any,
|
||||||
|
channel_kind: str,
|
||||||
|
dry_run: bool,
|
||||||
|
should_acknowledge: bool,
|
||||||
|
) -> None:
|
||||||
|
"""Les GUID RSS restent rejouables tant que XMPP n'a pas confirmé l'envoi.
|
||||||
|
|
||||||
|
:param pipeline_inputs: Données Pronote de test.
|
||||||
|
:param tmp_path: Répertoire temporaire pour l'état RSS.
|
||||||
|
:param channel_kind: Comportement du canal XMPP simulé.
|
||||||
|
:param dry_run: Active ou non le mode simulation.
|
||||||
|
:param should_acknowledge: Indique si l'état RSS doit être acquitté.
|
||||||
|
"""
|
||||||
|
lesson, homework = pipeline_inputs
|
||||||
|
calls: list[str] = []
|
||||||
|
state_file = tmp_path / "blog-state.json"
|
||||||
|
|
||||||
|
class SuccessfulBlogClient:
|
||||||
|
"""Client RSS renvoyant un article non encore livré."""
|
||||||
|
|
||||||
|
def fetch_and_parse(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
known_guids: frozenset[str] | None = None,
|
||||||
|
etag: str | None = None,
|
||||||
|
last_modified: str | None = None,
|
||||||
|
) -> BlogRSSFetchResult:
|
||||||
|
"""Retourne un article et des en-têtes de cache déterministes.
|
||||||
|
|
||||||
|
:param known_guids: GUID déjà connus, ignorés dans ce faux client.
|
||||||
|
:param etag: ETag mémorisé, ignoré dans ce faux client.
|
||||||
|
:param last_modified: Date HTTP mémorisée, ignorée dans ce faux client.
|
||||||
|
:return: Résultat RSS avec un article à livrer.
|
||||||
|
:rtype: BlogRSSFetchResult
|
||||||
|
"""
|
||||||
|
del known_guids, etag, last_modified
|
||||||
|
return BlogRSSFetchResult(
|
||||||
|
articles=(
|
||||||
|
BlogArticle(
|
||||||
|
id="article-to-deliver",
|
||||||
|
title="Article à livrer",
|
||||||
|
url="https://example.com/article-to-deliver",
|
||||||
|
published_at=datetime(2026, 9, 8, 12, 0),
|
||||||
|
updated_at=None,
|
||||||
|
category=None,
|
||||||
|
author=None,
|
||||||
|
content_html="<p>Contenu</p>",
|
||||||
|
content_text="Contenu",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
etag="etag-after-delivery",
|
||||||
|
last_modified="Tue, 08 Sep 2026 12:00:00 GMT",
|
||||||
|
)
|
||||||
|
|
||||||
|
channel: Any
|
||||||
|
if channel_kind == "success":
|
||||||
|
channel = StubChannel(calls)
|
||||||
|
elif channel_kind == "false":
|
||||||
|
channel = FailingChannel()
|
||||||
|
elif channel_kind == "exception":
|
||||||
|
channel = ExceptionalChannel()
|
||||||
|
else:
|
||||||
|
channel = None
|
||||||
|
|
||||||
|
runner = PipelineRunner(
|
||||||
|
settings=Settings(blog=Settings().blog.model_copy(update={"enabled": True})),
|
||||||
|
pronote_fetcher=StubFetcher(calls, lesson, homework),
|
||||||
|
caldav_synchronizer=lambda data, settings: successful_sync_result(),
|
||||||
|
agenda_comparator=cast("AgendaComparator | None", StubComparator(calls)),
|
||||||
|
blog_client=cast("BlogRSSClient | None", SuccessfulBlogClient()),
|
||||||
|
blog_state=BlogRSSState(state_file),
|
||||||
|
channel=channel,
|
||||||
|
dry_run=dry_run,
|
||||||
|
now_provider=lambda: datetime(2026, 9, 8, 7, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
data, errors = runner.run()
|
||||||
|
|
||||||
|
assert data is not None
|
||||||
|
if should_acknowledge:
|
||||||
|
acknowledged_state = BlogRSSState(state_file)
|
||||||
|
assert acknowledged_state.get_known_guids() == frozenset({"article-to-deliver"})
|
||||||
|
assert acknowledged_state.get_cache_headers() == (
|
||||||
|
"etag-after-delivery",
|
||||||
|
"Tue, 08 Sep 2026 12:00:00 GMT",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
assert not state_file.exists()
|
||||||
|
if channel_kind in {"false", "exception"}:
|
||||||
|
assert any(error.step == "send" for error in errors)
|
||||||
|
|
||||||
|
|
||||||
def test_runner_secret_redaction_in_pipeline_errors(
|
def test_runner_secret_redaction_in_pipeline_errors(
|
||||||
pipeline_inputs: tuple[Lesson, Homework],
|
pipeline_inputs: tuple[Lesson, Homework],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class FakeClientXMPP:
|
|||||||
:param handler: Fonction gestionnaire.
|
:param handler: Fonction gestionnaire.
|
||||||
:raises: AssertionError si l'événement n'est pas supporté.
|
:raises: AssertionError si l'événement n'est pas supporté.
|
||||||
"""
|
"""
|
||||||
if name not in ("session_start", "failed_auth", "disconnected"):
|
if name not in ("session_start", "failed_auth", "connection_failed", "disconnected"):
|
||||||
raise AssertionError(f"Unsupported event: {name}")
|
raise AssertionError(f"Unsupported event: {name}")
|
||||||
self.handlers.setdefault(name, []).append(handler)
|
self.handlers.setdefault(name, []).append(handler)
|
||||||
|
|
||||||
|
|||||||
@@ -14,11 +14,14 @@ Tous les tests utilisent des fichiers temporaires via la fixture ``tmp_path``.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from pronote_sync.models.blog import BlogArticle
|
||||||
|
from pronote_sync.sources.blog.result import BlogRSSFetchResult
|
||||||
from pronote_sync.sources.blog.state import BlogRSSState
|
from pronote_sync.sources.blog.state import BlogRSSState
|
||||||
|
|
||||||
|
|
||||||
@@ -116,6 +119,38 @@ def test_add_guids_empty_noop(tmp_path: Path) -> None:
|
|||||||
assert state_file.read_text(encoding="utf-8") == original_content
|
assert state_file.read_text(encoding="utf-8") == original_content
|
||||||
|
|
||||||
|
|
||||||
|
def test_acknowledge_persists_guids_and_cache_headers_together(tmp_path: Path) -> None:
|
||||||
|
"""Vérifie l'acquittement atomique après une livraison confirmée.
|
||||||
|
|
||||||
|
:param tmp_path: Fixture pytest pour un répertoire temporaire.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
state_file = tmp_path / "state.json"
|
||||||
|
state = BlogRSSState(state_file)
|
||||||
|
article = BlogArticle(
|
||||||
|
id="guid-1",
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
|
||||||
|
state.acknowledge(
|
||||||
|
BlogRSSFetchResult(
|
||||||
|
articles=(article,),
|
||||||
|
etag="etag-1",
|
||||||
|
last_modified="Sat, 12 Sep 2026 08:00:00 GMT",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert state.get_known_guids() == frozenset({"guid-1"})
|
||||||
|
assert state.get_cache_headers() == ("etag-1", "Sat, 12 Sep 2026 08:00:00 GMT")
|
||||||
|
|
||||||
|
|
||||||
def test_state_load_persisted_guids(tmp_path: Path) -> None:
|
def test_state_load_persisted_guids(tmp_path: Path) -> None:
|
||||||
"""Vérifie que les GUID persistés sont rechargés dans une nouvelle instance.
|
"""Vérifie que les GUID persistés sont rechargés dans une nouvelle instance.
|
||||||
|
|
||||||
@@ -390,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
|
||||||
|
|||||||
@@ -327,6 +327,37 @@ def test_list_managed_events_returns_only_managed(
|
|||||||
assert str(vevent.get("UID")) == "test-uid-123"
|
assert str(vevent.get("UID")) == "test-uid-123"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_managed_events_ignores_managed_event_without_uid(
|
||||||
|
caldav_settings: CalDAVSettings,
|
||||||
|
mock_client_factory: MagicMock,
|
||||||
|
caplog: LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Ignore un événement géré sans UID et ne le transmet pas au planificateur.
|
||||||
|
|
||||||
|
:param caldav_settings: Paramètres CalDAV valides.
|
||||||
|
:param mock_client_factory: Usine de clients CalDAV mockée.
|
||||||
|
:param caplog: Capture des journaux de diagnostic.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
gateway = CalDAVGateway(caldav_settings, client_factory=mock_client_factory)
|
||||||
|
gateway.connect()
|
||||||
|
|
||||||
|
malformed_event = Event()
|
||||||
|
malformed_event.add("SUMMARY", "Événement sans identifiant")
|
||||||
|
malformed_event.add(MANAGED_PROPERTY, MANAGED_VALUE)
|
||||||
|
remote_event = MagicMock()
|
||||||
|
remote_event.icalendar_component = Calendar()
|
||||||
|
remote_event.icalendar_component.add_component(malformed_event)
|
||||||
|
calendar = mock_client_factory.return_value.principal.return_value.calendars.return_value[0]
|
||||||
|
calendar.search.return_value = [remote_event]
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
result = gateway.list_managed_events(datetime(2026, 1, 1), datetime(2026, 12, 31))
|
||||||
|
|
||||||
|
assert result == []
|
||||||
|
assert "sans UID ignoré" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
def test_list_managed_events_not_connected_raises(
|
def test_list_managed_events_not_connected_raises(
|
||||||
caldav_settings: CalDAVSettings,
|
caldav_settings: CalDAVSettings,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -118,6 +118,75 @@ def test_main_detects_sensitive_url_parameter(
|
|||||||
assert sentinel not in output
|
assert sentinel not in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_ignores_documentation_url_placeholders(
|
||||||
|
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""Ignore les marqueurs de remplacement utilisés dans une documentation.
|
||||||
|
|
||||||
|
:param secret_checker: Module du script sous test.
|
||||||
|
:param tmp_path: Répertoire temporaire représentant un dépôt.
|
||||||
|
:param capsys: Fixture de capture de sortie.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
(tmp_path / "guide.md").write_text(
|
||||||
|
"\n".join(
|
||||||
|
(
|
||||||
|
"https://example.invalid/?icalsecurise={jeton}",
|
||||||
|
"https://example.invalid/?icalsecurise=••••••••",
|
||||||
|
"https://example.invalid/?icalsecurise=<token>",
|
||||||
|
"https://example.invalid/?icalsecurise=...",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
+ "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert secret_checker.main([], root=tmp_path) == 0
|
||||||
|
assert "OK:" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_detects_a_real_url_secret_after_a_placeholder(
|
||||||
|
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""Détecte un secret réel placé après un placeholder sur la même URL.
|
||||||
|
|
||||||
|
:param secret_checker: Module du script sous test.
|
||||||
|
:param tmp_path: Répertoire temporaire représentant un dépôt.
|
||||||
|
:param capsys: Fixture de capture de sortie.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
sentinel = "m14-url-after-placeholder-sentinel"
|
||||||
|
(tmp_path / "guide.md").write_text(
|
||||||
|
f"https://example.invalid/?token={{jeton}}&api_key={sentinel}\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert secret_checker.main([], root=tmp_path) == 1
|
||||||
|
output = capsys.readouterr().out
|
||||||
|
assert "guide.md:1 (parametre-url)" in output
|
||||||
|
assert sentinel not in output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", ["<ghp_…>", "***"])
|
||||||
|
def test_main_rejects_ambiguous_url_placeholders(
|
||||||
|
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str], value: str
|
||||||
|
) -> None:
|
||||||
|
"""Refuse les valeurs qui ne sont pas des placeholders documentaires fermés.
|
||||||
|
|
||||||
|
:param secret_checker: Module du script sous test.
|
||||||
|
:param tmp_path: Répertoire temporaire représentant un dépôt.
|
||||||
|
:param capsys: Fixture de capture de sortie.
|
||||||
|
:param value: Valeur ambiguë à ne pas exempter.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
(tmp_path / "guide.md").write_text(
|
||||||
|
f"https://example.invalid/?token={value}\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert secret_checker.main([], root=tmp_path) == 1
|
||||||
|
assert "guide.md:1 (parametre-url)" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
def test_staged_mode_inspects_only_paths_provided_by_git(
|
def test_staged_mode_inspects_only_paths_provided_by_git(
|
||||||
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
|
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -167,6 +236,58 @@ def test_main_detects_prefixed_secret_assignment(
|
|||||||
assert sentinel not in output
|
assert sentinel not in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_detects_pronote_pin_assignments_without_disclosing_value(
|
||||||
|
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""Détecte les PIN Pronote littéraux et non quotés sans afficher leur valeur.
|
||||||
|
|
||||||
|
:param secret_checker: Module du script sous test.
|
||||||
|
:param tmp_path: Répertoire temporaire représentant un dépôt.
|
||||||
|
:param capsys: Fixture de capture de sortie.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
literal_pin = "pin-literal-sentinel"
|
||||||
|
unquoted_pin = "pin-unquoted-sentinel"
|
||||||
|
(tmp_path / "settings.py").write_text(f'PRONOTE_QR_PIN = "{literal_pin}"\n', encoding="utf-8")
|
||||||
|
(tmp_path / "settings.yaml").write_text(
|
||||||
|
f"PRONOTE_ACCOUNT_PIN: {unquoted_pin}\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert secret_checker.main([], root=tmp_path) == 1
|
||||||
|
output = capsys.readouterr().out
|
||||||
|
assert "settings.py:1" in output
|
||||||
|
assert "settings.yaml:1" in output
|
||||||
|
assert literal_pin not in output
|
||||||
|
assert unquoted_pin not in output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"line",
|
||||||
|
[
|
||||||
|
'PRONOTE_QR_PIN = "<valeur>"',
|
||||||
|
"# PRONOTE_ACCOUNT_PIN doit rester dans le fichier d'environnement local",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_main_ignores_pronote_pin_placeholders_and_descriptions(
|
||||||
|
secret_checker: ModuleType,
|
||||||
|
tmp_path: Path,
|
||||||
|
capsys: CaptureFixture[str],
|
||||||
|
line: str,
|
||||||
|
) -> None:
|
||||||
|
"""Ignore les placeholders et descriptions de PIN sans affectation réelle.
|
||||||
|
|
||||||
|
:param secret_checker: Module du script sous test.
|
||||||
|
:param tmp_path: Répertoire temporaire représentant un dépôt.
|
||||||
|
:param capsys: Fixture de capture de sortie.
|
||||||
|
:param line: Ligne documentaire à analyser.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
(tmp_path / "guide.py").write_text(line + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
assert secret_checker.main([], root=tmp_path) == 0
|
||||||
|
assert "OK:" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
def test_main_detects_short_secret_assignment(
|
def test_main_detects_short_secret_assignment(
|
||||||
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
|
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
+137
-3
@@ -10,10 +10,10 @@ from __future__ import annotations
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic import SecretStr
|
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 PronoteSettings, Settings
|
from pronote_sync.config.settings import AppSettings, PronoteSettings, Settings
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from _pytest.monkeypatch import MonkeyPatch
|
from _pytest.monkeypatch import MonkeyPatch
|
||||||
@@ -207,4 +207,138 @@ def test_qr_pin_in_redaction_secrets(monkeypatch: MonkeyPatch) -> None:
|
|||||||
assert "**********" in repr(settings.pronote.qr_pin)
|
assert "**********" in repr(settings.pronote.qr_pin)
|
||||||
|
|
||||||
|
|
||||||
# Ensure trailing newline
|
def test_account_pin_loaded_as_secretstr_and_redacted(monkeypatch: MonkeyPatch) -> None:
|
||||||
|
"""Vérifie que ``PRONOTE_ACCOUNT_PIN`` est secret et expurgé.
|
||||||
|
|
||||||
|
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
monkeypatch.setenv("PRONOTE_ACCOUNT_PIN", "account-pin-42")
|
||||||
|
settings = load_settings()
|
||||||
|
|
||||||
|
assert isinstance(settings.pronote.account_pin, SecretStr)
|
||||||
|
assert settings.pronote.account_pin.get_secret_value() == "account-pin-42"
|
||||||
|
assert settings.pronote.account_pin in settings.redaction_secrets()
|
||||||
|
assert "account-pin-42" not in str(settings)
|
||||||
|
assert "account-pin-42" not in settings.model_dump_json()
|
||||||
|
assert "**********" in settings.model_dump_json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_past_days_negative_direct_instantiation() -> None:
|
||||||
|
"""Vérifie que ``sync_past_days`` négatif lève ``ValidationError`` à l'instanciation.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AppSettings(sync_past_days=-1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_future_days_negative_direct_instantiation() -> None:
|
||||||
|
"""Vérifie que ``sync_future_days`` négatif lève ``ValidationError`` à l'instanciation.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AppSettings(sync_future_days=-1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_past_days_zero_accepted() -> None:
|
||||||
|
"""Vérifie que ``sync_past_days=0`` est accepté.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
settings = AppSettings(sync_past_days=0)
|
||||||
|
assert settings.sync_past_days == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_future_days_zero_accepted() -> None:
|
||||||
|
"""Vérifie que ``sync_future_days=0`` est accepté.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
settings = AppSettings(sync_future_days=0)
|
||||||
|
assert settings.sync_future_days == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_past_days_positive_accepted() -> None:
|
||||||
|
"""Vérifie que ``sync_past_days`` positif est accepté.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
settings = AppSettings(sync_past_days=7)
|
||||||
|
assert settings.sync_past_days == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_future_days_positive_accepted() -> None:
|
||||||
|
"""Vérifie que ``sync_future_days`` positif est accepté.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
settings = AppSettings(sync_future_days=30)
|
||||||
|
assert settings.sync_future_days == 30
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_past_days_negative_env_loading(monkeypatch: MonkeyPatch) -> None:
|
||||||
|
"""Vérifie que ``SYNC_PAST_DAYS=-1`` lève ``ValidationError`` via chargement env.
|
||||||
|
|
||||||
|
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
monkeypatch.setenv("SYNC_PAST_DAYS", "-1")
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
load_settings()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_future_days_negative_env_loading(monkeypatch: MonkeyPatch) -> None:
|
||||||
|
"""Vérifie que ``SYNC_FUTURE_DAYS=-1`` lève ``ValidationError`` via chargement env.
|
||||||
|
|
||||||
|
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
monkeypatch.setenv("SYNC_FUTURE_DAYS", "-1")
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
load_settings()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_past_days_zero_env_loading(monkeypatch: MonkeyPatch) -> None:
|
||||||
|
"""Vérifie que ``SYNC_PAST_DAYS=0`` est accepté via chargement env.
|
||||||
|
|
||||||
|
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
monkeypatch.setenv("SYNC_PAST_DAYS", "0")
|
||||||
|
settings = load_settings()
|
||||||
|
assert settings.app.sync_past_days == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_future_days_zero_env_loading(monkeypatch: MonkeyPatch) -> None:
|
||||||
|
"""Vérifie que ``SYNC_FUTURE_DAYS=0`` est accepté via chargement env.
|
||||||
|
|
||||||
|
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
monkeypatch.setenv("SYNC_FUTURE_DAYS", "0")
|
||||||
|
settings = load_settings()
|
||||||
|
assert settings.app.sync_future_days == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_past_days_positive_env_loading(monkeypatch: MonkeyPatch) -> None:
|
||||||
|
"""Vérifie que ``SYNC_PAST_DAYS`` positif est accepté via chargement env.
|
||||||
|
|
||||||
|
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
monkeypatch.setenv("SYNC_PAST_DAYS", "7")
|
||||||
|
settings = load_settings()
|
||||||
|
assert settings.app.sync_past_days == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_future_days_positive_env_loading(monkeypatch: MonkeyPatch) -> None:
|
||||||
|
"""Vérifie que ``SYNC_FUTURE_DAYS`` positif est accepté via chargement env.
|
||||||
|
|
||||||
|
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
monkeypatch.setenv("SYNC_FUTURE_DAYS", "30")
|
||||||
|
settings = load_settings()
|
||||||
|
assert settings.app.sync_future_days == 30
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Régressions sur le contexte des exceptions expurgées."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import traceback
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pronote_sync.errors import PipelineCriticalError
|
||||||
|
from pronote_sync.models.agenda import Lesson, SchoolEvent
|
||||||
|
from pronote_sync.models.homework import Homework
|
||||||
|
from pronote_sync.models.message import Message
|
||||||
|
from pronote_sync.pipeline.steps.fetch import fetch_step
|
||||||
|
|
||||||
|
|
||||||
|
class _FailingFetcher:
|
||||||
|
"""Fetcher minimal qui expose une erreur externe porteuse d'un secret."""
|
||||||
|
|
||||||
|
def fetch_agenda(self) -> tuple[list[Lesson], list[SchoolEvent]]:
|
||||||
|
"""Déclenche une erreur externe pendant la récupération critique."""
|
||||||
|
raise RuntimeError("password=fetch-context-secret")
|
||||||
|
|
||||||
|
def fetch_homework(self, target_date: date) -> list[Homework]:
|
||||||
|
"""Retourne une liste vide pour compléter le protocole du fetcher."""
|
||||||
|
return []
|
||||||
|
|
||||||
|
def fetch_messages(self) -> list[Message]:
|
||||||
|
"""Retourne une liste vide pour compléter le protocole du fetcher."""
|
||||||
|
return []
|
||||||
|
|
||||||
|
def fetch_informations(self) -> list[Message]:
|
||||||
|
"""Retourne une liste vide pour compléter le protocole du fetcher."""
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_step_does_not_retain_external_exception_context() -> None:
|
||||||
|
"""Vérifie qu'une erreur critique ne conserve ni secret ni contexte externe."""
|
||||||
|
with pytest.raises(PipelineCriticalError) as exc_info:
|
||||||
|
fetch_step(_FailingFetcher())
|
||||||
|
|
||||||
|
error = exc_info.value
|
||||||
|
formatted = "".join(traceback.format_exception(error))
|
||||||
|
assert "fetch-context-secret" not in str(error)
|
||||||
|
assert "fetch-context-secret" not in formatted
|
||||||
|
assert error.__cause__ is None
|
||||||
|
assert error.__context__ is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_production_raise_from_none_is_never_inside_except() -> None:
|
||||||
|
"""Vérifie structurellement que les exceptions expurgées sont levées hors des handlers."""
|
||||||
|
root = Path(__file__).parents[2] / "pronote_sync"
|
||||||
|
violations: list[str] = []
|
||||||
|
|
||||||
|
class Visitor(ast.NodeVisitor):
|
||||||
|
"""Collecte les levées ``from None`` imbriquées dans un handler."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._inside_except = False
|
||||||
|
|
||||||
|
def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
|
||||||
|
"""Visite un handler en marquant son périmètre lexical."""
|
||||||
|
previous = self._inside_except
|
||||||
|
self._inside_except = True
|
||||||
|
self.generic_visit(node)
|
||||||
|
self._inside_except = previous
|
||||||
|
|
||||||
|
def visit_Raise(self, node: ast.Raise) -> None:
|
||||||
|
"""Signale une levée ``from None`` dans un handler."""
|
||||||
|
if (
|
||||||
|
self._inside_except
|
||||||
|
and isinstance(node.cause, ast.Constant)
|
||||||
|
and node.cause.value is None
|
||||||
|
):
|
||||||
|
violations.append(f"{path}:{node.lineno}")
|
||||||
|
self.generic_visit(node)
|
||||||
|
|
||||||
|
for path in sorted(root.rglob("*.py")):
|
||||||
|
Visitor().visit(ast.parse(path.read_text(encoding="utf-8")))
|
||||||
|
|
||||||
|
assert violations == []
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Tests de sélection de la date cible du digest Pronote."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from pronote_sync.models.agenda import Lesson, LessonStatus, SchoolEvent, SchoolEventKind
|
||||||
|
from pronote_sync.pipeline.steps.fetch import resolve_target_date
|
||||||
|
|
||||||
|
|
||||||
|
def _lesson(day: date, status: LessonStatus = LessonStatus.NORMAL) -> Lesson:
|
||||||
|
"""Construit un cours minimal à une date donnée.
|
||||||
|
|
||||||
|
:param day: Date du cours.
|
||||||
|
:param status: Statut du cours.
|
||||||
|
:return: Cours de test.
|
||||||
|
:rtype: Lesson
|
||||||
|
"""
|
||||||
|
return Lesson(
|
||||||
|
id=day.isoformat(),
|
||||||
|
start=datetime.combine(day, datetime.min.time()),
|
||||||
|
end=datetime.combine(day, datetime.min.time().replace(hour=1)),
|
||||||
|
subject="Mathématiques",
|
||||||
|
group=None,
|
||||||
|
status=status,
|
||||||
|
content=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _event(from_date: date, to_date: date) -> SchoolEvent:
|
||||||
|
"""Construit un événement scolaire à borne de fin exclusive.
|
||||||
|
|
||||||
|
:param from_date: Début inclusif.
|
||||||
|
:param to_date: Fin exclusive.
|
||||||
|
:return: Événement de vacances de test.
|
||||||
|
:rtype: SchoolEvent
|
||||||
|
"""
|
||||||
|
return SchoolEvent(
|
||||||
|
kind=SchoolEventKind.HOLIDAY,
|
||||||
|
label="Vacances anonymisées",
|
||||||
|
from_date=from_date,
|
||||||
|
to_date=to_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_target_date_prefers_tomorrow_effective_lesson() -> None:
|
||||||
|
"""Un cours effectif demain est prioritaire.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
today = date(2026, 9, 11)
|
||||||
|
|
||||||
|
assert resolve_target_date(today, [_lesson(date(2026, 9, 12))], []) == date(2026, 9, 12)
|
||||||
|
|
||||||
|
|
||||||
|
def test_target_date_skips_empty_days_and_cancelled_lessons() -> None:
|
||||||
|
"""Le prochain cours effectif remplace un lendemain vide ou annulé.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
today = date(2026, 9, 11)
|
||||||
|
lessons = [
|
||||||
|
_lesson(date(2026, 9, 12), LessonStatus.CANCELLED),
|
||||||
|
_lesson(date(2026, 9, 14)),
|
||||||
|
]
|
||||||
|
|
||||||
|
assert resolve_target_date(today, lessons, []) == date(2026, 9, 14)
|
||||||
|
|
||||||
|
|
||||||
|
def test_target_date_uses_exclusive_holiday_end_when_no_reprise_is_visible() -> None:
|
||||||
|
"""La fin exclusive d'une période couvrant demain devient la reprise connue.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
today = date(2026, 10, 19)
|
||||||
|
holiday = _event(date(2026, 10, 20), date(2026, 11, 2))
|
||||||
|
|
||||||
|
assert resolve_target_date(today, [], [holiday]) == date(2026, 11, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_target_date_falls_back_to_tomorrow_without_data() -> None:
|
||||||
|
"""Une liste vide valide sans événement conserve J+1 explicitement.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
assert resolve_target_date(date(2026, 9, 11), [], []) == date(2026, 9, 12)
|
||||||
+148
-5
@@ -294,8 +294,8 @@ def test_collect_homeworks_dedup() -> None:
|
|||||||
id="lesson2",
|
id="lesson2",
|
||||||
start=datetime(2026, 9, 10, 10, 0),
|
start=datetime(2026, 9, 10, 10, 0),
|
||||||
end=datetime(2026, 9, 10, 11, 0),
|
end=datetime(2026, 9, 10, 11, 0),
|
||||||
subject="Physique",
|
subject="Math",
|
||||||
teachers=("M. Martin",),
|
teachers=("M. Dupont",),
|
||||||
rooms=("205",),
|
rooms=("205",),
|
||||||
group=None,
|
group=None,
|
||||||
status=LessonStatus.NORMAL,
|
status=LessonStatus.NORMAL,
|
||||||
@@ -343,8 +343,8 @@ def test_collect_homeworks_id_stability() -> None:
|
|||||||
id="lesson2",
|
id="lesson2",
|
||||||
start=datetime(2026, 9, 10, 10, 0),
|
start=datetime(2026, 9, 10, 10, 0),
|
||||||
end=datetime(2026, 9, 10, 11, 0),
|
end=datetime(2026, 9, 10, 11, 0),
|
||||||
subject="Physique",
|
subject="Math",
|
||||||
teachers=("M. Martin",),
|
teachers=("M. Dupont",),
|
||||||
rooms=("205",),
|
rooms=("205",),
|
||||||
group=None,
|
group=None,
|
||||||
status=LessonStatus.NORMAL,
|
status=LessonStatus.NORMAL,
|
||||||
@@ -361,7 +361,150 @@ def test_collect_homeworks_id_stability() -> None:
|
|||||||
|
|
||||||
homeworks = collect_homeworks([lesson1, lesson2], target_date=date(2026, 9, 10))
|
homeworks = collect_homeworks([lesson1, lesson2], target_date=date(2026, 9, 10))
|
||||||
assert len(homeworks) == 1
|
assert len(homeworks) == 1
|
||||||
assert homeworks[0].id == generate_homework_id(date(2026, 9, 10), "devoir commun")
|
assert homeworks[0].id == generate_homework_id(
|
||||||
|
date(2026, 9, 10), "devoir commun", "Math", ("M. Dupont",)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_homeworks_keeps_distinct_subjects() -> None:
|
||||||
|
"""Deux matières différentes conservent deux devoirs homonymes.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
lesson = Lesson(
|
||||||
|
id="lesson1",
|
||||||
|
start=datetime(2026, 9, 10, 8, 0),
|
||||||
|
end=datetime(2026, 9, 10, 9, 0),
|
||||||
|
subject="Math",
|
||||||
|
teachers=("M. Dupont",),
|
||||||
|
group=None,
|
||||||
|
content=None,
|
||||||
|
homework_blocks=(
|
||||||
|
HomeworkBlock(
|
||||||
|
kind="due",
|
||||||
|
date=date(2026, 9, 10),
|
||||||
|
text="Devoir commun",
|
||||||
|
html="<p>Devoir commun</p>",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
other_lesson = lesson.model_copy(
|
||||||
|
update={"id": "lesson2", "subject": "Physique", "teachers": ("M. Martin",)}
|
||||||
|
)
|
||||||
|
|
||||||
|
homeworks = collect_homeworks([lesson, other_lesson], target_date=date(2026, 9, 10))
|
||||||
|
|
||||||
|
assert len(homeworks) == 2
|
||||||
|
assert {homework.subject for homework in homeworks} == {"Math", "Physique"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_homeworks_deduplicates_teacher_order_variants() -> None:
|
||||||
|
"""Deux ordres d'enseignants équivalents ne créent pas de doublon.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
homework_block = HomeworkBlock(
|
||||||
|
kind="due",
|
||||||
|
date=date(2026, 9, 10),
|
||||||
|
text="Devoir commun",
|
||||||
|
html="<p>Devoir commun</p>",
|
||||||
|
)
|
||||||
|
lesson = Lesson(
|
||||||
|
id="lesson1",
|
||||||
|
start=datetime(2026, 9, 10, 8, 0),
|
||||||
|
end=datetime(2026, 9, 10, 9, 0),
|
||||||
|
subject="Math",
|
||||||
|
teachers=("M. Martin", "M. Dupont"),
|
||||||
|
group=None,
|
||||||
|
content=None,
|
||||||
|
homework_blocks=(homework_block,),
|
||||||
|
)
|
||||||
|
other_lesson = lesson.model_copy(
|
||||||
|
update={"id": "lesson2", "teachers": ("M. Dupont", "M. Martin")}
|
||||||
|
)
|
||||||
|
|
||||||
|
homeworks = collect_homeworks([lesson, other_lesson], target_date=date(2026, 9, 10))
|
||||||
|
|
||||||
|
assert len(homeworks) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_body_accepts_html_variants_and_sanitizes() -> None:
|
||||||
|
"""Les attributs HTML, les espaces et le contenu actif sont traités correctement.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
body = (
|
||||||
|
'<strong class="label"> Contenu pédagogique : </strong><p>Leçon & exemple</p>'
|
||||||
|
'<strong data-kind="homework"> Pour le 10/09/2026 : </strong>'
|
||||||
|
'<p onclick="evil()">Lire <em>le chapitre</em></p><script>alert(1)</script>'
|
||||||
|
"<strong> Donné le 05/09/2026 : </strong><p>Noter & relire</p>"
|
||||||
|
)
|
||||||
|
|
||||||
|
content, due_blocks, assigned_blocks = parse_body(body)
|
||||||
|
|
||||||
|
assert content == "Leçon & exemple"
|
||||||
|
assert due_blocks[0].date == date(2026, 9, 10)
|
||||||
|
assert due_blocks[0].text == "Lire le chapitre"
|
||||||
|
assert "<em>le chapitre</em>" in due_blocks[0].html
|
||||||
|
assert "onclick" not in due_blocks[0].html
|
||||||
|
assert "script" not in due_blocks[0].html.lower()
|
||||||
|
assert assigned_blocks[0].date == date(2026, 9, 5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_anonymized_variants_fixture() -> None:
|
||||||
|
"""Vérifie le corpus iCal anonymisé sur les statuts et le contexte des devoirs.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
fixture_path = Path(__file__).parent.parent / "fixtures" / "pronote-variants.ics"
|
||||||
|
lessons, _, school_events = parse_ical(fixture_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
assert lessons[0].class_part == "Groupe 1"
|
||||||
|
assert lessons[0].homework_blocks[0].html == "<p>Lire le chapitre</p>"
|
||||||
|
assert any(lesson.status == LessonStatus.MOVED for lesson in lessons)
|
||||||
|
assert any(event.kind.value == "public_holiday" for event in school_events)
|
||||||
|
assert any(
|
||||||
|
event.from_date == date(2026, 10, 20) and event.to_date == date(2026, 10, 22)
|
||||||
|
for event in school_events
|
||||||
|
)
|
||||||
|
|
||||||
|
homeworks = collect_homeworks(lessons, target_date=date(2026, 9, 15))
|
||||||
|
|
||||||
|
assert len(homeworks) == 2
|
||||||
|
assert {homework.subject for homework in homeworks} == {"Mathématiques", "Sciences"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_ical_removes_old_replaced_lesson() -> None:
|
||||||
|
"""Un ancien cours annulé est retiré lorsqu'un cours déplacé le remplace.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
raw_ical = """BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:old
|
||||||
|
DTSTART:20260911T080000Z
|
||||||
|
DTEND:20260911T090000Z
|
||||||
|
SUMMARY:Mathématiques
|
||||||
|
CATEGORIES:Cours - Cours annulé
|
||||||
|
STATUS:CANCELLED
|
||||||
|
DESCRIPTION:<div>Matière : Mathématiques Professeur : Enseignant A Salle : 101</div>
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:new
|
||||||
|
DTSTART:20260911T080000Z
|
||||||
|
DTEND:20260911T090000Z
|
||||||
|
SUMMARY:Mathématiques
|
||||||
|
CATEGORIES:Cours - Changement de salle
|
||||||
|
DESCRIPTION:<div>Matière : Mathématiques Professeur : Enseignant A Salle : 204</div>
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR"""
|
||||||
|
|
||||||
|
lessons, _, _ = parse_ical(raw_ical)
|
||||||
|
|
||||||
|
assert len(lessons) == 1
|
||||||
|
assert lessons[0].status == LessonStatus.MOVED
|
||||||
|
assert lessons[0].rooms == ("204",)
|
||||||
|
|
||||||
|
|
||||||
def test_collect_homeworks_sorted() -> None:
|
def test_collect_homeworks_sorted() -> None:
|
||||||
|
|||||||
@@ -17,10 +17,12 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from fcntl import LOCK_EX, LOCK_NB, LOCK_UN, flock
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from pronote_sync.errors import PronoteAuthStateLockError
|
||||||
from pronote_sync.sources.pronote.auth_state import PronoteAuthState
|
from pronote_sync.sources.pronote.auth_state import PronoteAuthState
|
||||||
|
|
||||||
|
|
||||||
@@ -290,3 +292,76 @@ def test_no_credentials_in_logs(tmp_path: Path, caplog: pytest.LogCaptureFixture
|
|||||||
assert "SENTINEL_USER_ZZZ" not in caplog.text
|
assert "SENTINEL_USER_ZZZ" not in caplog.text
|
||||||
assert "SENTINEL_PASSWORD_ZZZ" not in caplog.text
|
assert "SENTINEL_PASSWORD_ZZZ" not in caplog.text
|
||||||
assert "SENTINEL_UUID_ZZZ" not in caplog.text
|
assert "SENTINEL_UUID_ZZZ" not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_lock_rejects_concurrent_access_with_a_redacted_dedicated_error(tmp_path: Path) -> None:
|
||||||
|
"""Vérifie qu'un verrou concurrent échoue immédiatement sans fuite interne.
|
||||||
|
|
||||||
|
:param tmp_path: Fixture pytest pour un répertoire temporaire.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
state_file = tmp_path / ".pronote_auth_state.json"
|
||||||
|
state = PronoteAuthState(state_file)
|
||||||
|
competing_state = PronoteAuthState(state_file)
|
||||||
|
|
||||||
|
with state.lock():
|
||||||
|
assert state_file.with_name(f"{state_file.name}.lock").exists()
|
||||||
|
with pytest.raises(PronoteAuthStateLockError) as exc_info:
|
||||||
|
with competing_state.lock():
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert "BlockingIOError" not in str(exc_info.value)
|
||||||
|
assert exc_info.value.__cause__ is None
|
||||||
|
assert exc_info.value.__context__ is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_lock_open_failure_does_not_log_sensitive_lock_path(
|
||||||
|
tmp_path: Path, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie qu'un échec d'ouverture du verrou ne divulgue pas son chemin.
|
||||||
|
|
||||||
|
:param tmp_path: Fixture pytest pour un répertoire temporaire.
|
||||||
|
:param caplog: Fixture pytest pour capturer les logs.
|
||||||
|
:param monkeypatch: Fixture pytest pour remplacer l'ouverture du verrou.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
sentinel_path = "/SENTINEL_LOCK_PATH_ZZZ/.pronote_auth_state.json.lock"
|
||||||
|
|
||||||
|
def raise_lock_open_error(*args: object, **kwargs: object) -> int:
|
||||||
|
"""Simule un refus d'ouverture portant un chemin sensible."""
|
||||||
|
del args, kwargs
|
||||||
|
raise OSError(13, "Permission denied", sentinel_path)
|
||||||
|
|
||||||
|
monkeypatch.setattr(os, "open", raise_lock_open_error)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.ERROR):
|
||||||
|
with pytest.raises(PronoteAuthStateLockError) as exc_info:
|
||||||
|
with PronoteAuthState(tmp_path / ".pronote_auth_state.json").lock():
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert "Impossible d'ouvrir le verrou d'état d'authentification Pronote" in caplog.text
|
||||||
|
assert "SENTINEL_LOCK_PATH_ZZZ" not in caplog.text
|
||||||
|
assert exc_info.value.__cause__ is None
|
||||||
|
assert exc_info.value.__context__ is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_lock_is_released_when_the_protected_operation_raises(tmp_path: Path) -> None:
|
||||||
|
"""Vérifie que le verrou est libéré même si le bloc protégé échoue.
|
||||||
|
|
||||||
|
:param tmp_path: Fixture pytest pour un répertoire temporaire.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
state_file = tmp_path / ".pronote_auth_state.json"
|
||||||
|
lock_file = state_file.with_name(f"{state_file.name}.lock")
|
||||||
|
state = PronoteAuthState(state_file)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="échec simulé"):
|
||||||
|
with state.lock():
|
||||||
|
raise RuntimeError("échec simulé")
|
||||||
|
|
||||||
|
descriptor = os.open(lock_file, os.O_RDWR)
|
||||||
|
try:
|
||||||
|
flock(descriptor, LOCK_EX | LOCK_NB)
|
||||||
|
flock(descriptor, LOCK_UN)
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import traceback
|
||||||
|
from collections.abc import Generator
|
||||||
|
from contextlib import contextmanager
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
@@ -231,6 +234,24 @@ def test_get_lessons_success(
|
|||||||
assert lesson.content == "Lesson content"
|
assert lesson.content == "Lesson content"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_lessons_maps_moved_status(
|
||||||
|
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
|
||||||
|
) -> None:
|
||||||
|
"""Mappe le statut ``Changement de salle`` de pronotepy vers ``MOVED``.
|
||||||
|
|
||||||
|
:param mocker: Fixture pytest-mock pour le mocking.
|
||||||
|
:param pronote_settings: Paramètres Pronote valides.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
mock_client = _make_lessons_mock_client(mocker)
|
||||||
|
mock_client.lessons.return_value[0].status = "Changement de salle"
|
||||||
|
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
|
||||||
|
|
||||||
|
lessons = PronoteClient(pronote_settings).get_lessons(date(2024, 9, 1), date(2024, 9, 30))
|
||||||
|
|
||||||
|
assert lessons[0].status == LessonStatus.MOVED
|
||||||
|
|
||||||
|
|
||||||
def test_get_homeworks_success(
|
def test_get_homeworks_success(
|
||||||
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
|
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -601,12 +622,17 @@ def test_get_messages_degraded_on_error(
|
|||||||
|
|
||||||
|
|
||||||
def test_get_informations_degraded_on_error(
|
def test_get_informations_degraded_on_error(
|
||||||
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
|
mocker: pytest_mock.MockerFixture,
|
||||||
|
pronote_settings: PronoteSettings,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Vérifie que get_informations retourne une liste vide en cas d'erreur réseau.
|
"""Vérifie que get_informations retourne une liste vide en cas d'erreur réseau.
|
||||||
|
|
||||||
|
Assert que le chemin d'erreur retourne toujours [] avec un log ERROR.
|
||||||
|
|
||||||
:param mocker: Fixture pytest-mock pour le mocking.
|
:param mocker: Fixture pytest-mock pour le mocking.
|
||||||
:param pronote_settings: Paramètres Pronote valides.
|
:param pronote_settings: Paramètres Pronote valides.
|
||||||
|
:param caplog: Fixture pour capturer les logs.
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
mock_client = mocker.MagicMock()
|
mock_client = mocker.MagicMock()
|
||||||
@@ -614,22 +640,265 @@ def test_get_informations_degraded_on_error(
|
|||||||
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
|
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
|
||||||
|
|
||||||
client = PronoteClient(pronote_settings)
|
client = PronoteClient(pronote_settings)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.ERROR, logger="pronote_sync.sources.pronote.client"):
|
||||||
messages = client.get_informations()
|
messages = client.get_informations()
|
||||||
|
|
||||||
assert messages == []
|
assert messages == []
|
||||||
|
# Assert ERROR log is present
|
||||||
|
error_records = [r for r in caplog.records if r.levelno == logging.ERROR]
|
||||||
|
assert len(error_records) >= 1
|
||||||
|
assert any(
|
||||||
|
"Échec de la récupération des informations Pronote" in r.message for r in error_records
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# --- QR code / token authentication tests ---
|
# --- QR code / token authentication tests ---
|
||||||
|
|
||||||
|
|
||||||
|
# --- get_informations qr_token mode guard tests ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_informations_skips_in_qr_token_mode(
|
||||||
|
mocker: pytest_mock.MockerFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie que get_informations retourne [] immédiatement en mode qr_token.
|
||||||
|
|
||||||
|
:param mocker: Fixture pytest-mock pour le mocking.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
settings = PronoteSettings(
|
||||||
|
url="https://pronote.example.com",
|
||||||
|
username="testuser",
|
||||||
|
password=SecretStr("testpass"),
|
||||||
|
ent="bordeaux",
|
||||||
|
account_type="parent",
|
||||||
|
auth_mode="qr_token",
|
||||||
|
)
|
||||||
|
client = PronoteClient(settings)
|
||||||
|
assert client._client is None
|
||||||
|
|
||||||
|
messages = client.get_informations()
|
||||||
|
|
||||||
|
assert messages == []
|
||||||
|
assert client._client is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_informations_no_connect_in_qr_token_mode(
|
||||||
|
mocker: pytest_mock.MockerFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie que _connect n'est jamais appelé en mode qr_token pour get_informations.
|
||||||
|
|
||||||
|
:param mocker: Fixture pytest-mock pour le mocking.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
settings = PronoteSettings(
|
||||||
|
url="https://pronote.example.com",
|
||||||
|
username="testuser",
|
||||||
|
password=SecretStr("testpass"),
|
||||||
|
ent="bordeaux",
|
||||||
|
account_type="parent",
|
||||||
|
auth_mode="qr_token",
|
||||||
|
)
|
||||||
|
connect_spy = mocker.spy(PronoteClient, "_connect")
|
||||||
|
|
||||||
|
client = PronoteClient(settings)
|
||||||
|
messages = client.get_informations()
|
||||||
|
|
||||||
|
assert messages == []
|
||||||
|
connect_spy.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_informations_no_information_and_surveys_in_qr_token_mode(
|
||||||
|
mocker: pytest_mock.MockerFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie que information_and_surveys n'est jamais appelé en mode qr_token.
|
||||||
|
|
||||||
|
:param mocker: Fixture pytest-mock pour le mocking.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
settings = PronoteSettings(
|
||||||
|
url="https://pronote.example.com",
|
||||||
|
username="testuser",
|
||||||
|
password=SecretStr("testpass"),
|
||||||
|
ent="bordeaux",
|
||||||
|
account_type="parent",
|
||||||
|
auth_mode="qr_token",
|
||||||
|
)
|
||||||
|
mock_client = mocker.MagicMock()
|
||||||
|
mock_client.information_and_surveys = mocker.MagicMock()
|
||||||
|
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
|
||||||
|
|
||||||
|
client = PronoteClient(settings)
|
||||||
|
messages = client.get_informations()
|
||||||
|
|
||||||
|
assert messages == []
|
||||||
|
mock_client.information_and_surveys.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_informations_qr_token_no_side_effects(
|
||||||
|
mocker: pytest_mock.MockerFixture,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Prouve le contrat complet sans effet de bordure du chemin de saut qr_token.
|
||||||
|
|
||||||
|
Avec PronoteAuthState et _qr_token_operation_lock et _persist_credentials
|
||||||
|
mockés, assert que sur le saut : le gestionnaire de contexte de verrou n'est
|
||||||
|
PAS entré, auth-state load() n'est PAS appelé, _persist_credentials() n'est
|
||||||
|
PAS appelé, et l'export des credentials n'est PAS invoqué. Assert aussi que
|
||||||
|
self._client est inchangé.
|
||||||
|
|
||||||
|
:param mocker: Fixture pytest-mock pour le mocking.
|
||||||
|
:param caplog: Fixture pour capturer les logs.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
auth_state = mocker.MagicMock(spec=PronoteAuthState)
|
||||||
|
auth_state.load = mocker.MagicMock()
|
||||||
|
auth_state.lock = mocker.MagicMock()
|
||||||
|
|
||||||
|
settings = PronoteSettings(
|
||||||
|
url="https://pronote.example.com",
|
||||||
|
username="testuser",
|
||||||
|
password=SecretStr("testpass"),
|
||||||
|
ent="bordeaux",
|
||||||
|
account_type="parent",
|
||||||
|
auth_mode="qr_token",
|
||||||
|
)
|
||||||
|
|
||||||
|
client = PronoteClient(settings, auth_state=auth_state)
|
||||||
|
sentinel = MagicMock()
|
||||||
|
client._client = sentinel
|
||||||
|
persist_spy = mocker.spy(client, "_persist_credentials")
|
||||||
|
|
||||||
|
with caplog.at_level(logging.INFO, logger="pronote_sync.sources.pronote.client"):
|
||||||
|
messages = client.get_informations()
|
||||||
|
|
||||||
|
# Assert no side effects
|
||||||
|
assert messages == []
|
||||||
|
assert client._client is sentinel
|
||||||
|
assert isinstance(sentinel, MagicMock)
|
||||||
|
sentinel.export_credentials.assert_not_called()
|
||||||
|
persist_spy.assert_not_called()
|
||||||
|
auth_state.load.assert_not_called()
|
||||||
|
auth_state.lock.assert_not_called()
|
||||||
|
|
||||||
|
# Assert exactly one INFO log record with the exact message
|
||||||
|
info_records = [r for r in caplog.records if r.levelno == logging.INFO]
|
||||||
|
assert len(info_records) == 1
|
||||||
|
assert (
|
||||||
|
info_records[0].message
|
||||||
|
== "Récupération des informations Pronote ignorée : endpoint PageActualites "
|
||||||
|
"indisponible en mode d'authentification qr_token."
|
||||||
|
)
|
||||||
|
# Assert no secret sentinel appears in any log record
|
||||||
|
for record in caplog.records:
|
||||||
|
assert "testpass" not in record.message
|
||||||
|
assert "testuser" not in record.message
|
||||||
|
assert "pronote.example.com" not in record.message
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_informations_logs_info_in_qr_token_mode(
|
||||||
|
mocker: pytest_mock.MockerFixture,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie que get_informations log un message INFO exact en mode qr_token.
|
||||||
|
|
||||||
|
Assert exactement un enregistrement logging.INFO avec le message exact
|
||||||
|
(inspection de caplog.records, pas seulement caplog.text), et qu'aucune
|
||||||
|
sentinelle de secret n'apparaît.
|
||||||
|
|
||||||
|
:param mocker: Fixture pytest-mock pour le mocking.
|
||||||
|
:param caplog: Fixture pour capturer les logs.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
settings = PronoteSettings(
|
||||||
|
url="https://pronote.example.com",
|
||||||
|
username="testuser",
|
||||||
|
password=SecretStr("testpass"),
|
||||||
|
ent="bordeaux",
|
||||||
|
account_type="parent",
|
||||||
|
auth_mode="qr_token",
|
||||||
|
)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.INFO, logger="pronote_sync.sources.pronote.client"):
|
||||||
|
client = PronoteClient(settings)
|
||||||
|
messages = client.get_informations()
|
||||||
|
|
||||||
|
assert messages == []
|
||||||
|
# Assert exactly one INFO record with the exact message
|
||||||
|
info_records = [r for r in caplog.records if r.levelno == logging.INFO]
|
||||||
|
assert len(info_records) == 1
|
||||||
|
assert (
|
||||||
|
info_records[0].message
|
||||||
|
== "Récupération des informations Pronote ignorée : endpoint PageActualites "
|
||||||
|
"indisponible en mode d'authentification qr_token."
|
||||||
|
)
|
||||||
|
# Assert no secret sentinel appears in any record
|
||||||
|
for record in caplog.records:
|
||||||
|
assert "testpass" not in record.message
|
||||||
|
assert "testuser" not in record.message
|
||||||
|
assert "pronote.example.com" not in record.message
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_informations_unchanged_in_password_mode(
|
||||||
|
mocker: pytest_mock.MockerFixture,
|
||||||
|
pronote_settings: PronoteSettings,
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie que get_informations en mode password reste inchangé (régression).
|
||||||
|
|
||||||
|
Assert que _connect() A ÉTÉ appelé et information_and_surveys() A ÉTÉ appelé
|
||||||
|
(en cas de succès), en conservant les assertions de mappage existantes.
|
||||||
|
|
||||||
|
:param mocker: Fixture pytest-mock pour le mocking.
|
||||||
|
:param pronote_settings: Paramètres Pronote valides en mode password.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
mock_client = mocker.MagicMock()
|
||||||
|
mock_info = mocker.MagicMock()
|
||||||
|
mock_info.id = "info-456"
|
||||||
|
mock_info.title = "Important Info"
|
||||||
|
mock_info.content.return_value = "Important content"
|
||||||
|
mock_info.author = "Admin"
|
||||||
|
mock_info.creation_date = datetime(2024, 9, 2, 14, 30, 0)
|
||||||
|
mock_info.read = False
|
||||||
|
mock_info.survey = True
|
||||||
|
mock_client.information_and_surveys.return_value = [mock_info]
|
||||||
|
|
||||||
|
# Patch _connect to return mock_client and track calls
|
||||||
|
connect_patch = mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
|
||||||
|
|
||||||
|
client = PronoteClient(pronote_settings)
|
||||||
|
messages = client.get_informations()
|
||||||
|
|
||||||
|
# Assert _connect() WAS called and information_and_surveys() WAS called
|
||||||
|
connect_patch.assert_called_once()
|
||||||
|
mock_client.information_and_surveys.assert_called_once()
|
||||||
|
|
||||||
|
# Retain existing mapping assertions
|
||||||
|
assert isinstance(messages, list)
|
||||||
|
assert len(messages) == 1
|
||||||
|
message = messages[0]
|
||||||
|
assert isinstance(message, Message)
|
||||||
|
assert message.id == "info-456"
|
||||||
|
assert message.type == MessageType.SURVEY
|
||||||
|
assert message.title == "Important Info"
|
||||||
|
assert message.content == "Important content"
|
||||||
|
assert message.author == "Admin"
|
||||||
|
assert message.date == datetime(2024, 9, 2, 14, 30, 0)
|
||||||
|
assert message.read is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("account_pin", [None, "account-pin-42"])
|
||||||
def test_connect_password_mode_unchanged(
|
def test_connect_password_mode_unchanged(
|
||||||
mocker: pytest_mock.MockerFixture,
|
mocker: pytest_mock.MockerFixture,
|
||||||
pronote_settings: PronoteSettings,
|
pronote_settings: PronoteSettings,
|
||||||
|
account_pin: str | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Vérifie que le mode password conserve le comportement historique.
|
"""Vérifie que le mode password conserve le comportement historique.
|
||||||
|
|
||||||
:param mocker: Fixture pytest-mock pour le mocking.
|
:param mocker: Fixture pytest-mock pour le mocking.
|
||||||
:param pronote_settings: Paramètres Pronote valides en mode password.
|
:param pronote_settings: Paramètres Pronote valides en mode password.
|
||||||
|
:param account_pin: PIN de compte éventuellement configuré.
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
from unittest.mock import Mock
|
from unittest.mock import Mock
|
||||||
@@ -639,7 +908,10 @@ def test_connect_password_mode_unchanged(
|
|||||||
mocker.patch("pronotepy.ParentClient", new=mock_client_class)
|
mocker.patch("pronotepy.ParentClient", new=mock_client_class)
|
||||||
mocker.patch("pronotepy.Client")
|
mocker.patch("pronotepy.Client")
|
||||||
|
|
||||||
client = PronoteClient(pronote_settings, auth_state=None)
|
settings = pronote_settings.model_copy(
|
||||||
|
update={"account_pin": SecretStr(account_pin) if account_pin is not None else None}
|
||||||
|
)
|
||||||
|
client = PronoteClient(settings, auth_state=None)
|
||||||
connected = client._connect()
|
connected = client._connect()
|
||||||
|
|
||||||
assert connected is mock_client
|
assert connected is mock_client
|
||||||
@@ -654,8 +926,10 @@ def test_connect_password_mode_unchanged(
|
|||||||
assert mock_client_class.call_count == 1
|
assert mock_client_class.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("account_pin", [None, "account-pin-42"])
|
||||||
def test_connect_qr_token_with_persisted_creds(
|
def test_connect_qr_token_with_persisted_creds(
|
||||||
mocker: pytest_mock.MockerFixture,
|
mocker: pytest_mock.MockerFixture,
|
||||||
|
account_pin: str | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Vérifie le login par token persisté en mode qr_token.
|
"""Vérifie le login par token persisté en mode qr_token.
|
||||||
|
|
||||||
@@ -663,6 +937,7 @@ def test_connect_qr_token_with_persisted_creds(
|
|||||||
``token_login`` et le token rotate est resauvegardé.
|
``token_login`` et le token rotate est resauvegardé.
|
||||||
|
|
||||||
:param mocker: Fixture pytest-mock pour le mocking.
|
:param mocker: Fixture pytest-mock pour le mocking.
|
||||||
|
:param account_pin: PIN de second facteur facultatif.
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
creds = {
|
creds = {
|
||||||
@@ -675,10 +950,26 @@ def test_connect_qr_token_with_persisted_creds(
|
|||||||
auth_state = mocker.MagicMock(spec=PronoteAuthState)
|
auth_state = mocker.MagicMock(spec=PronoteAuthState)
|
||||||
auth_state.load.return_value = creds
|
auth_state.load.return_value = creds
|
||||||
|
|
||||||
mock_client = mocker.MagicMock()
|
mock_client: MagicMock = mocker.MagicMock()
|
||||||
mock_client.logged_in = True
|
mock_client.logged_in = True
|
||||||
mock_client.export_credentials.return_value = rotated_creds
|
mock_client.export_credentials.return_value = rotated_creds
|
||||||
mocker.patch("pronotepy.ParentClient.token_login", return_value=mock_client)
|
|
||||||
|
def token_login_fake(
|
||||||
|
pronote_url: str,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
uuid: str,
|
||||||
|
account_pin: str | None = None,
|
||||||
|
client_identifier: str | None = None,
|
||||||
|
device_name: str | None = None,
|
||||||
|
) -> MagicMock:
|
||||||
|
"""Retourne le faux client avec la signature pronotepy 2.15.7."""
|
||||||
|
del pronote_url, username, password, uuid, account_pin, client_identifier, device_name
|
||||||
|
return mock_client
|
||||||
|
|
||||||
|
token_login = mocker.patch(
|
||||||
|
"pronotepy.ParentClient.token_login", autospec=True, side_effect=token_login_fake
|
||||||
|
)
|
||||||
|
|
||||||
settings = PronoteSettings(
|
settings = PronoteSettings(
|
||||||
url="https://pronote.example.com",
|
url="https://pronote.example.com",
|
||||||
@@ -687,23 +978,27 @@ def test_connect_qr_token_with_persisted_creds(
|
|||||||
ent=None,
|
ent=None,
|
||||||
account_type="parent",
|
account_type="parent",
|
||||||
auth_mode="qr_token",
|
auth_mode="qr_token",
|
||||||
|
account_pin=SecretStr(account_pin) if account_pin is not None else None,
|
||||||
)
|
)
|
||||||
client = PronoteClient(settings, auth_state=auth_state)
|
client = PronoteClient(settings, auth_state=auth_state)
|
||||||
connected = client._connect()
|
connected = client._connect()
|
||||||
|
|
||||||
assert connected is mock_client
|
assert connected is mock_client
|
||||||
pronotepy.ParentClient.token_login.assert_called_once_with(**creds) # type: ignore[attr-defined]
|
token_login.assert_called_once_with(**creds, account_pin=account_pin)
|
||||||
auth_state.save.assert_called_once_with(rotated_creds)
|
auth_state.save.assert_called_once_with(rotated_creds)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("account_pin", [None, "account-pin-42"])
|
||||||
def test_connect_qr_token_no_creds_with_qr_code(
|
def test_connect_qr_token_no_creds_with_qr_code(
|
||||||
mocker: pytest_mock.MockerFixture,
|
mocker: pytest_mock.MockerFixture,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
|
account_pin: str | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Vérifie l'enrôlement initial par QR code quand aucun token n'est persisté.
|
"""Vérifie l'enrôlement initial par QR code quand aucun token n'est persisté.
|
||||||
|
|
||||||
:param mocker: Fixture pytest-mock pour le mocking.
|
:param mocker: Fixture pytest-mock pour le mocking.
|
||||||
:param tmp_path: Répertoire temporaire de test.
|
:param tmp_path: Répertoire temporaire de test.
|
||||||
|
:param account_pin: PIN de second facteur facultatif.
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
qr_file = tmp_path / "qr_code.json"
|
qr_file = tmp_path / "qr_code.json"
|
||||||
@@ -726,10 +1021,26 @@ def test_connect_qr_token_no_creds_with_qr_code(
|
|||||||
"password": "new-token", # pragma: allowlist secret
|
"password": "new-token", # pragma: allowlist secret
|
||||||
"uuid": "new-uuid",
|
"uuid": "new-uuid",
|
||||||
}
|
}
|
||||||
mock_client = mocker.MagicMock()
|
mock_client: MagicMock = mocker.MagicMock()
|
||||||
mock_client.logged_in = True
|
mock_client.logged_in = True
|
||||||
mock_client.export_credentials.return_value = creds
|
mock_client.export_credentials.return_value = creds
|
||||||
mocker.patch("pronotepy.ParentClient.qrcode_login", return_value=mock_client)
|
|
||||||
|
def qrcode_login_fake(
|
||||||
|
qr_code: dict[str, str],
|
||||||
|
pin: str,
|
||||||
|
uuid: str,
|
||||||
|
account_pin: str | None = None,
|
||||||
|
client_identifier: str | None = None,
|
||||||
|
device_name: str | None = None,
|
||||||
|
skip_2fa: bool = False,
|
||||||
|
) -> MagicMock:
|
||||||
|
"""Retourne le faux client avec la signature pronotepy 2.15.7."""
|
||||||
|
del qr_code, pin, uuid, account_pin, client_identifier, device_name, skip_2fa
|
||||||
|
return mock_client
|
||||||
|
|
||||||
|
qrcode_login = mocker.patch(
|
||||||
|
"pronotepy.ParentClient.qrcode_login", autospec=True, side_effect=qrcode_login_fake
|
||||||
|
)
|
||||||
|
|
||||||
settings = PronoteSettings(
|
settings = PronoteSettings(
|
||||||
url="https://pronote.example.com",
|
url="https://pronote.example.com",
|
||||||
@@ -740,15 +1051,16 @@ def test_connect_qr_token_no_creds_with_qr_code(
|
|||||||
auth_mode="qr_token",
|
auth_mode="qr_token",
|
||||||
qr_code_file=str(qr_file),
|
qr_code_file=str(qr_file),
|
||||||
qr_pin=SecretStr("123456"),
|
qr_pin=SecretStr("123456"),
|
||||||
|
account_pin=SecretStr(account_pin) if account_pin is not None else None,
|
||||||
)
|
)
|
||||||
client = PronoteClient(settings, auth_state=auth_state)
|
client = PronoteClient(settings, auth_state=auth_state)
|
||||||
connected = client._connect()
|
connected = client._connect()
|
||||||
|
|
||||||
assert connected is mock_client
|
assert connected is mock_client
|
||||||
qrcode_login = pronotepy.ParentClient.qrcode_login
|
qrcode_login.assert_called_once()
|
||||||
qrcode_login.assert_called_once() # type: ignore[attr-defined]
|
kwargs = qrcode_login.call_args.kwargs
|
||||||
kwargs = qrcode_login.call_args.kwargs # type: ignore[attr-defined]
|
|
||||||
assert kwargs["pin"] == "123456"
|
assert kwargs["pin"] == "123456"
|
||||||
|
assert kwargs["account_pin"] == account_pin
|
||||||
assert kwargs["qr_code"] == {
|
assert kwargs["qr_code"] == {
|
||||||
"login": "testuser",
|
"login": "testuser",
|
||||||
"jeton": "qr-jeton",
|
"jeton": "qr-jeton",
|
||||||
@@ -1125,6 +1437,12 @@ def test_no_secrets_in_rotation_error_messages(
|
|||||||
assert sentinel_pin not in caplog.text
|
assert sentinel_pin not in caplog.text
|
||||||
assert sentinel_token not in caplog.text
|
assert sentinel_token not in caplog.text
|
||||||
assert "sentinel-url" not in caplog.text
|
assert "sentinel-url" not in caplog.text
|
||||||
|
assert exc_info.value.__cause__ is None
|
||||||
|
assert exc_info.value.__context__ is None
|
||||||
|
formatted_traceback = "".join(traceback.format_exception(exc_info.value))
|
||||||
|
assert sentinel_pin not in formatted_traceback
|
||||||
|
assert sentinel_token not in formatted_traceback
|
||||||
|
assert "sentinel-url" not in formatted_traceback
|
||||||
|
|
||||||
|
|
||||||
def test_no_raw_secrets_in_logs(
|
def test_no_raw_secrets_in_logs(
|
||||||
@@ -1147,6 +1465,7 @@ def test_no_raw_secrets_in_logs(
|
|||||||
sentinel_token = "SENTINEL_RAW_TOKEN_ALPHA"
|
sentinel_token = "SENTINEL_RAW_TOKEN_ALPHA"
|
||||||
sentinel_pin = "SENTINEL_RAW_PIN_BRAVO"
|
sentinel_pin = "SENTINEL_RAW_PIN_BRAVO"
|
||||||
sentinel_jeton = "SENTINEL_RAW_JETON_CHARLIE"
|
sentinel_jeton = "SENTINEL_RAW_JETON_CHARLIE"
|
||||||
|
sentinel_account_pin = "SENTINEL_RAW_ACCOUNT_PIN_DELTA"
|
||||||
|
|
||||||
qr_file = tmp_path / "qr_code.json"
|
qr_file = tmp_path / "qr_code.json"
|
||||||
qr_file.write_text(
|
qr_file.write_text(
|
||||||
@@ -1171,6 +1490,7 @@ def test_no_raw_secrets_in_logs(
|
|||||||
"pronotepy.ParentClient.token_login",
|
"pronotepy.ParentClient.token_login",
|
||||||
side_effect=pronotepy.PronoteAPIError(
|
side_effect=pronotepy.PronoteAPIError(
|
||||||
f"login refusé {sentinel_token} puis {sentinel_pin} puis {sentinel_jeton} "
|
f"login refusé {sentinel_token} puis {sentinel_pin} puis {sentinel_jeton} "
|
||||||
|
f"puis {sentinel_account_pin}"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1183,6 +1503,7 @@ def test_no_raw_secrets_in_logs(
|
|||||||
auth_mode="qr_token",
|
auth_mode="qr_token",
|
||||||
qr_code_file=str(qr_file),
|
qr_code_file=str(qr_file),
|
||||||
qr_pin=SecretStr(sentinel_pin),
|
qr_pin=SecretStr(sentinel_pin),
|
||||||
|
account_pin=SecretStr(sentinel_account_pin),
|
||||||
)
|
)
|
||||||
client = PronoteClient(settings, auth_state=auth_state)
|
client = PronoteClient(settings, auth_state=auth_state)
|
||||||
|
|
||||||
@@ -1194,15 +1515,109 @@ def test_no_raw_secrets_in_logs(
|
|||||||
assert sentinel_token not in message
|
assert sentinel_token not in message
|
||||||
assert sentinel_pin not in message
|
assert sentinel_pin not in message
|
||||||
assert sentinel_jeton not in message
|
assert sentinel_jeton not in message
|
||||||
|
assert sentinel_account_pin not in message
|
||||||
assert caplog.text
|
assert caplog.text
|
||||||
assert sentinel_token not in caplog.text
|
assert sentinel_token not in caplog.text
|
||||||
assert sentinel_pin not in caplog.text
|
assert sentinel_pin not in caplog.text
|
||||||
assert sentinel_jeton not in caplog.text
|
assert sentinel_jeton not in caplog.text
|
||||||
|
assert sentinel_account_pin not in caplog.text
|
||||||
|
assert exc_info.value.__cause__ is None
|
||||||
|
assert exc_info.value.__context__ is None
|
||||||
|
formatted_traceback = "".join(traceback.format_exception(exc_info.value))
|
||||||
|
assert sentinel_token not in formatted_traceback
|
||||||
|
assert sentinel_pin not in formatted_traceback
|
||||||
|
assert sentinel_jeton not in formatted_traceback
|
||||||
|
assert sentinel_account_pin not in formatted_traceback
|
||||||
|
|
||||||
|
|
||||||
# --- Persistence of credentials after data operations ---
|
# --- Persistence of credentials after data operations ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_qr_token_lock_covers_login_retrieval_and_credential_persistence(
|
||||||
|
mocker: pytest_mock.MockerFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie que le verrou d'état couvre tout le cycle QR/token des cours.
|
||||||
|
|
||||||
|
:param mocker: Fixture pytest-mock pour le mocking.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
events: list[str] = []
|
||||||
|
credentials = {
|
||||||
|
"pronote_url": "https://pronote.example.com",
|
||||||
|
"username": "testuser",
|
||||||
|
"password": "persisted-token", # pragma: allowlist secret
|
||||||
|
"uuid": "persisted-uuid",
|
||||||
|
}
|
||||||
|
auth_state = mocker.MagicMock(spec=PronoteAuthState)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def record_lock() -> Generator[None]:
|
||||||
|
events.append("lock_acquired")
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
events.append("lock_released")
|
||||||
|
|
||||||
|
def load_credentials() -> dict[str, str]:
|
||||||
|
"""Simule le chargement des credentials persistés."""
|
||||||
|
events.append("load")
|
||||||
|
return credentials
|
||||||
|
|
||||||
|
def get_no_lessons(*_: object) -> list[object]:
|
||||||
|
"""Simule une récupération de cours vide."""
|
||||||
|
events.append("lessons")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def export_credentials() -> dict[str, str]:
|
||||||
|
"""Simule l'export des credentials courantes."""
|
||||||
|
events.append("export")
|
||||||
|
return credentials
|
||||||
|
|
||||||
|
def token_login(**_: object) -> MagicMock:
|
||||||
|
"""Simule le login par token."""
|
||||||
|
events.append("token_login")
|
||||||
|
return mock_client
|
||||||
|
|
||||||
|
auth_state.lock.side_effect = record_lock
|
||||||
|
auth_state.load.side_effect = load_credentials
|
||||||
|
auth_state.save.side_effect = lambda _: events.append("save")
|
||||||
|
|
||||||
|
mock_client = _make_lessons_mock_client(mocker)
|
||||||
|
mock_client.logged_in = True
|
||||||
|
mock_client.lessons.side_effect = get_no_lessons
|
||||||
|
mock_client.export_credentials.side_effect = export_credentials
|
||||||
|
mocker.patch(
|
||||||
|
"pronotepy.ParentClient.token_login",
|
||||||
|
side_effect=token_login,
|
||||||
|
)
|
||||||
|
|
||||||
|
settings = PronoteSettings(
|
||||||
|
url="https://pronote.example.com",
|
||||||
|
username="testuser",
|
||||||
|
password=SecretStr("testpass"),
|
||||||
|
ent=None,
|
||||||
|
account_type="parent",
|
||||||
|
auth_mode="qr_token",
|
||||||
|
)
|
||||||
|
|
||||||
|
lessons = PronoteClient(settings, auth_state=auth_state).get_lessons(
|
||||||
|
date(2024, 9, 1), date(2024, 9, 30)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert lessons == []
|
||||||
|
assert events == [
|
||||||
|
"lock_acquired",
|
||||||
|
"load",
|
||||||
|
"token_login",
|
||||||
|
"export",
|
||||||
|
"save",
|
||||||
|
"lessons",
|
||||||
|
"export",
|
||||||
|
"save",
|
||||||
|
"lock_released",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _make_auth_state_mock(mocker: pytest_mock.MockerFixture) -> MagicMock:
|
def _make_auth_state_mock(mocker: pytest_mock.MockerFixture) -> MagicMock:
|
||||||
"""Retourne un mock de PronoteAuthState sans credentials persistés.
|
"""Retourne un mock de PronoteAuthState sans credentials persistés.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""Tests des règles communes de statut et de consolidation des cours."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pronote_sync.models.agenda import Lesson, LessonStatus
|
||||||
|
from pronote_sync.sources.pronote.lessons import collapse_replaced_lessons, lesson_status
|
||||||
|
|
||||||
|
|
||||||
|
def _lesson(status: LessonStatus, room: str) -> Lesson:
|
||||||
|
"""Construit un cours minimal pour les tests de consolidation.
|
||||||
|
|
||||||
|
:param status: Statut métier du cours.
|
||||||
|
:param room: Salle du cours.
|
||||||
|
:return: Cours de test.
|
||||||
|
:rtype: Lesson
|
||||||
|
"""
|
||||||
|
return Lesson(
|
||||||
|
id=f"lesson-{room}",
|
||||||
|
start=datetime(2026, 9, 11, 8, 0),
|
||||||
|
end=datetime(2026, 9, 11, 9, 0),
|
||||||
|
subject="Mathématiques",
|
||||||
|
teachers=("Enseignant A",),
|
||||||
|
rooms=(room,),
|
||||||
|
group="Classe entière",
|
||||||
|
status=status,
|
||||||
|
content=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("raw_status", "canceled", "expected"),
|
||||||
|
[
|
||||||
|
("Changement de salle", False, LessonStatus.MOVED),
|
||||||
|
("Cours déplacé", False, LessonStatus.MOVED),
|
||||||
|
("Cours annulé", False, LessonStatus.CANCELLED),
|
||||||
|
(None, True, LessonStatus.CANCELLED),
|
||||||
|
("Statut inconnu", False, LessonStatus.NORMAL),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_lesson_status_maps_pronotepy_variants(
|
||||||
|
raw_status: object, canceled: bool, expected: LessonStatus
|
||||||
|
) -> None:
|
||||||
|
"""Convertit les statuts Pronote connus sans interpréter les inconnus.
|
||||||
|
|
||||||
|
:param raw_status: Statut brut simulé.
|
||||||
|
:param canceled: Indicateur d'annulation simulé.
|
||||||
|
:param expected: Statut métier attendu.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
assert lesson_status(raw_status, canceled) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_collapse_replaced_lessons_keeps_effective_lesson() -> None:
|
||||||
|
"""Retire l'ancien exemplaire annulé d'un changement de salle.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
cancelled = _lesson(LessonStatus.CANCELLED, "101")
|
||||||
|
moved = _lesson(LessonStatus.MOVED, "204")
|
||||||
|
|
||||||
|
result = collapse_replaced_lessons([cancelled, moved])
|
||||||
|
|
||||||
|
assert result == [moved]
|
||||||
|
|
||||||
|
|
||||||
|
def test_collapse_replaced_lessons_keeps_real_cancellation() -> None:
|
||||||
|
"""Conserve une annulation lorsqu'aucun cours déplacé ne la remplace.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
cancelled = _lesson(LessonStatus.CANCELLED, "101")
|
||||||
|
|
||||||
|
assert collapse_replaced_lessons([cancelled]) == [cancelled]
|
||||||
|
|
||||||
|
|
||||||
|
def test_collapse_replaced_lessons_keeps_ambiguous_cancellations() -> None:
|
||||||
|
"""Conserve les annulations lorsqu'une paire de remplacement est ambiguë.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
cancelled_old = _lesson(LessonStatus.CANCELLED, "101")
|
||||||
|
cancelled_real = _lesson(LessonStatus.CANCELLED, "305")
|
||||||
|
moved = _lesson(LessonStatus.MOVED, "204")
|
||||||
|
|
||||||
|
result = collapse_replaced_lessons([cancelled_old, cancelled_real, moved])
|
||||||
|
|
||||||
|
assert result == [cancelled_old, cancelled_real, moved]
|
||||||
@@ -239,15 +239,16 @@ def test_homework_to_vevent_uid_prefix() -> None:
|
|||||||
assert str(event.get("UID")) == "homework-HW-5678"
|
assert str(event.get("UID")) == "homework-HW-5678"
|
||||||
|
|
||||||
|
|
||||||
def test_homework_to_vevent_status() -> None:
|
def test_homework_to_vevent_is_transparent_without_task_status() -> None:
|
||||||
"""Vérifie qu'un devoir a STATUS=NEEDS-ACTION.
|
"""Vérifie qu'un devoir VEVENT est transparent et sans statut VTODO.
|
||||||
|
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
homework = _make_homework()
|
homework = _make_homework()
|
||||||
event = homework_to_vevent(homework)
|
event = homework_to_vevent(homework)
|
||||||
|
|
||||||
assert str(event.get("STATUS")) == "NEEDS-ACTION"
|
assert event.get("STATUS") is None
|
||||||
|
assert str(event.get("TRANSP")) == "TRANSPARENT"
|
||||||
|
|
||||||
|
|
||||||
def test_homework_to_vevent_categories() -> None:
|
def test_homework_to_vevent_categories() -> None:
|
||||||
@@ -265,15 +266,15 @@ def test_homework_to_vevent_categories() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_homework_to_vevent_dtstart_dtend() -> None:
|
def test_homework_to_vevent_dtstart_dtend() -> None:
|
||||||
"""Vérifie que DTSTART et DTEND couvrent la journée d'échéance (08:00-18:00).
|
"""Vérifie que DTSTART et DTEND encadrent la journée d'échéance.
|
||||||
|
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
homework = _make_homework(due_on=date(2026, 1, 20))
|
homework = _make_homework(due_on=date(2026, 1, 20))
|
||||||
event = homework_to_vevent(homework)
|
event = homework_to_vevent(homework)
|
||||||
|
|
||||||
assert event.get("DTSTART").dt == datetime(2026, 1, 20, 8, 0)
|
assert event.get("DTSTART").dt == date(2026, 1, 20)
|
||||||
assert event.get("DTEND").dt == datetime(2026, 1, 20, 18, 0)
|
assert event.get("DTEND").dt == date(2026, 1, 21)
|
||||||
|
|
||||||
|
|
||||||
def test_homework_to_vevent_summary() -> None:
|
def test_homework_to_vevent_summary() -> None:
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ factory de sélection, en vérifiant :
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date, datetime, time
|
from datetime import date, datetime, time
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any, Literal
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -29,6 +29,7 @@ from pronote_sync.models.diff import AgendaChange, AgendaChangeType, AgendaDiff
|
|||||||
from pronote_sync.models.message import Message, MessageType
|
from pronote_sync.models.message import Message, MessageType
|
||||||
from pronote_sync.models.synthesis import SynthesisInput
|
from pronote_sync.models.synthesis import SynthesisInput
|
||||||
from pronote_sync.synthesis import get_synthesis_provider
|
from pronote_sync.synthesis import get_synthesis_provider
|
||||||
|
from pronote_sync.synthesis.litellm import LiteLLMSynthesisProvider
|
||||||
from pronote_sync.synthesis.openai import OpenAISynthesisProvider
|
from pronote_sync.synthesis.openai import OpenAISynthesisProvider
|
||||||
from pronote_sync.synthesis.provider import SynthesisProvider
|
from pronote_sync.synthesis.provider import SynthesisProvider
|
||||||
|
|
||||||
@@ -872,6 +873,70 @@ def test_openai_compatible_valid_https_url_accepted() -> None:
|
|||||||
assert isinstance(result, OpenAISynthesisProvider)
|
assert isinstance(result, OpenAISynthesisProvider)
|
||||||
|
|
||||||
|
|
||||||
|
def test_openai_compatible_url_returned_unchanged() -> None:
|
||||||
|
"""Vérifie que l'URL est retournée strictement inchangée, sans manipulation de /v1."""
|
||||||
|
custom_url = "https://api.example.com/custom/path?query=value"
|
||||||
|
settings = AISettings(
|
||||||
|
enabled=True,
|
||||||
|
api_key=SecretStr("test"),
|
||||||
|
provider="openai-compatible",
|
||||||
|
base_url=custom_url,
|
||||||
|
model="test-model",
|
||||||
|
)
|
||||||
|
result = get_synthesis_provider(settings)
|
||||||
|
assert isinstance(result, OpenAISynthesisProvider)
|
||||||
|
# OpenAI SDK appends a trailing slash to base_url, so we check the string representation
|
||||||
|
assert str(result._client.base_url).rstrip("/") == custom_url
|
||||||
|
|
||||||
|
|
||||||
|
def test_openai_url_returned_unchanged() -> None:
|
||||||
|
"""Vérifie que l'URL est retournée strictement inchangée pour openai."""
|
||||||
|
custom_url = "https://api.example.com/custom/path?query=value"
|
||||||
|
settings = AISettings(
|
||||||
|
enabled=True,
|
||||||
|
api_key=SecretStr("test"),
|
||||||
|
provider="openai",
|
||||||
|
base_url=custom_url,
|
||||||
|
)
|
||||||
|
result = get_synthesis_provider(settings)
|
||||||
|
assert isinstance(result, OpenAISynthesisProvider)
|
||||||
|
# OpenAI SDK appends a trailing slash to base_url, so we check the string representation
|
||||||
|
assert str(result._client.base_url).rstrip("/") == custom_url
|
||||||
|
|
||||||
|
|
||||||
|
def test_litellm_url_returned_unchanged() -> None:
|
||||||
|
"""Vérifie que l'URL est retournée strictement inchangée pour litellm."""
|
||||||
|
pytest.importorskip("litellm")
|
||||||
|
custom_url = "https://api.example.com/custom/path?query=value"
|
||||||
|
settings = AISettings(
|
||||||
|
enabled=True,
|
||||||
|
api_key=SecretStr("test"),
|
||||||
|
provider="litellm",
|
||||||
|
base_url=custom_url,
|
||||||
|
)
|
||||||
|
result = get_synthesis_provider(settings)
|
||||||
|
assert result is not None
|
||||||
|
assert isinstance(result, LiteLLMSynthesisProvider)
|
||||||
|
assert result._base_url == custom_url
|
||||||
|
|
||||||
|
|
||||||
|
def test_openai_compatible_malformed_port_refused(
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie qu'un port malformé est refusé pour openai-compatible."""
|
||||||
|
settings = AISettings(
|
||||||
|
enabled=True,
|
||||||
|
api_key=SecretStr("test"),
|
||||||
|
provider="openai-compatible",
|
||||||
|
base_url="https://host:bad/v1",
|
||||||
|
model="test-model",
|
||||||
|
)
|
||||||
|
result = get_synthesis_provider(settings)
|
||||||
|
assert result is None
|
||||||
|
assert "URL invalide" in caplog.text
|
||||||
|
assert "openai-compatible" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
def test_openai_compatible_http_refused_by_default(
|
def test_openai_compatible_http_refused_by_default(
|
||||||
caplog: pytest.LogCaptureFixture,
|
caplog: pytest.LogCaptureFixture,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -887,6 +952,7 @@ def test_openai_compatible_http_refused_by_default(
|
|||||||
result = get_synthesis_provider(settings)
|
result = get_synthesis_provider(settings)
|
||||||
assert result is None
|
assert result is None
|
||||||
assert "URL HTTP non autorisée sans AI_ALLOW_INSECURE_HTTP=true" in caplog.text
|
assert "URL HTTP non autorisée sans AI_ALLOW_INSECURE_HTTP=true" in caplog.text
|
||||||
|
assert "openai-compatible" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
def test_openai_compatible_http_accepted_with_allow_insecure_http() -> None:
|
def test_openai_compatible_http_accepted_with_allow_insecure_http() -> None:
|
||||||
@@ -917,6 +983,7 @@ def test_openai_compatible_credentials_in_url_refused(
|
|||||||
result = get_synthesis_provider(settings)
|
result = get_synthesis_provider(settings)
|
||||||
assert result is None
|
assert result is None
|
||||||
assert "Credentials dans l'URL refusés" in caplog.text
|
assert "Credentials dans l'URL refusés" in caplog.text
|
||||||
|
assert "openai-compatible" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
def test_openai_compatible_sensitive_query_params_refused(
|
def test_openai_compatible_sensitive_query_params_refused(
|
||||||
@@ -933,6 +1000,24 @@ def test_openai_compatible_sensitive_query_params_refused(
|
|||||||
result = get_synthesis_provider(settings)
|
result = get_synthesis_provider(settings)
|
||||||
assert result is None
|
assert result is None
|
||||||
assert "Paramètres sensibles dans l'URL refusés" in caplog.text
|
assert "Paramètres sensibles dans l'URL refusés" in caplog.text
|
||||||
|
assert "openai-compatible" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_openai_compatible_sensitive_query_params_valueless_refused(
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie que les query params sensibles sans valeur sont refusés pour openai-compatible."""
|
||||||
|
settings = AISettings(
|
||||||
|
enabled=True,
|
||||||
|
api_key=SecretStr("test"),
|
||||||
|
provider="openai-compatible",
|
||||||
|
base_url="https://host/v1?token",
|
||||||
|
model="test-model",
|
||||||
|
)
|
||||||
|
result = get_synthesis_provider(settings)
|
||||||
|
assert result is None
|
||||||
|
assert "Paramètres sensibles dans l'URL refusés" in caplog.text
|
||||||
|
assert "openai-compatible" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
def test_openai_compatible_connection_error_returns_none(
|
def test_openai_compatible_connection_error_returns_none(
|
||||||
@@ -991,3 +1076,383 @@ def test_openai_compatible_factory_no_network_calls(
|
|||||||
assert isinstance(result, OpenAISynthesisProvider)
|
assert isinstance(result, OpenAISynthesisProvider)
|
||||||
mock_get.assert_not_called()
|
mock_get.assert_not_called()
|
||||||
mock_post.assert_not_called()
|
mock_post.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Tests de validation AI_BASE_URL pour openai et litellm (Issue #17) ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_openai_valid_https_base_url_accepted() -> None:
|
||||||
|
"""Vérifie qu'une URL HTTPS valide est acceptée pour openai."""
|
||||||
|
settings = AISettings(
|
||||||
|
enabled=True,
|
||||||
|
api_key=SecretStr("test"),
|
||||||
|
provider="openai",
|
||||||
|
base_url="https://api.openai.com/v1",
|
||||||
|
)
|
||||||
|
result = get_synthesis_provider(settings)
|
||||||
|
assert isinstance(result, OpenAISynthesisProvider)
|
||||||
|
|
||||||
|
|
||||||
|
def test_openai_http_refused_by_default(
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie que HTTP est refusé par défaut pour openai."""
|
||||||
|
settings = AISettings(
|
||||||
|
enabled=True,
|
||||||
|
api_key=SecretStr("test"),
|
||||||
|
provider="openai",
|
||||||
|
base_url="http://127.0.0.1:11434/v1",
|
||||||
|
allow_insecure_http=False,
|
||||||
|
)
|
||||||
|
result = get_synthesis_provider(settings)
|
||||||
|
assert result is None
|
||||||
|
assert "URL HTTP non autorisée sans AI_ALLOW_INSECURE_HTTP=true" in caplog.text
|
||||||
|
assert "openai" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_openai_http_accepted_with_allow_insecure_http() -> None:
|
||||||
|
"""Vérifie que HTTP est accepté avec allow_insecure_http=True pour openai."""
|
||||||
|
settings = AISettings(
|
||||||
|
enabled=True,
|
||||||
|
api_key=SecretStr("test"),
|
||||||
|
provider="openai",
|
||||||
|
base_url="http://127.0.0.1:11434/v1",
|
||||||
|
allow_insecure_http=True,
|
||||||
|
)
|
||||||
|
result = get_synthesis_provider(settings)
|
||||||
|
assert isinstance(result, OpenAISynthesisProvider)
|
||||||
|
|
||||||
|
|
||||||
|
def test_openai_credentials_in_url_refused(
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie que les credentials dans l'URL sont refusés pour openai."""
|
||||||
|
settings = AISettings(
|
||||||
|
enabled=True,
|
||||||
|
api_key=SecretStr("test"),
|
||||||
|
provider="openai",
|
||||||
|
base_url="https://user:pass@host/v1", # pragma: allowlist secret
|
||||||
|
)
|
||||||
|
result = get_synthesis_provider(settings)
|
||||||
|
assert result is None
|
||||||
|
assert "Credentials dans l'URL refusés" in caplog.text
|
||||||
|
assert "openai" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_openai_sensitive_query_params_refused(
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie que les query params sensibles sont refusés pour openai."""
|
||||||
|
settings = AISettings(
|
||||||
|
enabled=True,
|
||||||
|
api_key=SecretStr("test"),
|
||||||
|
provider="openai",
|
||||||
|
base_url="https://host/v1?token=secret",
|
||||||
|
)
|
||||||
|
result = get_synthesis_provider(settings)
|
||||||
|
assert result is None
|
||||||
|
assert "Paramètres sensibles dans l'URL refusés" in caplog.text
|
||||||
|
assert "openai" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_openai_sensitive_query_params_valueless_refused(
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie que les query params sensibles sans valeur sont refusés pour openai."""
|
||||||
|
settings = AISettings(
|
||||||
|
enabled=True,
|
||||||
|
api_key=SecretStr("test"),
|
||||||
|
provider="openai",
|
||||||
|
base_url="https://host/v1?token",
|
||||||
|
)
|
||||||
|
result = get_synthesis_provider(settings)
|
||||||
|
assert result is None
|
||||||
|
assert "Paramètres sensibles dans l'URL refusés" in caplog.text
|
||||||
|
assert "openai" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_openai_malformed_url_refused(
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie qu'une URL malformée est refusée pour openai."""
|
||||||
|
settings = AISettings(
|
||||||
|
enabled=True,
|
||||||
|
api_key=SecretStr("test"),
|
||||||
|
provider="openai",
|
||||||
|
base_url="not-a-valid-url",
|
||||||
|
)
|
||||||
|
result = get_synthesis_provider(settings)
|
||||||
|
assert result is None
|
||||||
|
assert "URL invalide" in caplog.text
|
||||||
|
assert "openai" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_openai_no_hostname_url_refused(
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie qu'une URL sans hostname est refusée pour openai."""
|
||||||
|
settings = AISettings(
|
||||||
|
enabled=True,
|
||||||
|
api_key=SecretStr("test"),
|
||||||
|
provider="openai",
|
||||||
|
base_url="https:///v1",
|
||||||
|
)
|
||||||
|
result = get_synthesis_provider(settings)
|
||||||
|
assert result is None
|
||||||
|
assert "URL sans hostname" in caplog.text
|
||||||
|
assert "openai" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_openai_malformed_port_refused(
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie qu'un port malformé est refusé pour openai."""
|
||||||
|
settings = AISettings(
|
||||||
|
enabled=True,
|
||||||
|
api_key=SecretStr("test"),
|
||||||
|
provider="openai",
|
||||||
|
base_url="https://host:bad/v1",
|
||||||
|
)
|
||||||
|
result = get_synthesis_provider(settings)
|
||||||
|
assert result is None
|
||||||
|
assert "URL invalide" in caplog.text
|
||||||
|
assert "openai" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_openai_no_network_calls_during_validation(
|
||||||
|
mocker: MockerFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie qu'aucun appel réseau n'est effectué pendant la validation pour openai."""
|
||||||
|
mock_get = mocker.patch("requests.get")
|
||||||
|
mock_post = mocker.patch("requests.post")
|
||||||
|
|
||||||
|
settings = AISettings(
|
||||||
|
enabled=True,
|
||||||
|
api_key=SecretStr("test"),
|
||||||
|
provider="openai",
|
||||||
|
base_url="https://api.example.com/v1",
|
||||||
|
)
|
||||||
|
result = get_synthesis_provider(settings)
|
||||||
|
assert isinstance(result, OpenAISynthesisProvider)
|
||||||
|
mock_get.assert_not_called()
|
||||||
|
mock_post.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_openai_sentinel_key_not_in_logs(
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie qu'une clé sentinelle est absente des logs pour openai."""
|
||||||
|
sentinel = "sk-SENTINEL-OPENAI-BASE-URL-12345"
|
||||||
|
settings = AISettings(
|
||||||
|
enabled=True,
|
||||||
|
api_key=SecretStr(sentinel),
|
||||||
|
provider="openai",
|
||||||
|
base_url="https://user:pass@host/v1", # pragma: allowlist secret
|
||||||
|
)
|
||||||
|
result = get_synthesis_provider(settings)
|
||||||
|
assert result is None
|
||||||
|
assert sentinel not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_litellm_valid_https_base_url_accepted() -> None:
|
||||||
|
"""Vérifie qu'une URL HTTPS valide est acceptée pour litellm."""
|
||||||
|
pytest.importorskip("litellm")
|
||||||
|
settings = AISettings(
|
||||||
|
enabled=True,
|
||||||
|
api_key=SecretStr("test"),
|
||||||
|
provider="litellm",
|
||||||
|
base_url="https://api.litellm.ai/v1",
|
||||||
|
)
|
||||||
|
result = get_synthesis_provider(settings)
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_litellm_http_refused_by_default(
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie que HTTP est refusé par défaut pour litellm."""
|
||||||
|
pytest.importorskip("litellm")
|
||||||
|
settings = AISettings(
|
||||||
|
enabled=True,
|
||||||
|
api_key=SecretStr("test"),
|
||||||
|
provider="litellm",
|
||||||
|
base_url="http://127.0.0.1:11434/v1",
|
||||||
|
allow_insecure_http=False,
|
||||||
|
)
|
||||||
|
result = get_synthesis_provider(settings)
|
||||||
|
assert result is None
|
||||||
|
assert "URL HTTP non autorisée sans AI_ALLOW_INSECURE_HTTP=true" in caplog.text
|
||||||
|
assert "litellm" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_litellm_http_accepted_with_allow_insecure_http() -> None:
|
||||||
|
"""Vérifie que HTTP est accepté avec allow_insecure_http=True pour litellm."""
|
||||||
|
pytest.importorskip("litellm")
|
||||||
|
settings = AISettings(
|
||||||
|
enabled=True,
|
||||||
|
api_key=SecretStr("test"),
|
||||||
|
provider="litellm",
|
||||||
|
base_url="http://127.0.0.1:11434/v1",
|
||||||
|
allow_insecure_http=True,
|
||||||
|
)
|
||||||
|
result = get_synthesis_provider(settings)
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_litellm_credentials_in_url_refused(
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie que les credentials dans l'URL sont refusés pour litellm."""
|
||||||
|
pytest.importorskip("litellm")
|
||||||
|
settings = AISettings(
|
||||||
|
enabled=True,
|
||||||
|
api_key=SecretStr("test"),
|
||||||
|
provider="litellm",
|
||||||
|
base_url="https://user:pass@host/v1", # pragma: allowlist secret
|
||||||
|
)
|
||||||
|
result = get_synthesis_provider(settings)
|
||||||
|
assert result is None
|
||||||
|
assert "Credentials dans l'URL refusés" in caplog.text
|
||||||
|
assert "litellm" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_litellm_sensitive_query_params_refused(
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie que les query params sensibles sont refusés pour litellm."""
|
||||||
|
pytest.importorskip("litellm")
|
||||||
|
settings = AISettings(
|
||||||
|
enabled=True,
|
||||||
|
api_key=SecretStr("test"),
|
||||||
|
provider="litellm",
|
||||||
|
base_url="https://host/v1?token=secret",
|
||||||
|
)
|
||||||
|
result = get_synthesis_provider(settings)
|
||||||
|
assert result is None
|
||||||
|
assert "Paramètres sensibles dans l'URL refusés" in caplog.text
|
||||||
|
assert "litellm" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_litellm_sensitive_query_params_valueless_refused(
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie que les query params sensibles sans valeur sont refusés pour litellm."""
|
||||||
|
pytest.importorskip("litellm")
|
||||||
|
settings = AISettings(
|
||||||
|
enabled=True,
|
||||||
|
api_key=SecretStr("test"),
|
||||||
|
provider="litellm",
|
||||||
|
base_url="https://host/v1?token",
|
||||||
|
)
|
||||||
|
result = get_synthesis_provider(settings)
|
||||||
|
assert result is None
|
||||||
|
assert "Paramètres sensibles dans l'URL refusés" in caplog.text
|
||||||
|
assert "litellm" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_litellm_malformed_url_refused(
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie qu'une URL malformée est refusée pour litellm."""
|
||||||
|
pytest.importorskip("litellm")
|
||||||
|
settings = AISettings(
|
||||||
|
enabled=True,
|
||||||
|
api_key=SecretStr("test"),
|
||||||
|
provider="litellm",
|
||||||
|
base_url="not-a-valid-url",
|
||||||
|
)
|
||||||
|
result = get_synthesis_provider(settings)
|
||||||
|
assert result is None
|
||||||
|
assert "URL invalide" in caplog.text
|
||||||
|
assert "litellm" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_litellm_no_hostname_url_refused(
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie qu'une URL sans hostname est refusée pour litellm."""
|
||||||
|
pytest.importorskip("litellm")
|
||||||
|
settings = AISettings(
|
||||||
|
enabled=True,
|
||||||
|
api_key=SecretStr("test"),
|
||||||
|
provider="litellm",
|
||||||
|
base_url="https:///v1",
|
||||||
|
)
|
||||||
|
result = get_synthesis_provider(settings)
|
||||||
|
assert result is None
|
||||||
|
assert "URL sans hostname" in caplog.text
|
||||||
|
assert "litellm" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_litellm_malformed_port_refused(
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie qu'un port malformé est refusé pour litellm."""
|
||||||
|
pytest.importorskip("litellm")
|
||||||
|
settings = AISettings(
|
||||||
|
enabled=True,
|
||||||
|
api_key=SecretStr("test"),
|
||||||
|
provider="litellm",
|
||||||
|
base_url="https://host:bad/v1",
|
||||||
|
)
|
||||||
|
result = get_synthesis_provider(settings)
|
||||||
|
assert result is None
|
||||||
|
assert "URL invalide" in caplog.text
|
||||||
|
assert "litellm" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_litellm_no_network_calls_during_validation(
|
||||||
|
mocker: MockerFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie qu'aucun appel réseau n'est effectué pendant la validation pour litellm."""
|
||||||
|
pytest.importorskip("litellm")
|
||||||
|
mock_get = mocker.patch("requests.get")
|
||||||
|
mock_post = mocker.patch("requests.post")
|
||||||
|
|
||||||
|
settings = AISettings(
|
||||||
|
enabled=True,
|
||||||
|
api_key=SecretStr("test"),
|
||||||
|
provider="litellm",
|
||||||
|
base_url="https://api.example.com/v1",
|
||||||
|
)
|
||||||
|
result = get_synthesis_provider(settings)
|
||||||
|
assert result is not None
|
||||||
|
mock_get.assert_not_called()
|
||||||
|
mock_post.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_litellm_sentinel_key_not_in_logs(
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie qu'une clé sentinelle est absente des logs pour litellm."""
|
||||||
|
pytest.importorskip("litellm")
|
||||||
|
sentinel = "sk-SENTINEL-LITELLM-BASE-URL-67890"
|
||||||
|
settings = AISettings(
|
||||||
|
enabled=True,
|
||||||
|
api_key=SecretStr(sentinel),
|
||||||
|
provider="litellm",
|
||||||
|
base_url="https://user:pass@host/v1", # pragma: allowlist secret
|
||||||
|
)
|
||||||
|
result = get_synthesis_provider(settings)
|
||||||
|
assert result is None
|
||||||
|
assert sentinel not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("provider", ["openai", "litellm", "openai-compatible"])
|
||||||
|
def test_all_providers_http_refused_same_warning(
|
||||||
|
provider: Literal["openai", "litellm", "openai-compatible"], caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie que tous les providers émettent le même message d'avertissement pour HTTP refusé."""
|
||||||
|
if provider == "litellm":
|
||||||
|
pytest.importorskip("litellm")
|
||||||
|
settings = AISettings(
|
||||||
|
enabled=True,
|
||||||
|
api_key=SecretStr("test"),
|
||||||
|
provider=provider,
|
||||||
|
base_url="http://127.0.0.1:11434/v1",
|
||||||
|
allow_insecure_http=False,
|
||||||
|
model="test-model" if provider == "openai-compatible" else None,
|
||||||
|
)
|
||||||
|
result = get_synthesis_provider(settings)
|
||||||
|
assert result is None
|
||||||
|
assert "URL HTTP non autorisée sans AI_ALLOW_INSECURE_HTTP=true" in caplog.text
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from unittest.mock import patch
|
|||||||
import pytest
|
import pytest
|
||||||
from pydantic import SecretStr
|
from pydantic import SecretStr
|
||||||
|
|
||||||
from pronote_sync.channels.xmpp import XmppChannel, XmppMessage
|
from pronote_sync.channels.xmpp import SyncXmppChannel, XmppChannel, XmppMessage
|
||||||
from pronote_sync.config.settings import XmppSettings
|
from pronote_sync.config.settings import XmppSettings
|
||||||
from pronote_sync.models.agenda import Lesson, TheoreticalLesson
|
from pronote_sync.models.agenda import Lesson, TheoreticalLesson
|
||||||
from pronote_sync.models.blog import BlogArticle, ExternalInfo
|
from pronote_sync.models.blog import BlogArticle, ExternalInfo
|
||||||
@@ -48,11 +48,15 @@ class FakeClientXMPP:
|
|||||||
self._should_disconnect_early = False
|
self._should_disconnect_early = False
|
||||||
self._host_used: str | None = None
|
self._host_used: str | None = None
|
||||||
self._port_used: int | None = None
|
self._port_used: int | None = None
|
||||||
|
# New modes for realistic failure simulation
|
||||||
|
self._connect_mode: str = "ok" # "ok", "pending", "connection_failed"
|
||||||
|
self._disconnect_mode: str = "ok" # "ok", "pending"
|
||||||
|
self._fire_connection_failed: bool = False
|
||||||
|
|
||||||
def add_event_handler(
|
def add_event_handler(
|
||||||
self, name: str, pointer: Callable[..., object], disposable: bool = False
|
self, name: str, pointer: Callable[..., object], disposable: bool = False
|
||||||
) -> None:
|
) -> None:
|
||||||
if name not in ("session_start", "failed_auth", "disconnected"):
|
if name not in ("session_start", "failed_auth", "connection_failed", "disconnected"):
|
||||||
raise AssertionError(f"Unsupported event: {name}")
|
raise AssertionError(f"Unsupported event: {name}")
|
||||||
self.handlers.setdefault(name, []).append(pointer)
|
self.handlers.setdefault(name, []).append(pointer)
|
||||||
|
|
||||||
@@ -63,11 +67,25 @@ class FakeClientXMPP:
|
|||||||
self.connected = True
|
self.connected = True
|
||||||
self._host_used = host
|
self._host_used = host
|
||||||
self._port_used = port
|
self._port_used = port
|
||||||
|
|
||||||
|
if self._connect_mode == "pending":
|
||||||
|
# Never resolves - simulates connection timeout
|
||||||
|
return future
|
||||||
|
elif self._connect_mode == "connection_failed":
|
||||||
|
# Resolves connect future but fires connection_failed event
|
||||||
|
loop.call_soon(self._fire_connection_failed_event)
|
||||||
|
future.set_result(True)
|
||||||
|
return future
|
||||||
|
else:
|
||||||
# Schedule event handlers to fire after connect returns
|
# Schedule event handlers to fire after connect returns
|
||||||
loop.call_soon(self._fire_events)
|
loop.call_soon(self._fire_events)
|
||||||
future.set_result(True)
|
future.set_result(True)
|
||||||
return future
|
return future
|
||||||
|
|
||||||
|
def _fire_connection_failed_event(self) -> None:
|
||||||
|
"""Fire connection_failed event for testing."""
|
||||||
|
self._fire("connection_failed")
|
||||||
|
|
||||||
def _fire_events(self) -> None:
|
def _fire_events(self) -> None:
|
||||||
if self._should_disconnect_early:
|
if self._should_disconnect_early:
|
||||||
self._fire("disconnected")
|
self._fire("disconnected")
|
||||||
@@ -86,6 +104,11 @@ class FakeClientXMPP:
|
|||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
future: asyncio.Future[bool] = loop.create_future()
|
future: asyncio.Future[bool] = loop.create_future()
|
||||||
self.disconnected = True
|
self.disconnected = True
|
||||||
|
|
||||||
|
if self._disconnect_mode == "pending":
|
||||||
|
# Never resolves - simulates cleanup timeout
|
||||||
|
return future
|
||||||
|
else:
|
||||||
future.set_result(True)
|
future.set_result(True)
|
||||||
return future
|
return future
|
||||||
|
|
||||||
@@ -681,6 +704,339 @@ class TestXmppChannelSend:
|
|||||||
jid_arg = call_args.args[0]
|
jid_arg = call_args.args[0]
|
||||||
assert jid_arg == "bot@example.com/myresource"
|
assert jid_arg == "bot@example.com/myresource"
|
||||||
|
|
||||||
|
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_connect_timeout_returns_false(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||||
|
"""Test que connect_timeout retourne False quand connect() ne résout pas.
|
||||||
|
|
||||||
|
:param caplog: Fixture pytest pour capturer les logs.
|
||||||
|
"""
|
||||||
|
settings = XmppSettings(
|
||||||
|
enabled=True,
|
||||||
|
jid="bot@example.com",
|
||||||
|
password=SecretStr("secret123"),
|
||||||
|
host="xmpp.example.com",
|
||||||
|
port=5222,
|
||||||
|
to="parent@example.com",
|
||||||
|
resource="pronote-sync",
|
||||||
|
tls_mode="starttls",
|
||||||
|
connect_timeout=0.05,
|
||||||
|
timeout=30,
|
||||||
|
cleanup_timeout=0.01,
|
||||||
|
)
|
||||||
|
|
||||||
|
class PendingConnectClient(FakeClientXMPP):
|
||||||
|
def __init__(self, jid: str, password: str) -> None:
|
||||||
|
super().__init__(jid, password)
|
||||||
|
self._connect_mode = "pending"
|
||||||
|
|
||||||
|
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=PendingConnectClient):
|
||||||
|
channel = XmppChannel(settings, dry_run=False)
|
||||||
|
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
|
||||||
|
result = await channel.send_async(msg)
|
||||||
|
assert result is False
|
||||||
|
# Vérifier que le log contient "connexion"
|
||||||
|
logs = caplog.text
|
||||||
|
assert "connexion" in logs.lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cancelled_error_cancels_pending_tasks(self) -> None:
|
||||||
|
"""Test que l'annulation de send_async annule les tâches encore en attente.
|
||||||
|
|
||||||
|
Un ``connect()`` qui ne résout jamais et une annulation de la tâche
|
||||||
|
appelante doivent entraîner la cancellation de la future de connexion
|
||||||
|
(idempotence de ``_cancel_pending`` sur les chemins d'exception).
|
||||||
|
"""
|
||||||
|
settings = XmppSettings(
|
||||||
|
enabled=True,
|
||||||
|
jid="bot@example.com",
|
||||||
|
password=SecretStr("secret123"),
|
||||||
|
host="xmpp.example.com",
|
||||||
|
port=5222,
|
||||||
|
to="parent@example.com",
|
||||||
|
resource="pronote-sync",
|
||||||
|
tls_mode="starttls",
|
||||||
|
connect_timeout=15,
|
||||||
|
timeout=30,
|
||||||
|
cleanup_timeout=0.01,
|
||||||
|
)
|
||||||
|
|
||||||
|
class PendingConnectClient(FakeClientXMPP):
|
||||||
|
"""Client dont ``connect()`` retourne une future jamais résolue."""
|
||||||
|
|
||||||
|
def __init__(self, jid: str, password: str) -> None:
|
||||||
|
super().__init__(jid, password)
|
||||||
|
self._connect_mode = "pending"
|
||||||
|
self.connect_future: asyncio.Future[bool] | None = None
|
||||||
|
|
||||||
|
def connect(
|
||||||
|
self, host: str | None = None, port: int | None = None
|
||||||
|
) -> asyncio.Future[bool]:
|
||||||
|
future = super().connect(host, port)
|
||||||
|
self.connect_future = future
|
||||||
|
return future
|
||||||
|
|
||||||
|
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=PendingConnectClient):
|
||||||
|
channel = XmppChannel(settings, dry_run=False)
|
||||||
|
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
|
||||||
|
client = PendingConnectClient("bot@example.com", "secret123")
|
||||||
|
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=lambda j, p: client):
|
||||||
|
task = asyncio.ensure_future(channel.send_async(msg))
|
||||||
|
# Laisse asyncio.wait démarrer et la future de connexion rester en attente
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
task.cancel()
|
||||||
|
result = await task
|
||||||
|
assert result is False
|
||||||
|
assert client.connect_future is not None
|
||||||
|
assert client.connect_future.cancelled()
|
||||||
|
|
||||||
|
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_connection_failed_event_returns_false(
|
||||||
|
self, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""Test que connection_failed event retourne False rapidement.
|
||||||
|
|
||||||
|
:param caplog: Fixture pytest pour capturer les logs.
|
||||||
|
"""
|
||||||
|
settings = XmppSettings(
|
||||||
|
enabled=True,
|
||||||
|
jid="bot@example.com",
|
||||||
|
password=SecretStr("secret123"),
|
||||||
|
host="xmpp.example.com",
|
||||||
|
port=5222,
|
||||||
|
to="parent@example.com",
|
||||||
|
resource="pronote-sync",
|
||||||
|
tls_mode="starttls",
|
||||||
|
connect_timeout=15,
|
||||||
|
timeout=30,
|
||||||
|
cleanup_timeout=0.01,
|
||||||
|
)
|
||||||
|
|
||||||
|
class ConnectionFailedClient(FakeClientXMPP):
|
||||||
|
def __init__(self, jid: str, password: str) -> None:
|
||||||
|
super().__init__(jid, password)
|
||||||
|
self._connect_mode = "connection_failed"
|
||||||
|
|
||||||
|
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=ConnectionFailedClient):
|
||||||
|
channel = XmppChannel(settings, dry_run=False)
|
||||||
|
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
|
||||||
|
result = await channel.send_async(msg)
|
||||||
|
assert result is False
|
||||||
|
# Vérifier que le log contient une mention d'échec réseau
|
||||||
|
logs = caplog.text
|
||||||
|
assert "réseau" in logs.lower() or "connexion" in logs.lower()
|
||||||
|
|
||||||
|
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cleanup_timeout_does_not_hang(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||||
|
"""Test que cleanup_timeout ne bloque pas quand disconnect() ne résout pas.
|
||||||
|
|
||||||
|
:param caplog: Fixture pytest pour capturer les logs.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
caplog.set_level(logging.DEBUG)
|
||||||
|
|
||||||
|
settings = XmppSettings(
|
||||||
|
enabled=True,
|
||||||
|
jid="bot@example.com",
|
||||||
|
password=SecretStr("secret123"),
|
||||||
|
host="xmpp.example.com",
|
||||||
|
port=5222,
|
||||||
|
to="parent@example.com",
|
||||||
|
resource="pronote-sync",
|
||||||
|
tls_mode="starttls",
|
||||||
|
connect_timeout=0.05,
|
||||||
|
timeout=30,
|
||||||
|
cleanup_timeout=0.01,
|
||||||
|
)
|
||||||
|
|
||||||
|
class HangingDisconnectClient(FakeClientXMPP):
|
||||||
|
def __init__(self, jid: str, password: str) -> None:
|
||||||
|
super().__init__(jid, password)
|
||||||
|
self._disconnect_mode = "pending"
|
||||||
|
|
||||||
|
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=HangingDisconnectClient):
|
||||||
|
channel = XmppChannel(settings, dry_run=False)
|
||||||
|
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
|
||||||
|
# Should complete quickly despite hanging disconnect
|
||||||
|
result = await asyncio.wait_for(channel.send_async(msg), timeout=0.5)
|
||||||
|
assert result is True
|
||||||
|
# Vérifier que le log contient une mention de timeout de nettoyage
|
||||||
|
logs = caplog.text
|
||||||
|
assert "déconnexion" in logs.lower()
|
||||||
|
|
||||||
|
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tls_mode_direct_config(self) -> None:
|
||||||
|
"""Test que tls_mode='direct' configure enable_direct_tls=True et enable_starttls=False.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
settings = XmppSettings(
|
||||||
|
enabled=True,
|
||||||
|
jid="bot@example.com",
|
||||||
|
password=SecretStr("secret123"),
|
||||||
|
host="xmpp.example.com",
|
||||||
|
port=5222,
|
||||||
|
to="parent@example.com",
|
||||||
|
resource="pronote-sync",
|
||||||
|
tls_mode="direct",
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
class InspectClient(FakeClientXMPP):
|
||||||
|
def __init__(self, jid: str, password: str) -> None:
|
||||||
|
super().__init__(jid, password)
|
||||||
|
|
||||||
|
with patch("pronote_sync.channels.xmpp.ClientXMPP") as mock_cls:
|
||||||
|
mock_cls.return_value = InspectClient("bot@example.com", "secret123")
|
||||||
|
channel = XmppChannel(settings, dry_run=False)
|
||||||
|
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
|
||||||
|
await channel.send_async(msg)
|
||||||
|
client_instance = mock_cls.return_value
|
||||||
|
assert client_instance.enable_direct_tls is True
|
||||||
|
assert client_instance.enable_starttls is False
|
||||||
|
|
||||||
|
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tls_mode_starttls_config(self) -> None:
|
||||||
|
"""Test que tls_mode='starttls' configure enable_starttls=True et enable_direct_tls=False.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
settings = XmppSettings(
|
||||||
|
enabled=True,
|
||||||
|
jid="bot@example.com",
|
||||||
|
password=SecretStr("secret123"),
|
||||||
|
host="xmpp.example.com",
|
||||||
|
port=5222,
|
||||||
|
to="parent@example.com",
|
||||||
|
resource="pronote-sync",
|
||||||
|
tls_mode="starttls",
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
class InspectClient(FakeClientXMPP):
|
||||||
|
def __init__(self, jid: str, password: str) -> None:
|
||||||
|
super().__init__(jid, password)
|
||||||
|
|
||||||
|
with patch("pronote_sync.channels.xmpp.ClientXMPP") as mock_cls:
|
||||||
|
mock_cls.return_value = InspectClient("bot@example.com", "secret123")
|
||||||
|
channel = XmppChannel(settings, dry_run=False)
|
||||||
|
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
|
||||||
|
await channel.send_async(msg)
|
||||||
|
client_instance = mock_cls.return_value
|
||||||
|
assert client_instance.enable_starttls is True
|
||||||
|
assert client_instance.enable_direct_tls is False
|
||||||
|
|
||||||
|
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tls_mode_disabled_config(self) -> None:
|
||||||
|
"""Test que tls_mode='disabled' avec host='127.0.0.1' configure TLS désactivé.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
settings = XmppSettings(
|
||||||
|
enabled=True,
|
||||||
|
jid="bot@example.com",
|
||||||
|
password=SecretStr("secret123"),
|
||||||
|
host="127.0.0.1",
|
||||||
|
port=5222,
|
||||||
|
to="parent@example.com",
|
||||||
|
resource="pronote-sync",
|
||||||
|
tls_mode="disabled",
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
class InspectClient(FakeClientXMPP):
|
||||||
|
def __init__(self, jid: str, password: str) -> None:
|
||||||
|
super().__init__(jid, password)
|
||||||
|
|
||||||
|
with patch("pronote_sync.channels.xmpp.ClientXMPP") as mock_cls:
|
||||||
|
mock_cls.return_value = InspectClient("bot@example.com", "secret123")
|
||||||
|
channel = XmppChannel(settings, dry_run=False)
|
||||||
|
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
|
||||||
|
await channel.send_async(msg)
|
||||||
|
client_instance = mock_cls.return_value
|
||||||
|
assert client_instance.enable_direct_tls is False
|
||||||
|
assert client_instance.enable_starttls is False
|
||||||
|
|
||||||
|
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_secret_leak_in_connection_failure_log(
|
||||||
|
self, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""Test qu'aucun secret ne fuit dans les logs en cas d'échec de connexion.
|
||||||
|
|
||||||
|
:param caplog: Fixture pytest pour capturer les logs.
|
||||||
|
"""
|
||||||
|
sentinel_password = "SECRET_PASSWORD_XMPP_12345" # pragma: allowlist secret
|
||||||
|
settings = XmppSettings(
|
||||||
|
enabled=True,
|
||||||
|
jid="bot@example.com",
|
||||||
|
password=SecretStr(sentinel_password),
|
||||||
|
host="xmpp.example.com",
|
||||||
|
port=5222,
|
||||||
|
to="parent@example.com",
|
||||||
|
resource="pronote-sync",
|
||||||
|
tls_mode="starttls",
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
class ConnectionFailedClient(FakeClientXMPP):
|
||||||
|
def __init__(self, jid: str, password: str) -> None:
|
||||||
|
super().__init__(jid, password)
|
||||||
|
self._connect_mode = "connection_failed"
|
||||||
|
|
||||||
|
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=ConnectionFailedClient):
|
||||||
|
channel = XmppChannel(settings, dry_run=False)
|
||||||
|
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
|
||||||
|
await channel.send_async(msg)
|
||||||
|
|
||||||
|
# Vérifier que le mot de passe sentinelle n'apparaît pas dans les logs
|
||||||
|
logs = caplog.text
|
||||||
|
assert sentinel_password not in logs
|
||||||
|
|
||||||
|
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_secret_leak_in_connect_timeout_log(
|
||||||
|
self, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""Test qu'aucun secret ne fuit dans les logs en cas de timeout de connexion.
|
||||||
|
|
||||||
|
:param caplog: Fixture pytest pour capturer les logs.
|
||||||
|
"""
|
||||||
|
sentinel_password = "SECRET_PASSWORD_XMPP_12345"
|
||||||
|
settings = XmppSettings(
|
||||||
|
enabled=True,
|
||||||
|
jid="bot@example.com",
|
||||||
|
password=SecretStr(sentinel_password),
|
||||||
|
host="xmpp.example.com",
|
||||||
|
port=5222,
|
||||||
|
to="parent@example.com",
|
||||||
|
resource="pronote-sync",
|
||||||
|
tls_mode="starttls",
|
||||||
|
connect_timeout=0.05,
|
||||||
|
timeout=30,
|
||||||
|
cleanup_timeout=0.01,
|
||||||
|
)
|
||||||
|
|
||||||
|
class PendingConnectClient(FakeClientXMPP):
|
||||||
|
def __init__(self, jid: str, password: str) -> None:
|
||||||
|
super().__init__(jid, password)
|
||||||
|
self._connect_mode = "pending"
|
||||||
|
|
||||||
|
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=PendingConnectClient):
|
||||||
|
channel = XmppChannel(settings, dry_run=False)
|
||||||
|
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
|
||||||
|
await channel.send_async(msg)
|
||||||
|
|
||||||
|
# Vérifier que le mot de passe sentinelle n'apparaît pas dans les logs
|
||||||
|
logs = caplog.text
|
||||||
|
assert sentinel_password not in logs
|
||||||
|
|
||||||
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
|
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_async_tls_direct_config(self) -> None:
|
async def test_send_async_tls_direct_config(self) -> None:
|
||||||
@@ -816,6 +1172,328 @@ class TestXmppChannelSend:
|
|||||||
result = await channel.send_async(msg)
|
result = await channel.send_async(msg)
|
||||||
assert result is True
|
assert result is True
|
||||||
|
|
||||||
|
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_async_connect_raises_exception_returns_false(self) -> None:
|
||||||
|
"""Test que connect() levant une exception retourne False.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
settings = XmppSettings(
|
||||||
|
enabled=True,
|
||||||
|
jid="bot@example.com",
|
||||||
|
password=SecretStr("secret123"),
|
||||||
|
host="xmpp.example.com",
|
||||||
|
port=5222,
|
||||||
|
to="parent@example.com",
|
||||||
|
resource="pronote-sync",
|
||||||
|
tls_mode="starttls",
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
class ConnectExceptionClient(FakeClientXMPP):
|
||||||
|
def connect(
|
||||||
|
self, host: str | None = None, port: int | None = None
|
||||||
|
) -> asyncio.Future[bool]:
|
||||||
|
raise ConnectionError("Network unreachable")
|
||||||
|
|
||||||
|
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=ConnectExceptionClient):
|
||||||
|
channel = XmppChannel(settings, dry_run=False)
|
||||||
|
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
|
||||||
|
result = await channel.send_async(msg)
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_async_connect_future_error_returns_false(
|
||||||
|
self, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""Test que la future de connect() résolue avec une exception retourne False.
|
||||||
|
|
||||||
|
Le message d'erreur doit être expurgé et ne pas contenir les secrets.
|
||||||
|
|
||||||
|
:param caplog: Fixture pytest pour capturer les logs.
|
||||||
|
"""
|
||||||
|
sentinel_password = "SECRET_PASSWORD_XMPP_CONNECT_FUT" # pragma: allowlist secret
|
||||||
|
settings = XmppSettings(
|
||||||
|
enabled=True,
|
||||||
|
jid="bot@example.com",
|
||||||
|
password=SecretStr(sentinel_password),
|
||||||
|
host="xmpp.example.com",
|
||||||
|
port=5222,
|
||||||
|
to="parent@example.com",
|
||||||
|
resource="pronote-sync",
|
||||||
|
tls_mode="starttls",
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
class ConnectFutureErrorClient(FakeClientXMPP):
|
||||||
|
def connect(
|
||||||
|
self, host: str | None = None, port: int | None = None
|
||||||
|
) -> asyncio.Future[bool]:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
future: asyncio.Future[bool] = loop.create_future()
|
||||||
|
future.set_exception(ConnectionError("Network unreachable"))
|
||||||
|
return future
|
||||||
|
|
||||||
|
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=ConnectFutureErrorClient):
|
||||||
|
channel = XmppChannel(settings, dry_run=False)
|
||||||
|
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
|
||||||
|
result = await channel.send_async(msg)
|
||||||
|
assert result is False
|
||||||
|
assert "Échec de connexion XMPP" in caplog.text
|
||||||
|
assert sentinel_password not in caplog.text
|
||||||
|
|
||||||
|
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_async_session_failure_after_connect_returns_false(
|
||||||
|
self, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""Test qu'un échec de session après connexion résolue retourne False.
|
||||||
|
|
||||||
|
Le connect future est résolu avec succès, puis l'événement
|
||||||
|
``failed_auth`` arrive pendant l'attente de session.
|
||||||
|
|
||||||
|
:param caplog: Fixture pytest pour capturer les logs.
|
||||||
|
"""
|
||||||
|
settings = XmppSettings(
|
||||||
|
enabled=True,
|
||||||
|
jid="bot@example.com",
|
||||||
|
password=SecretStr("secret123"),
|
||||||
|
host="xmpp.example.com",
|
||||||
|
port=5222,
|
||||||
|
to="parent@example.com",
|
||||||
|
resource="pronote-sync",
|
||||||
|
tls_mode="starttls",
|
||||||
|
timeout=5,
|
||||||
|
cleanup_timeout=0.01,
|
||||||
|
)
|
||||||
|
|
||||||
|
class LateAuthFailClient(FakeClientXMPP):
|
||||||
|
def connect(
|
||||||
|
self, host: str | None = None, port: int | None = None
|
||||||
|
) -> asyncio.Future[bool]:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
future: asyncio.Future[bool] = loop.create_future()
|
||||||
|
self.connected = True
|
||||||
|
self._host_used = host
|
||||||
|
self._port_used = port
|
||||||
|
loop.call_later(0.01, lambda: self._fire("failed_auth"))
|
||||||
|
future.set_result(True)
|
||||||
|
return future
|
||||||
|
|
||||||
|
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=LateAuthFailClient):
|
||||||
|
channel = XmppChannel(settings, dry_run=False)
|
||||||
|
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
|
||||||
|
result = await channel.send_async(msg)
|
||||||
|
assert result is False
|
||||||
|
assert "authentification" in caplog.text.lower()
|
||||||
|
|
||||||
|
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_async_cancelled_returns_false(
|
||||||
|
self, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""Test que l'annulation de la tâche retourne False sans lever.
|
||||||
|
|
||||||
|
Le contrat du canal impose un retour booléen : ``CancelledError`` doit
|
||||||
|
être interceptée et convertie en ``False``.
|
||||||
|
|
||||||
|
:param caplog: Fixture pytest pour capturer les logs.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
caplog.set_level(logging.DEBUG)
|
||||||
|
settings = XmppSettings(
|
||||||
|
enabled=True,
|
||||||
|
jid="bot@example.com",
|
||||||
|
password=SecretStr("secret123"),
|
||||||
|
host="xmpp.example.com",
|
||||||
|
port=5222,
|
||||||
|
to="parent@example.com",
|
||||||
|
resource="pronote-sync",
|
||||||
|
tls_mode="starttls",
|
||||||
|
connect_timeout=15,
|
||||||
|
timeout=30,
|
||||||
|
cleanup_timeout=0.01,
|
||||||
|
)
|
||||||
|
|
||||||
|
class PendingConnectClient(FakeClientXMPP):
|
||||||
|
def __init__(self, jid: str, password: str) -> None:
|
||||||
|
super().__init__(jid, password)
|
||||||
|
self._connect_mode = "pending"
|
||||||
|
|
||||||
|
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=PendingConnectClient):
|
||||||
|
channel = XmppChannel(settings, dry_run=False)
|
||||||
|
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
|
||||||
|
task = asyncio.ensure_future(channel.send_async(msg))
|
||||||
|
await asyncio.sleep(0.001)
|
||||||
|
task.cancel()
|
||||||
|
result = await asyncio.wait_for(task, timeout=1.0)
|
||||||
|
assert result is False
|
||||||
|
assert "annulé" in caplog.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
class TestSecretValues:
|
||||||
|
"""Tests pour la fonction _secret_values."""
|
||||||
|
|
||||||
|
def test_secret_values_with_all_secrets(self) -> None:
|
||||||
|
"""Test que _secret_values retourne tous les secrets.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
from pronote_sync.channels.xmpp import _secret_values
|
||||||
|
|
||||||
|
settings = XmppSettings(
|
||||||
|
jid="bot@example.com",
|
||||||
|
password=SecretStr("secret123"),
|
||||||
|
to="parent@example.com",
|
||||||
|
)
|
||||||
|
secrets = _secret_values(settings)
|
||||||
|
assert len(secrets) == 3
|
||||||
|
assert "bot@example.com" in secrets
|
||||||
|
assert settings.password in secrets
|
||||||
|
assert "parent@example.com" in secrets
|
||||||
|
|
||||||
|
def test_secret_values_with_none_values(self) -> None:
|
||||||
|
"""Test que _secret_values filtre les valeurs None.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
from pronote_sync.channels.xmpp import _secret_values
|
||||||
|
|
||||||
|
settings = XmppSettings(
|
||||||
|
jid=None,
|
||||||
|
password=None,
|
||||||
|
to=None,
|
||||||
|
)
|
||||||
|
secrets = _secret_values(settings)
|
||||||
|
assert len(secrets) == 0
|
||||||
|
|
||||||
|
def test_secret_values_with_some_none(self) -> None:
|
||||||
|
"""Test que _secret_values gère les valeurs partiellement None.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
from pronote_sync.channels.xmpp import _secret_values
|
||||||
|
|
||||||
|
settings = XmppSettings(
|
||||||
|
jid="bot@example.com",
|
||||||
|
password=None,
|
||||||
|
to="parent@example.com",
|
||||||
|
)
|
||||||
|
secrets = _secret_values(settings)
|
||||||
|
assert len(secrets) == 2
|
||||||
|
assert "bot@example.com" in secrets
|
||||||
|
assert "parent@example.com" in secrets
|
||||||
|
|
||||||
|
|
||||||
|
class TestSyncXmppChannel:
|
||||||
|
"""Tests unitaires pour la classe SyncXmppChannel."""
|
||||||
|
|
||||||
|
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
|
||||||
|
def test_sync_send_dry_run_returns_true(self) -> None:
|
||||||
|
"""Test que SyncXmppChannel en dry_run retourne True.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
settings = XmppSettings(
|
||||||
|
enabled=True,
|
||||||
|
jid="bot@example.com",
|
||||||
|
password=SecretStr("secret123"),
|
||||||
|
host="xmpp.example.com",
|
||||||
|
port=5222,
|
||||||
|
to="parent@example.com",
|
||||||
|
resource="pronote-sync",
|
||||||
|
use_tls=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
channel = SyncXmppChannel(settings, dry_run=True)
|
||||||
|
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
|
||||||
|
result = channel.send(msg)
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
|
||||||
|
def test_sync_send_success_returns_true(self) -> None:
|
||||||
|
"""Test que SyncXmppChannel.send retourne True en cas de succès.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
settings = XmppSettings(
|
||||||
|
enabled=True,
|
||||||
|
jid="bot@example.com",
|
||||||
|
password=SecretStr("secret123"),
|
||||||
|
host="xmpp.example.com",
|
||||||
|
port=5222,
|
||||||
|
to="parent@example.com",
|
||||||
|
resource="pronote-sync",
|
||||||
|
use_tls=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
channel = SyncXmppChannel(settings, dry_run=False)
|
||||||
|
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
|
||||||
|
result = channel.send(msg)
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
|
||||||
|
def test_sync_send_exception_returns_false(self) -> None:
|
||||||
|
"""Test que SyncXmppChannel.send retourne False en cas d'exception.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
settings = XmppSettings(
|
||||||
|
enabled=True,
|
||||||
|
jid="bot@example.com",
|
||||||
|
password=SecretStr("secret123"),
|
||||||
|
host="xmpp.example.com",
|
||||||
|
port=5222,
|
||||||
|
to="parent@example.com",
|
||||||
|
resource="pronote-sync",
|
||||||
|
use_tls=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
class ErrorClient(FakeClientXMPP):
|
||||||
|
def connect(
|
||||||
|
self, host: str | None = None, port: int | None = None
|
||||||
|
) -> asyncio.Future[bool]:
|
||||||
|
raise RuntimeError("Connection failed")
|
||||||
|
|
||||||
|
with patch("pronote_sync.channels.xmpp.ClientXMPP", new=ErrorClient):
|
||||||
|
channel = SyncXmppChannel(settings, dry_run=False)
|
||||||
|
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
|
||||||
|
result = channel.send(msg)
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
@patch("pronote_sync.channels.xmpp.ClientXMPP", new=FakeClientXMPP)
|
||||||
|
def test_sync_send_asyncio_run_error_returns_false(
|
||||||
|
self, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""Test que send retourne False si asyncio.run lève une exception.
|
||||||
|
|
||||||
|
:param caplog: Fixture pytest pour capturer les logs.
|
||||||
|
"""
|
||||||
|
settings = XmppSettings(
|
||||||
|
enabled=True,
|
||||||
|
jid="bot@example.com",
|
||||||
|
password=SecretStr("secret123"),
|
||||||
|
host="xmpp.example.com",
|
||||||
|
port=5222,
|
||||||
|
to="parent@example.com",
|
||||||
|
resource="pronote-sync",
|
||||||
|
use_tls=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
channel = SyncXmppChannel(settings, dry_run=False)
|
||||||
|
msg = XmppMessage(target_date=date(2025, 9, 7), synthesis=None, external_info=None)
|
||||||
|
with patch(
|
||||||
|
"pronote_sync.channels.xmpp.asyncio.run",
|
||||||
|
side_effect=RuntimeError("Boucle événementielle indisponible"),
|
||||||
|
):
|
||||||
|
result = channel.send(msg)
|
||||||
|
assert result is False
|
||||||
|
assert "erreur" in caplog.text.lower()
|
||||||
|
|
||||||
|
|
||||||
class TestXmppChannelSecurity:
|
class TestXmppChannelSecurity:
|
||||||
"""Tests de sécurité pour XmppChannel (non-fuite de secrets).
|
"""Tests de sécurité pour XmppChannel (non-fuite de secrets).
|
||||||
|
|||||||
@@ -99,19 +99,52 @@ class TestTimeoutConstraints:
|
|||||||
class TestTlsPolicy:
|
class TestTlsPolicy:
|
||||||
"""Tests de la politique TLS pour le champ use_tls."""
|
"""Tests de la politique TLS pour le champ use_tls."""
|
||||||
|
|
||||||
def test_use_tls_false_with_remote_host_rejected(self) -> None:
|
def test_tls_disabled_with_remote_host_rejected(self) -> None:
|
||||||
"""Vérifie que use_tls=False avec un hôte distant est rejeté.
|
"""Vérifie que tls_mode="disabled" avec un hôte distant est rejeté.
|
||||||
|
|
||||||
:raises ValidationError: Si use_tls=False et host n'est pas une boucle locale.
|
:raises ValidationError: Si tls_mode="disabled" et host n'est pas une boucle locale.
|
||||||
"""
|
"""
|
||||||
with pytest.raises(ValidationError) as exc_info:
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
XmppSettings(use_tls=False, host="talk.example.com")
|
XmppSettings(tls_mode="disabled", host="talk.example.com")
|
||||||
assert "use_tls" in str(exc_info.value).lower() or "tls" in str(exc_info.value).lower()
|
assert "tls" in str(exc_info.value).lower()
|
||||||
|
|
||||||
def test_use_tls_false_with_remote_host_rejected_when_enabled(self) -> None:
|
def test_tls_disabled_with_remote_host_rejected_when_enabled(self) -> None:
|
||||||
"""Vérifie que la politique TLS s'applique même quand le canal est activé."""
|
"""Vérifie que la politique TLS s'applique même quand le canal est activé."""
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
XmppSettings(enabled=True, use_tls=False, host="talk.example.com")
|
XmppSettings(enabled=True, tls_mode="disabled", host="talk.example.com")
|
||||||
|
|
||||||
|
def test_use_tls_false_maps_to_starttls(self) -> None:
|
||||||
|
"""Vérifie que l'alias obsolète use_tls=False mappe vers tls_mode='starttls'.
|
||||||
|
|
||||||
|
:return: Vérifie le mapping et l'émission d'un DeprecationWarning.
|
||||||
|
:rtype: None
|
||||||
|
"""
|
||||||
|
with pytest.warns(DeprecationWarning):
|
||||||
|
settings = XmppSettings(use_tls=False, host="talk.example.com")
|
||||||
|
assert settings.tls_mode == "starttls"
|
||||||
|
assert settings.host == "talk.example.com"
|
||||||
|
|
||||||
|
def test_use_tls_true_maps_to_direct(self) -> None:
|
||||||
|
"""Vérifie que l'alias obsolète use_tls=True mappe vers tls_mode='direct'.
|
||||||
|
|
||||||
|
:return: Vérifie le mapping et l'émission d'un DeprecationWarning.
|
||||||
|
:rtype: None
|
||||||
|
"""
|
||||||
|
with pytest.warns(DeprecationWarning):
|
||||||
|
settings = XmppSettings(use_tls=True, host="talk.example.com")
|
||||||
|
assert settings.tls_mode == "direct"
|
||||||
|
assert settings.host == "talk.example.com"
|
||||||
|
|
||||||
|
def test_default_tls_mode_is_starttls(self) -> None:
|
||||||
|
"""Vérifie que tls_mode vaut 'starttls' par défaut avec les timeouts par défaut.
|
||||||
|
|
||||||
|
:return: Vérifie tls_mode='starttls', connect_timeout=15, cleanup_timeout=10.
|
||||||
|
:rtype: None
|
||||||
|
"""
|
||||||
|
settings = XmppSettings()
|
||||||
|
assert settings.tls_mode == "starttls"
|
||||||
|
assert settings.connect_timeout == 15
|
||||||
|
assert settings.cleanup_timeout == 10
|
||||||
|
|
||||||
def test_use_tls_false_with_localhost_allowed(self) -> None:
|
def test_use_tls_false_with_localhost_allowed(self) -> None:
|
||||||
"""Vérifie que use_tls=False avec localhost est autorisé.
|
"""Vérifie que use_tls=False avec localhost est autorisé.
|
||||||
@@ -120,7 +153,7 @@ class TestTlsPolicy:
|
|||||||
:rtype: None
|
:rtype: None
|
||||||
"""
|
"""
|
||||||
settings = XmppSettings(use_tls=False, host="localhost")
|
settings = XmppSettings(use_tls=False, host="localhost")
|
||||||
assert settings.use_tls is False
|
assert settings.tls_mode == "starttls"
|
||||||
assert settings.host == "localhost"
|
assert settings.host == "localhost"
|
||||||
|
|
||||||
def test_use_tls_false_with_127_allowed(self) -> None:
|
def test_use_tls_false_with_127_allowed(self) -> None:
|
||||||
@@ -130,7 +163,7 @@ class TestTlsPolicy:
|
|||||||
:rtype: None
|
:rtype: None
|
||||||
"""
|
"""
|
||||||
settings = XmppSettings(use_tls=False, host="127.0.0.1")
|
settings = XmppSettings(use_tls=False, host="127.0.0.1")
|
||||||
assert settings.use_tls is False
|
assert settings.tls_mode == "starttls"
|
||||||
assert settings.host == "127.0.0.1"
|
assert settings.host == "127.0.0.1"
|
||||||
|
|
||||||
def test_use_tls_false_with_ipv6_loopback_allowed(self) -> None:
|
def test_use_tls_false_with_ipv6_loopback_allowed(self) -> None:
|
||||||
@@ -140,7 +173,7 @@ class TestTlsPolicy:
|
|||||||
:rtype: None
|
:rtype: None
|
||||||
"""
|
"""
|
||||||
settings = XmppSettings(use_tls=False, host="::1")
|
settings = XmppSettings(use_tls=False, host="::1")
|
||||||
assert settings.use_tls is False
|
assert settings.tls_mode == "starttls"
|
||||||
assert settings.host == "::1"
|
assert settings.host == "::1"
|
||||||
|
|
||||||
def test_use_tls_true_with_remote_host_allowed(self) -> None:
|
def test_use_tls_true_with_remote_host_allowed(self) -> None:
|
||||||
@@ -150,7 +183,7 @@ class TestTlsPolicy:
|
|||||||
:rtype: None
|
:rtype: None
|
||||||
"""
|
"""
|
||||||
settings = XmppSettings(use_tls=True, host="talk.example.com")
|
settings = XmppSettings(use_tls=True, host="talk.example.com")
|
||||||
assert settings.use_tls is True
|
assert settings.tls_mode == "direct"
|
||||||
assert settings.host == "talk.example.com"
|
assert settings.host == "talk.example.com"
|
||||||
|
|
||||||
def test_use_tls_true_with_empty_host_allowed(self) -> None:
|
def test_use_tls_true_with_empty_host_allowed(self) -> None:
|
||||||
@@ -160,7 +193,7 @@ class TestTlsPolicy:
|
|||||||
:rtype: None
|
:rtype: None
|
||||||
"""
|
"""
|
||||||
settings = XmppSettings(use_tls=True, host="")
|
settings = XmppSettings(use_tls=True, host="")
|
||||||
assert settings.use_tls is True
|
assert settings.tls_mode == "direct"
|
||||||
assert settings.host == ""
|
assert settings.host == ""
|
||||||
|
|
||||||
|
|
||||||
@@ -173,7 +206,7 @@ class TestNoSecretInErrorMessages:
|
|||||||
Crée une instance avec des valeurs sensibles et vérifie que l'erreur de validation
|
Crée une instance avec des valeurs sensibles et vérifie que l'erreur de validation
|
||||||
ne contient pas ces valeurs dans son message.
|
ne contient pas ces valeurs dans son message.
|
||||||
|
|
||||||
:raises ValidationError: Si use_tls=False avec un hôte non-local.
|
:raises ValidationError: Si tls_mode="disabled" avec un hôte non-local.
|
||||||
:return: Vérifie que le message d'erreur ne contient pas les secrets.
|
:return: Vérifie que le message d'erreur ne contient pas les secrets.
|
||||||
:rtype: None
|
:rtype: None
|
||||||
"""
|
"""
|
||||||
@@ -184,7 +217,7 @@ class TestNoSecretInErrorMessages:
|
|||||||
|
|
||||||
with pytest.raises(ValidationError) as exc_info:
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
XmppSettings(
|
XmppSettings(
|
||||||
use_tls=False,
|
tls_mode="disabled",
|
||||||
host="talk.example.com",
|
host="talk.example.com",
|
||||||
jid=sentinel_jid,
|
jid=sentinel_jid,
|
||||||
password=sentinel_password,
|
password=sentinel_password,
|
||||||
@@ -197,3 +230,72 @@ class TestNoSecretInErrorMessages:
|
|||||||
assert "test_password_123" not in error_message
|
assert "test_password_123" not in error_message
|
||||||
assert "test_to@example.com" not in error_message
|
assert "test_to@example.com" not in error_message
|
||||||
assert "secret" not in error_message
|
assert "secret" not in error_message
|
||||||
|
|
||||||
|
|
||||||
|
class TestXmppSettingsTlsMode:
|
||||||
|
"""Tests supplémentaires pour les validateurs de XmppSettings."""
|
||||||
|
|
||||||
|
def test_use_tls_true_maps_to_direct_with_warning(self) -> None:
|
||||||
|
"""Vérifie que use_tls=True mappe vers tls_mode='direct' avec DeprecationWarning.
|
||||||
|
|
||||||
|
:return: Vérifie le mapping et l'émission d'un DeprecationWarning.
|
||||||
|
:rtype: None
|
||||||
|
"""
|
||||||
|
with pytest.warns(DeprecationWarning):
|
||||||
|
settings = XmppSettings(use_tls=True, host="talk.example.com")
|
||||||
|
assert settings.tls_mode == "direct"
|
||||||
|
|
||||||
|
def test_use_tls_false_maps_to_starttls_with_warning(self) -> None:
|
||||||
|
"""Vérifie que use_tls=False mappe vers tls_mode='starttls' avec DeprecationWarning.
|
||||||
|
|
||||||
|
:return: Vérifie le mapping et l'émission d'un DeprecationWarning.
|
||||||
|
:rtype: None
|
||||||
|
"""
|
||||||
|
with pytest.warns(DeprecationWarning):
|
||||||
|
settings = XmppSettings(use_tls=False, host="talk.example.com")
|
||||||
|
assert settings.tls_mode == "starttls"
|
||||||
|
|
||||||
|
def test_tls_mode_disabled_rejected_for_remote(self) -> None:
|
||||||
|
"""Vérifie que tls_mode='disabled' avec un hôte distant est rejeté.
|
||||||
|
|
||||||
|
:raises ValidationError: Si tls_mode="disabled" et host n'est pas une boucle locale.
|
||||||
|
"""
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
XmppSettings(tls_mode="disabled", host="example.com")
|
||||||
|
assert "tls" in str(exc_info.value).lower()
|
||||||
|
|
||||||
|
def test_tls_mode_disabled_allowed_for_loopback(self) -> None:
|
||||||
|
"""Vérifie que tls_mode='disabled' avec host='127.0.0.1' est valide.
|
||||||
|
|
||||||
|
:return: Vérifie que XmppSettings(tls_mode='disabled', host='127.0.0.1') est valide.
|
||||||
|
:rtype: None
|
||||||
|
"""
|
||||||
|
settings = XmppSettings(tls_mode="disabled", host="127.0.0.1")
|
||||||
|
assert settings.tls_mode == "disabled"
|
||||||
|
assert settings.host == "127.0.0.1"
|
||||||
|
|
||||||
|
def test_connect_timeout_must_be_positive(self) -> None:
|
||||||
|
"""Vérifie que connect_timeout doit être strictement positif.
|
||||||
|
|
||||||
|
:raises ValidationError: Si connect_timeout <= 0.
|
||||||
|
"""
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
XmppSettings(connect_timeout=0)
|
||||||
|
assert "connect_timeout" in str(exc_info.value).lower()
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
XmppSettings(connect_timeout=-1)
|
||||||
|
assert "connect_timeout" in str(exc_info.value).lower()
|
||||||
|
|
||||||
|
def test_cleanup_timeout_must_be_positive(self) -> None:
|
||||||
|
"""Vérifie que cleanup_timeout doit être strictement positif.
|
||||||
|
|
||||||
|
:raises ValidationError: Si cleanup_timeout <= 0.
|
||||||
|
"""
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
XmppSettings(cleanup_timeout=0)
|
||||||
|
assert "cleanup_timeout" in str(exc_info.value).lower()
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
XmppSettings(cleanup_timeout=-1)
|
||||||
|
assert "cleanup_timeout" in str(exc_info.value).lower()
|
||||||
|
|||||||
Reference in New Issue
Block a user