Compare commits
12 Commits
m13-tests-
...
docs/agent
| Author | SHA1 | Date | |
|---|---|---|---|
|
de647a6ff8
|
|||
| 82b9877aad | |||
| c851f67172 | |||
|
4ac5be4c8d
|
|||
|
fdd3310462
|
|||
|
0be02a660a
|
|||
|
d85733116a
|
|||
|
2deeb83c76
|
|||
|
b518508632
|
|||
|
b474f02e90
|
|||
|
e6e4b10047
|
|||
|
d60357a017
|
2
.gitignore
vendored
2
.gitignore
vendored
@@ -52,7 +52,9 @@ Thumbs.db
|
|||||||
|
|
||||||
# --- Local scratch / WIP files ---
|
# --- Local scratch / WIP files ---
|
||||||
FIXME_*
|
FIXME_*
|
||||||
|
FEAT_*
|
||||||
TEST_*
|
TEST_*
|
||||||
|
HANDOFF.md
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
|
||||||
# --- Logs ---
|
# --- Logs ---
|
||||||
|
|||||||
44
AGENTS.md
44
AGENTS.md
@@ -326,3 +326,47 @@ Un changement est considéré comme **terminé** lorsque :
|
|||||||
- Le *handoff* distingue clairement :
|
- Le *handoff* distingue clairement :
|
||||||
- Ce qui a été vérifié localement (ex. : tests unitaires, linter).
|
- Ce qui a été vérifié localement (ex. : tests unitaires, linter).
|
||||||
- Ce qui nécessite encore une vérification manuelle (ex. : tests d'intégration avec un serveur CalDAV réel).
|
- Ce qui nécessite encore une vérification manuelle (ex. : tests d'intégration avec un serveur CalDAV réel).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Versionnage et releases
|
||||||
|
|
||||||
|
### Politique de versionnage
|
||||||
|
|
||||||
|
Le projet suit **Semantic Versioning** (semver.org v2.0.0). Phase actuelle : `0.x` (pré-`1.0.0`).
|
||||||
|
|
||||||
|
| Changement | Incrément |
|
||||||
|
|------------|----------|
|
||||||
|
| Défaut constaté au déploiement | Patch (`0.1.Z`) — correction rétrocompatible |
|
||||||
|
| Ajout ou cassure en phase `0.x` | Minor (`0.Y.0`) |
|
||||||
|
| Déploiement réel validé | `1.0.0` |
|
||||||
|
|
||||||
|
### Règle absolue de validation
|
||||||
|
|
||||||
|
**Aucune montée de version (tag + release) ne peut être effectuée
|
||||||
|
sans validation préalable en environnement réel.** Les tests automatisés et la revue de code
|
||||||
|
ne suffisent pas ; le correctif ou la fonctionnalité doit avoir été testé avec succès
|
||||||
|
sur le serveur de production (ou un environnement équivalent) avant de tagger.
|
||||||
|
|
||||||
|
### Procédure de release
|
||||||
|
|
||||||
|
1. **Valider en environnement réel** : le correctif ou la fonctionnalité est testé
|
||||||
|
sur le serveur de production.
|
||||||
|
2. **Mettre à jour `pyproject.toml`** : incrémenter le champ `version` à la nouvelle version.
|
||||||
|
3. **Mettre à jour `CHANGELOG.md`** : ajouter une entrée sous le format
|
||||||
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) avec la nouvelle version et la date.
|
||||||
|
4. **Committer** : un commit `chore: monter en version x.y.z` regroupe
|
||||||
|
les mises à jour de `pyproject.toml` et `CHANGELOG.md`.
|
||||||
|
5. **Tagger** : créer un tag annoté `vx.y.z` sur le commit de version.
|
||||||
|
6. **Pousser le tag** : `git push origin vx.y.z`.
|
||||||
|
7. **Créer la release** sur Gitea avec le changelog correspondant.
|
||||||
|
|
||||||
|
### Cohérence des versions
|
||||||
|
|
||||||
|
Les trois sources de version doivent toujours être synchronisées au moment d'un tag :
|
||||||
|
- Le tag Git (`vx.y.z`)
|
||||||
|
- `pyproject.toml` (`version = "x.y.z"`)
|
||||||
|
- `CHANGELOG.md` (`## [x.y.z] - YYYY-MM-DD`)
|
||||||
|
|
||||||
|
> **Rappel** : Ne jamais créer un tag sans avoir d'abord mis à jour
|
||||||
|
> `pyproject.toml` et `CHANGELOG.md`.
|
||||||
|
|||||||
28
CHANGELOG.md
Normal file
28
CHANGELOG.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-09-08
|
||||||
|
|
||||||
|
Initial release covering milestones M1 through M15.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **M1 (Scaffolding)**: Python project structure with `pyproject.toml`, and tooling configuration for `ruff`, `mypy`, `bandit`, and `pre-commit`.
|
||||||
|
- **M2 (Configuration & secrets)**: Pydantic Settings for configuration management, `SecretStr` for sensitive fields, and redaction utilities (`redact_url`, `redact_secrets`, `redact_exception`) with `RedactingFormatter` for logging.
|
||||||
|
- **M3 (Data models)**: 16 Pydantic models and 6 enums across 10 modules, including frozen contracts and mutable work results.
|
||||||
|
- **M4 (Pronote sources)**: iCal fetch and parse, `pronotepy.ParentClient` integration, automatic fallback logic for `auto`, `ical`, and `pronotepy` modes, and error redaction for sensitive data.
|
||||||
|
- **M5 (Blog RSS)**: `feedparser`-based RSS client with GUID deduplication, HTTP cache support (ETag/If-Modified-Since), and `BlogRSSState` persistence.
|
||||||
|
- **M6 (Theoretical agenda)**: JSON provider with week parity (even/odd), school holidays calendar, and deterministic IDs for events.
|
||||||
|
- **M7 (CalDAV sync)**: Differential synchronization by UID, `X-PRONOTE-SYNC-MANAGED` marker for managed events, idempotent operations, preserved cancelled events, and dry-run support.
|
||||||
|
- **M8 (Agenda diff)**: `AgendaComparator` with deterministic matching, and generation of `AgendaDiff`/`AgendaChange` objects for tracking differences.
|
||||||
|
- **M9 (AI synthesis)**: `SynthesisProvider` protocol, OpenAI provider, optional `litellm` provider, and `openai-compatible` provider with degraded mode (returns `None` on failure).
|
||||||
|
- **M10 (XMPP channel)**: `XmppChannel` using `slixmpp`, formatted messages (synthesis, homeworks, changes, messages, blog), and error handling that returns `False` on failure.
|
||||||
|
- **M11 (Pipeline orchestration)**: `PipelineRunner` as composition root, 7 pipeline steps, degraded error handling, dry-run mode, and iCal reuse within a single run.
|
||||||
|
- **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.
|
||||||
|
- **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).
|
||||||
|
- **Other**: MIT License. Gitea Actions CI/CD reference for LXC/VPS (Debian/CentOS) is planned and optional, not delivered in this release.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Guide de Développement : Synchronisation Pronote → CalDAV + XMPP (Python)
|
# Guide de Développement : Synchronisation Pronote → CalDAV + XMPP (Python)
|
||||||
|
|
||||||
> **Statut** : Guide de référence pour un futur projet Python inspiré de [`pronote-digest`](https://github.com/antoine-coulon/pronote-digest) (TypeScript).
|
> **Statut** : Guide de référence pour un futur projet Python inspiré de [`pronote-digest`](https://github.com/yoanbernabeu/pronote-digest) (TypeScript) par [Yoan Bernabeu](https://yoanbernabeu.github.io/pronote-digest/).
|
||||||
> **Public cible** : Développeurs Python (≥ 3.13.5) familiers avec les concepts de CLI, synchronisation de calendriers et messagerie instantanée.
|
> **Public cible** : Développeurs Python (≥ 3.13.5) familiers avec les concepts de CLI, synchronisation de calendriers et messagerie instantanée.
|
||||||
> **Objectif** : Fournir une base architecturale et technique pour un outil **synchronisant l'agenda Pronote vers CalDAV**, **comparant avec un agenda théorique**, **récupérant messages et informations**, et **envoyant une synthèse par XMPP**.
|
> **Objectif** : Fournir une base architecturale et technique pour un outil **synchronisant l'agenda Pronote vers CalDAV**, **comparant avec un agenda théorique**, **récupérant messages et informations**, et **envoyant une synthèse par XMPP**.
|
||||||
|
|
||||||
@@ -6090,7 +6090,7 @@ Ce guide fournit une **base architecturale et technique solide** pour développe
|
|||||||
1. **Créer le dépôt** : Initialiser un nouveau dépôt Python avec la structure proposée.
|
1. **Créer le dépôt** : Initialiser un nouveau dépôt Python avec la structure proposée.
|
||||||
2. **Implémenter le cœur** : Commencer par les modules `models/`, `sources/pronote/ical.py` et `utils/`.
|
2. **Implémenter le cœur** : Commencer par les modules `models/`, `sources/pronote/ical.py` et `utils/`.
|
||||||
3. **Ajouter les tests** : Écrire des tests unitaires pour chaque module dès le début.
|
3. **Ajouter les tests** : Écrire des tests unitaires pour chaque module dès le début.
|
||||||
4. **Configurer CI/CD** : Mettre en place GitHub Actions pour exécuter les tests et vérifier la sécurité.
|
4. **Configurer Gitea Actions** : Mettre en place Gitea Actions pour exécuter les tests et vérifier la sécurité, en vue d'un déploiement sur LXC/VPS (Debian/CentOS).
|
||||||
5. **Tester en conditions réelles** : Utiliser des flux iCal Pronote anonymisés pour valider le parsing.
|
5. **Tester en conditions réelles** : Utiliser des flux iCal Pronote anonymisés pour valider le parsing.
|
||||||
|
|
||||||
> **⚠️ Rappel** : Ce guide est **volontairement détaillé** pour préserver les connaissances acquises sur les spécificités de Pronote. Certaines sections (ex: parsing iCal) contiennent des **observations précises** issues de l'analyse du code TypeScript existant. **Ne pas sous-estimer l'importance de ces détails** : ils sont critiques pour un fonctionnement fiable du projet.
|
> **⚠️ Rappel** : Ce guide est **volontairement détaillé** pour préserver les connaissances acquises sur les spécificités de Pronote. Certaines sections (ex: parsing iCal) contiennent des **observations précises** issues de l'analyse du code TypeScript existant. **Ne pas sous-estimer l'importance de ces détails** : ils sont critiques pour un fonctionnement fiable du projet.
|
||||||
|
|||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Antoine Van Elstraete
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
147
README.LLM.md
Normal file
147
README.LLM.md
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# pronote-sync — AI Agent Setup Guide
|
||||||
|
|
||||||
|
This document guides an AI agent through installing and pre-configuring the `pronote-sync` project on a fresh Linux host (Debian/CentOS). It covers environment setup, dependency installation, and configuration file preparation. It does **NOT** cover secrets provisioning — those must be provided by the operator.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Python ≥ 3.13.5 (check with `python3 --version`)
|
||||||
|
- Git
|
||||||
|
- A non-root service user (e.g., `pronote-sync`)
|
||||||
|
- Target paths:
|
||||||
|
- `/opt/pronote-sync` (code)
|
||||||
|
- `/var/lib/pronote-sync` (state)
|
||||||
|
- `/var/log/pronote-sync` (logs)
|
||||||
|
- `/etc/pronote-sync` (config)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation Steps
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create service user
|
||||||
|
sudo useradd --system --no-create-home --shell /usr/sbin/nologin pronote-sync
|
||||||
|
|
||||||
|
# Clone the repository
|
||||||
|
sudo git clone <repo-url> /opt/pronote-sync
|
||||||
|
sudo chown -R pronote-sync:pronote-sync /opt/pronote-sync
|
||||||
|
|
||||||
|
# Create virtual environment
|
||||||
|
cd /opt/pronote-sync
|
||||||
|
sudo -u pronote-sync python3.13 -m venv .venv
|
||||||
|
sudo -u pronote-sync .venv/bin/pip install -e ".[dev]"
|
||||||
|
|
||||||
|
# Create directories
|
||||||
|
sudo install -d -m 0700 -o pronote-sync -g pronote-sync /etc/pronote-sync
|
||||||
|
sudo install -d -m 0750 -o pronote-sync -g pronote-sync /var/lib/pronote-sync
|
||||||
|
sudo install -d -m 0750 -o pronote-sync -g pronote-sync /var/log/pronote-sync
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration Preparation (Without Secrets)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy the example config
|
||||||
|
sudo -u pronote-sync cp /opt/pronote-sync/.env.example /etc/pronote-sync/pronote-sync.env
|
||||||
|
|
||||||
|
# The operator must fill in secrets (PRONOTE_PASSWORD, CALDAV_PASSWORD, XMPP_PASSWORD, AI_API_KEY, etc.)
|
||||||
|
# Do NOT populate secrets automatically — leave them for the operator.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Non-Secret Environment Variables (Pre-Configurable)
|
||||||
|
|
||||||
|
The following variables can be safely pre-configured in `/etc/pronote-sync/pronote-sync.env`:
|
||||||
|
|
||||||
|
- **Pronote:**
|
||||||
|
- `PRONOTE_ACCOUNT_TYPE` (default: `parent`)
|
||||||
|
- `PRONOTE_ENT` (ENT slug, e.g., `lyceeconnecte`)
|
||||||
|
- `PRONOTE_AGENDA_SOURCE`, `PRONOTE_HOMEWORK_SOURCE`, `PRONOTE_MESSAGES_SOURCE` (`auto`, `ical`, or `pronotepy`)
|
||||||
|
|
||||||
|
- **CalDAV:**
|
||||||
|
- `CALDAV_CALENDAR_PATH` (e.g., `/pronote-sync/`)
|
||||||
|
- `CALDAV_ALLOW_INSECURE_HTTP` (default: `false`)
|
||||||
|
|
||||||
|
- **Sync Window:**
|
||||||
|
- `SYNC_PAST_DAYS`, `SYNC_FUTURE_DAYS`
|
||||||
|
|
||||||
|
- **Theoretical Agenda:**
|
||||||
|
- `THEORETICAL_AGENDA_PATH`, `SCHOOL_HOLIDAYS_PATH`
|
||||||
|
- `THEORETICAL_WEEK_ANCHOR_DATE`, `THEORETICAL_WEEK_ANCHOR_TYPE`
|
||||||
|
|
||||||
|
- **XMPP:**
|
||||||
|
- `XMPP_ENABLED`, `XMPP_HOST`, `XMPP_PORT`, `XMPP_USE_TLS`, `XMPP_TIMEOUT`, `XMPP_RESOURCE`
|
||||||
|
|
||||||
|
- **AI:**
|
||||||
|
- `AI_ENABLED`, `AI_PROVIDER`, `AI_BASE_URL`, `AI_MODEL`, `AI_ALLOW_INSECURE_HTTP`
|
||||||
|
|
||||||
|
- **Blog:**
|
||||||
|
- `BLOG_ENABLED`, `BLOG_RSS_URL`
|
||||||
|
|
||||||
|
- **General:**
|
||||||
|
- `DRY_RUN`, `LOG_LEVEL`
|
||||||
|
|
||||||
|
### Secret Variables (Operator Must Provide)
|
||||||
|
|
||||||
|
**Do NOT set these variables automatically.** The operator must manually provide the following secrets:
|
||||||
|
|
||||||
|
- **Pronote:**
|
||||||
|
- `PRONOTE_ICAL_URL`, `PRONOTE_URL`, `PRONOTE_USERNAME`, `PRONOTE_PASSWORD`
|
||||||
|
|
||||||
|
- **CalDAV:**
|
||||||
|
- `CALDAV_URL`, `CALDAV_USERNAME`, `CALDAV_PASSWORD`
|
||||||
|
|
||||||
|
- **XMPP:**
|
||||||
|
- `XMPP_JID`, `XMPP_PASSWORD`, `XMPP_TO`
|
||||||
|
|
||||||
|
- **AI:**
|
||||||
|
- `AI_API_KEY`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pre-Deployment Checks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verify no secrets in the codebase
|
||||||
|
/opt/pronote-sync/.venv/bin/python /opt/pronote-sync/scripts/check_secrets.py
|
||||||
|
|
||||||
|
# Verify dependencies
|
||||||
|
/opt/pronote-sync/.venv/bin/python -m pip check
|
||||||
|
|
||||||
|
# Dry-run test (will fail without secrets, but verifies the environment)
|
||||||
|
/opt/pronote-sync/.venv/bin/pronote-sync --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## systemd Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install systemd units
|
||||||
|
sudo install -m 0644 /opt/pronote-sync/deploy/systemd/pronote-sync.service /etc/systemd/system/
|
||||||
|
sudo install -m 0644 /opt/pronote-sync/deploy/systemd/pronote-sync.timer /etc/systemd/system/
|
||||||
|
|
||||||
|
# Install logrotate config
|
||||||
|
sudo install -m 0644 /opt/pronote-sync/deploy/logrotate/pronote_sync /etc/logrotate.d/pronote_sync
|
||||||
|
|
||||||
|
# Reload and enable
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now pronote-sync.timer
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for the AI Agent
|
||||||
|
|
||||||
|
- **Do NOT commit or write secrets** to any file in the repository.
|
||||||
|
- **Do NOT modify** `.gitignore`, `pyproject.toml`, or existing source files.
|
||||||
|
- If `python3.13` is not available, install it first:
|
||||||
|
- Debian: `sudo apt install python3.13`
|
||||||
|
- CentOS: Compile from source or use `dnf` if available.
|
||||||
|
- The `check_secrets.py` script exits with:
|
||||||
|
- `0` (clean)
|
||||||
|
- `1` (secrets found)
|
||||||
|
- `2` (error)
|
||||||
|
- All paths in the systemd unit assume `/opt/pronote-sync` — adjust if installed elsewhere.
|
||||||
|
- The operator **must** provide real values for all **SECRET** variables before enabling the timer.
|
||||||
69
README.md
Normal file
69
README.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# pronote-sync
|
||||||
|
|
||||||
|
Synchronisation Pronote → CalDAV + XMPP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Synchronise l'agenda et les devoirs de **Pronote** vers un calendrier **CalDAV** et envoie un résumé quotidien par **XMPP**. Supporte les sources iCal et `pronotepy` avec repli automatique. Synthèse IA optionnelle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Démarrage rapide
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Cloner le dépôt
|
||||||
|
git clone <repo-url>
|
||||||
|
cd pronote-sync
|
||||||
|
|
||||||
|
# Créer l'environnement virtuel
|
||||||
|
python3.13 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
|
||||||
|
# Installer
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
|
||||||
|
# Configurer
|
||||||
|
cp .env.example .env
|
||||||
|
# Éditer .env avec vos paramètres (voir .env.example pour le détail)
|
||||||
|
|
||||||
|
# Tester
|
||||||
|
pronote-sync --dry-run --log-level DEBUG
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📖 Utilisation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pronote-sync # Exécute la synchronisation
|
||||||
|
pronote-sync --dry-run # Simulation sans écriture
|
||||||
|
pronote-sync --log-level DEBUG # Verbosité des journaux
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🙏 Remerciements
|
||||||
|
|
||||||
|
Ce projet repose sur les bibliothèques open-source suivantes :
|
||||||
|
- [pronotepy](https://github.com/bain3/pronotepy) — client Pronote
|
||||||
|
- [icalendar](https://github.com/collective/icalendar) — parsing iCal
|
||||||
|
- [caldav](https://github.com/python-caldav/caldav) — client CalDAV
|
||||||
|
- [slixmpp](https://github.com/poezio/slixmpp) — client XMPP
|
||||||
|
- [pydantic](https://github.com/pydantic/pydantic) — validation et configuration
|
||||||
|
- [openai](https://github.com/openai/openai-python) — synthèse IA
|
||||||
|
- [feedparser](https://github.com/kurtmckee/feedparser) — parsing RSS
|
||||||
|
- [beautifulsoup4](https://www.crummy.com/software/BeautifulSoup/) — parsing HTML
|
||||||
|
|
||||||
|
Inspiré de [pronote-digest](https://github.com/yoanbernabeu/pronote-digest) par [Yoan Bernabeu](https://yoanbernabeu.github.io/pronote-digest/).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
MIT — voir [LICENSE](LICENSE).
|
||||||
22
TODO.md
22
TODO.md
@@ -277,11 +277,11 @@ Couvrir l'ensemble du code par des tests sans réseau, avec fixtures anonymisée
|
|||||||
|
|
||||||
Mettre en production de façon supervisée (planification, rotation des logs, vérification des secrets).
|
Mettre en production de façon supervisée (planification, rotation des logs, vérification des secrets).
|
||||||
|
|
||||||
- [ ] Créer une unité systemd (`pronote-sync.service` + timer) ou une ligne cron (exécution quotidienne).
|
- [x] Créer une unité systemd (`pronote-sync.service` + timer) ou une ligne cron (exécution quotidienne).
|
||||||
- [ ] Créer `logrotate.d/pronote_sync` (daily, rotate 7, compress, delaycompress).
|
- [x] Créer `logrotate.d/pronote_sync` (daily, rotate 7, compress, delaycompress).
|
||||||
- [ ] Ajouter un script de vérification des secrets (§13.6) exécuté avant chaque déploiement.
|
- [x] Ajouter un script de vérification des secrets (§13.6) exécuté avant chaque déploiement.
|
||||||
- [ ] Documenter la supervision (logs, alertes en cas d'échec) et la maintenance (maj dépendances, dry-run avant MAJ).
|
- [x] Documenter la supervision (logs, alertes en cas d'échec) et la maintenance (maj dépendances, dry-run avant MAJ).
|
||||||
- [ ] Vérifier `pip check` et tester le dry-run avant mise en production.
|
- [x] Vérifier `pip check` et tester le dry-run avant mise en production.
|
||||||
|
|
||||||
### Critères d'acceptation
|
### Critères d'acceptation
|
||||||
- Le service/timer systemd (ou cron) lance le pipeline quotidiennement.
|
- Le service/timer systemd (ou cron) lance le pipeline quotidiennement.
|
||||||
@@ -294,13 +294,13 @@ Mettre en production de façon supervisée (planification, rotation des logs, v
|
|||||||
|
|
||||||
Rédiger la documentation utilisateur et finaliser le projet.
|
Rédiger la documentation utilisateur et finaliser le projet.
|
||||||
|
|
||||||
- [ ] Créer `README.md` (installation, configuration `.env`, usage CLI, systemd/docker, limites, RGPD).
|
- [x] Créer `README.md` (installation, configuration `.env`, usage CLI, systemd/docker, limites, RGPD).
|
||||||
- [ ] Documenter l'architecture (pipeline, modules) en résumé.
|
- [x] Documenter l'architecture (pipeline, modules) en résumé.
|
||||||
- [ ] Ajouter `CHANGELOG` initial et la licence (MIT).
|
- [x] Ajouter `CHANGELOG` initial et la licence (MIT).
|
||||||
- [ ] Revue finale : cohérence avec le guide, aucun secret documenté en clair.
|
- [x] Revue finale : cohérence avec le guide, aucun secret documenté en clair.
|
||||||
- [ ] (Optionnel) Configurer GitHub Actions CI/CD (pytest + bandit + ruff + mypy) d'après §Prochaines étapes.
|
- [ ] (Optionnel) Configurer Gitea Actions (pytest + bandit + ruff + mypy) pour le déploiement LXC/VPS (Debian/CentOS).
|
||||||
|
|
||||||
### 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.
|
||||||
- La CI exécute tests + lint + sécurité.
|
- Gitea Actions exécute tests + lint + sécurité.
|
||||||
- Aucun secret dans la documentation.
|
- Aucun secret dans la documentation.
|
||||||
|
|||||||
31
data/school_holidays.json
Normal file
31
data/school_holidays.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"zone": "A",
|
||||||
|
"school_year": "2026-2027",
|
||||||
|
"periods": [
|
||||||
|
{
|
||||||
|
"start_date": "2026-10-17",
|
||||||
|
"end_date": "2026-11-02",
|
||||||
|
"label": "Toussaint"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start_date": "2026-12-19",
|
||||||
|
"end_date": "2027-01-04",
|
||||||
|
"label": "Noël"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start_date": "2027-02-13",
|
||||||
|
"end_date": "2027-03-01",
|
||||||
|
"label": "Hiver"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start_date": "2027-04-10",
|
||||||
|
"end_date": "2027-04-26",
|
||||||
|
"label": "Printemps"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start_date": "2027-07-03",
|
||||||
|
"end_date": "2027-09-01",
|
||||||
|
"label": "Été"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
9
deploy/logrotate/pronote_sync
Normal file
9
deploy/logrotate/pronote_sync
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/var/log/pronote-sync/pronote-sync.log {
|
||||||
|
daily
|
||||||
|
missingok
|
||||||
|
rotate 7
|
||||||
|
compress
|
||||||
|
delaycompress
|
||||||
|
notifempty
|
||||||
|
create 0640 pronote-sync pronote-sync
|
||||||
|
}
|
||||||
23
deploy/systemd/pronote-sync.service
Normal file
23
deploy/systemd/pronote-sync.service
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Synchronisation Pronote vers CalDAV et XMPP
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=pronote-sync
|
||||||
|
Group=pronote-sync
|
||||||
|
WorkingDirectory=/var/lib/pronote-sync
|
||||||
|
EnvironmentFile=/etc/pronote-sync/pronote-sync.env
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
StateDirectory=pronote-sync
|
||||||
|
LogsDirectory=pronote-sync
|
||||||
|
ExecStartPre=/opt/pronote-sync/.venv/bin/python /opt/pronote-sync/scripts/check_secrets.py
|
||||||
|
ExecStart=/opt/pronote-sync/.venv/bin/pronote-sync
|
||||||
|
StandardOutput=append:/var/log/pronote-sync/pronote-sync.log
|
||||||
|
StandardError=append:/var/log/pronote-sync/pronote-sync.log
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ReadWritePaths=/var/lib/pronote-sync /var/log/pronote-sync
|
||||||
10
deploy/systemd/pronote-sync.timer
Normal file
10
deploy/systemd/pronote-sync.timer
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Exécution quotidienne de pronote-sync
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 18:00:00
|
||||||
|
Persistent=true
|
||||||
|
Unit=pronote-sync.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
143
docs/exploitation.md
Normal file
143
docs/exploitation.md
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
# Exploitation de `pronote-sync`
|
||||||
|
|
||||||
|
Ce guide décrit l'installation et l'exploitation des artefacts de déploiement
|
||||||
|
fournis par le projet. Les paramètres de l'unité systemd fournie sont des
|
||||||
|
exemples d'installation : adaptez-les à l'hôte cible avant son installation.
|
||||||
|
Ne placez jamais de secret dans une unité systemd, une commande shell, un
|
||||||
|
journal ou ce document.
|
||||||
|
|
||||||
|
## Préparer l'hôte
|
||||||
|
|
||||||
|
Installez le projet et ses dépendances dans le répertoire choisi, puis créez le
|
||||||
|
fichier d'environnement référencé par l'unité à partir de `.env.example`. Il
|
||||||
|
doit rester local et lisible uniquement par le compte de service :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo install -d -m 0700 -o <utilisateur-service> -g <groupe-service> <repertoire-configuration>
|
||||||
|
sudo install -m 0600 -o <utilisateur-service> -g <groupe-service> .env <fichier-environnement>
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
d'entrée :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/pronote-sync --help
|
||||||
|
```
|
||||||
|
|
||||||
|
Avant toute activation ou mise à jour, exécutez les contrôles depuis la racine
|
||||||
|
du projet :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python scripts/check_secrets.py
|
||||||
|
.venv/bin/python -m pip check
|
||||||
|
.venv/bin/pronote-sync --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
Le contrôle des secrets doit réussir avant le déploiement. Il inspecte les
|
||||||
|
fichiers textuels de l'artefact, en excluant volontairement `.env`, les
|
||||||
|
environnements virtuels, les répertoires générés, `tests/` et
|
||||||
|
`GUIDE_DEV_PYTHON.md` ; les sentinelles et exemples de ces deux derniers ne
|
||||||
|
bloquent donc pas le déploiement. Il ne valide ni les valeurs ni les permissions
|
||||||
|
du fichier d'environnement. Pour analyser seulement le contenu indexé avant un
|
||||||
|
commit, utilisez `scripts/check_secrets.py --staged`.
|
||||||
|
|
||||||
|
Le dry-run vérifie le pipeline sans appliquer les écritures de synchronisation ;
|
||||||
|
il ne remplace pas une vérification des paramètres réellement chargés.
|
||||||
|
|
||||||
|
## Installation systemd
|
||||||
|
|
||||||
|
Les fichiers versionnés sont :
|
||||||
|
|
||||||
|
- `deploy/systemd/pronote-sync.service` ;
|
||||||
|
- `deploy/systemd/pronote-sync.timer`.
|
||||||
|
|
||||||
|
Copiez-les dans le répertoire d'unités systemd de l'hôte. Avant de les activer,
|
||||||
|
adaptez `User`, `Group`, `WorkingDirectory`, `EnvironmentFile`, les chemins des
|
||||||
|
exécutables dans `ExecStartPre` et `ExecStart`, ainsi que les chemins de
|
||||||
|
`StateDirectory`, `LogsDirectory` et `ReadWritePaths`. L'artefact fourni prend
|
||||||
|
pour exemple le compte `pronote-sync`, le code dans `/opt/pronote-sync`, l'état
|
||||||
|
dans `/var/lib/pronote-sync`, les logs dans `/var/log/pronote-sync` et le fichier
|
||||||
|
d'environnement `/etc/pronote-sync/pronote-sync.env`. Ne copiez pas de valeur
|
||||||
|
secrète dans l'unité.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo install -m 0644 deploy/systemd/pronote-sync.service /etc/systemd/system/
|
||||||
|
sudo install -m 0644 deploy/systemd/pronote-sync.timer /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now pronote-sync.timer
|
||||||
|
systemctl list-timers pronote-sync.timer
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour tester une exécution sans attendre la prochaine échéance :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl start pronote-sync.service
|
||||||
|
sudo systemctl status pronote-sync.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Une exécution en échec laisse l'unité `pronote-sync.service` en état `failed`.
|
||||||
|
La supervision de l'hôte doit donc déclencher une alerte sur cet état ou sur un
|
||||||
|
échec du timer/service ; le transport de cette alerte (courriel, XMPP ou système
|
||||||
|
de supervision) relève de l'exploitation locale.
|
||||||
|
|
||||||
|
## Journaux et alertes
|
||||||
|
|
||||||
|
La configuration systemd redirige la sortie standard et la sortie d'erreur vers
|
||||||
|
`/var/log/pronote-sync/pronote-sync.log`. Consultez ce fichier ou, selon la
|
||||||
|
configuration de l'hôte, le journal de l'unité :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo tail -f /var/log/pronote-sync/pronote-sync.log
|
||||||
|
sudo journalctl -u pronote-sync.service --since today
|
||||||
|
sudo journalctl -u pronote-sync.service -f
|
||||||
|
systemctl status pronote-sync.timer
|
||||||
|
```
|
||||||
|
|
||||||
|
Traitez un statut non nul ou une unité `failed` comme un échec à investiguer.
|
||||||
|
Les logs applicatifs masquent les secrets configurés, mais évitez tout de même
|
||||||
|
de partager sans relecture un export de journal : une donnée sensible issue de
|
||||||
|
l'environnement ou d'un outil tiers ne doit pas être supposée sûre par défaut.
|
||||||
|
|
||||||
|
## Rotation des journaux
|
||||||
|
|
||||||
|
L'artefact `deploy/logrotate/pronote_sync` cible le fichier
|
||||||
|
`/var/log/pronote-sync/pronote-sync.log` utilisé par l'unité fournie. Installez-
|
||||||
|
le puis validez sa syntaxe avant activation :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo install -m 0644 deploy/logrotate/pronote_sync /etc/logrotate.d/pronote_sync
|
||||||
|
sudo logrotate --debug /etc/logrotate.d/pronote_sync
|
||||||
|
```
|
||||||
|
|
||||||
|
La rotation configurée est quotidienne, conserve sept archives et utilise
|
||||||
|
`compress` avec `delaycompress`. Elle recrée le fichier avec les droits `0640`
|
||||||
|
pour le compte de service. Si vous modifiez le chemin de journal dans l'unité,
|
||||||
|
mettez aussi à jour la règle logrotate correspondante.
|
||||||
|
|
||||||
|
## Mise à jour et retour au service
|
||||||
|
|
||||||
|
Avant de remplacer les dépendances ou le code, conservez une copie protégée du
|
||||||
|
fichier d'environnement local, sans l'ajouter au dépôt. Après la mise à jour,
|
||||||
|
réexécutez, dans cet ordre, les contrôles de secrets, de cohérence des paquets
|
||||||
|
et le dry-run :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python scripts/check_secrets.py
|
||||||
|
.venv/bin/python -m pip check
|
||||||
|
.venv/bin/pronote-sync --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
Rechargez ensuite les unités si leurs fichiers ont changé, puis vérifiez une
|
||||||
|
exécution et son journal :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl restart pronote-sync.timer
|
||||||
|
sudo systemctl start pronote-sync.service
|
||||||
|
journalctl -u pronote-sync.service -n 100 --no-pager
|
||||||
|
```
|
||||||
|
|
||||||
|
En cas d'échec, ne relancez pas automatiquement après avoir modifié des
|
||||||
|
identifiants : corrigez la configuration locale, repassez le contrôle des
|
||||||
|
secrets et le dry-run, puis consultez le journal expurgé.
|
||||||
@@ -155,16 +155,17 @@ class PronoteClient:
|
|||||||
"""Crée et connecte le client ``pronotepy`` (connexion paresseuse).
|
"""Crée et connecte le client ``pronotepy`` (connexion paresseuse).
|
||||||
|
|
||||||
Le client est créé une seule fois puis réutilisé pour les appels
|
Le client est créé une seule fois puis réutilisé pour les appels
|
||||||
suivants. Le nom d'ENT est résolu via :func:`_resolve_ent` et le
|
suivants. Le nom d'ENT, s'il est configuré, est résolu via
|
||||||
type de compte (``student`` ou ``parent``) détermine la classe de
|
:func:`_resolve_ent` ; en l'absence d'ENT, ``ent=None`` est transmis
|
||||||
client utilisée. L'erreur de connexion est relancée sans
|
à ``pronotepy`` pour une connexion directe. Le type de compte
|
||||||
journalisation, la méthode publique appelante étant responsable
|
(``student`` ou ``parent``) détermine la classe de client utilisée.
|
||||||
de la journaliser.
|
L'erreur de connexion est relancée sans journalisation, la méthode
|
||||||
|
publique appelante étant responsable de la journaliser.
|
||||||
|
|
||||||
:return: Le client ``pronotepy`` connecté.
|
:return: Le client ``pronotepy`` connecté.
|
||||||
:rtype: pronotepy.Client
|
:rtype: pronotepy.Client
|
||||||
:raises ValueError: Si ``pronote_url``, ``username``, ``password``
|
:raises ValueError: Si ``pronote_url``, ``username`` ou ``password``
|
||||||
ou ``ent`` est manquant, ou si l'ENT est inconnu.
|
est manquant, ou si l'ENT fourni est inconnu.
|
||||||
:raises pronotepy.PronoteAPIError: Si la connexion à Pronote échoue.
|
:raises pronotepy.PronoteAPIError: Si la connexion à Pronote échoue.
|
||||||
"""
|
"""
|
||||||
if self._client is None:
|
if self._client is None:
|
||||||
@@ -172,11 +173,9 @@ class PronoteClient:
|
|||||||
username = self._settings.username
|
username = self._settings.username
|
||||||
password = self._settings.password
|
password = self._settings.password
|
||||||
ent = self._settings.ent
|
ent = self._settings.ent
|
||||||
if pronote_url is None or username is None or password is None or ent is None:
|
if pronote_url is None or username is None or password is None:
|
||||||
raise ValueError(
|
raise ValueError("pronote_url, username et password sont requis pour pronotepy")
|
||||||
"pronote_url, username, password et ent sont requis pour pronotepy"
|
resolver = _resolve_ent(ent) if ent is not None else None
|
||||||
)
|
|
||||||
resolver = _resolve_ent(ent)
|
|
||||||
client_class: type[pronotepy.Client] = (
|
client_class: type[pronotepy.Client] = (
|
||||||
pronotepy.ParentClient
|
pronotepy.ParentClient
|
||||||
if self._settings.account_type == "parent"
|
if self._settings.account_type == "parent"
|
||||||
|
|||||||
@@ -149,8 +149,8 @@ class PronoteFetcher:
|
|||||||
def _is_pronotepy_configured(self) -> bool:
|
def _is_pronotepy_configured(self) -> bool:
|
||||||
"""Vérifie que la source pronotepy est entièrement configurée.
|
"""Vérifie que la source pronotepy est entièrement configurée.
|
||||||
|
|
||||||
:return: ``True`` si ``pronote_url``, ``username``, ``password``
|
:return: ``True`` si ``pronote_url``, ``username`` et ``password``
|
||||||
et ``ent`` sont tous définis, ``False`` sinon.
|
sont tous définis, ``False`` sinon.
|
||||||
:rtype: bool
|
:rtype: bool
|
||||||
"""
|
"""
|
||||||
pronote = self._settings.pronote
|
pronote = self._settings.pronote
|
||||||
@@ -158,7 +158,6 @@ class PronoteFetcher:
|
|||||||
pronote.pronote_url is not None
|
pronote.pronote_url is not None
|
||||||
and pronote.username is not None
|
and pronote.username is not None
|
||||||
and pronote.password is not None
|
and pronote.password is not None
|
||||||
and pronote.ent is not None
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _fetch_agenda_ical(self) -> tuple[list[Lesson], list[SchoolEvent]]:
|
def _fetch_agenda_ical(self) -> tuple[list[Lesson], list[SchoolEvent]]:
|
||||||
|
|||||||
245
scripts/check_secrets.py
Normal file
245
scripts/check_secrets.py
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Vérifie l'absence de secrets littéraux avant un déploiement.
|
||||||
|
|
||||||
|
Le script inspecte le contenu textuel du dépôt, ou uniquement les fichiers
|
||||||
|
ajoutés/modifiés dans l'index avec ``--staged``. Il ne transmet jamais la
|
||||||
|
valeur détectée : les résultats ne contiennent que le chemin, le numéro de
|
||||||
|
ligne et le type de motif. Les fichiers d'environnement et les répertoires
|
||||||
|
générés sont exclus, car ils ne doivent pas être versionnés ni déployés.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
import subprocess # nosec B404
|
||||||
|
from collections.abc import Callable, Iterable, Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_EXCLUDED_PARTS = frozenset({".git", ".venv", ".worktrees", "__pycache__", ".."})
|
||||||
|
_EXCLUDED_NAMES = frozenset({".env", ".secrets.baseline", "GUIDE_DEV_PYTHON.md"})
|
||||||
|
_EXCLUDED_TOP_LEVEL = frozenset({"tests"})
|
||||||
|
_ALLOWLIST_MARKER = "secret-check: allow"
|
||||||
|
_UNQUOTED_CONFIG_SUFFIXES = frozenset({".conf", ".ini", ".toml", ".yaml", ".yml"})
|
||||||
|
_TEXT_SUFFIXES = frozenset(
|
||||||
|
{".conf", ".ini", ".json", ".md", ".py", ".service", ".timer", ".toml", ".txt", ".yaml", ".yml"}
|
||||||
|
)
|
||||||
|
_LITERAL_SECRET_RE = re.compile(
|
||||||
|
r"(?ix)\b[a-z0-9_]*(?:api[_-]?key|access[_-]?token|auth(?:orization)?|icalsecurise|password|secret|token)"
|
||||||
|
r"\s*[:=]\s*['\"][^'\"\r\n]{3,}['\"]"
|
||||||
|
)
|
||||||
|
_UNQUOTED_SECRET_RE = re.compile(
|
||||||
|
r"(?ix)\b[a-z0-9_]*(?:api[_-]?key|access[_-]?token|auth(?:orization)?|icalsecurise|password|secret|token)"
|
||||||
|
r"\s*[:=]\s*[a-z0-9][a-z0-9._~+/-]{2,}"
|
||||||
|
)
|
||||||
|
_URL_SECRET_RE = re.compile(
|
||||||
|
r"(?ix)[?&](?:api[_-]?key|access[_-]?token|auth(?:orization)?|icalsecurise|password|secret|token)"
|
||||||
|
r"=([^&#\s]{3,})"
|
||||||
|
)
|
||||||
|
_EXTRA_NAMES = frozenset({"pronote_sync"})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SecretFinding:
|
||||||
|
"""Représente un motif sensible détecté sans exposer sa valeur.
|
||||||
|
|
||||||
|
:ivar path: Chemin relatif du fichier concerné.
|
||||||
|
:ivar line: Numéro de ligne du motif.
|
||||||
|
:ivar rule: Règle ayant détecté le motif.
|
||||||
|
"""
|
||||||
|
|
||||||
|
path: Path
|
||||||
|
line: int
|
||||||
|
rule: str
|
||||||
|
|
||||||
|
|
||||||
|
CommandRunner = Callable[..., subprocess.CompletedProcess[str]]
|
||||||
|
#: Fournisseur de contenu pour un chemin relatif ; retourne ``None`` pour ignorer.
|
||||||
|
ContentProvider = Callable[[Path], str | None]
|
||||||
|
|
||||||
|
|
||||||
|
def _is_candidate(path: Path) -> bool:
|
||||||
|
"""Indique si un chemin peut être analysé comme fichier texte.
|
||||||
|
|
||||||
|
Les fichiers de déploiement sans extension, nommés explicitement dans
|
||||||
|
``_EXTRA_NAMES``, sont également retenus.
|
||||||
|
|
||||||
|
:param path: Chemin relatif au dépôt.
|
||||||
|
:return: ``True`` lorsque le fichier est textuel et non exclu.
|
||||||
|
:rtype: bool
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
not path.is_absolute()
|
||||||
|
and path.name not in _EXCLUDED_NAMES
|
||||||
|
and path.parts[0] not in _EXCLUDED_TOP_LEVEL
|
||||||
|
and not any(part in _EXCLUDED_PARTS for part in path.parts)
|
||||||
|
and (path.suffix in _TEXT_SUFFIXES or path.name in _EXTRA_NAMES)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _repository_files(root: Path) -> list[Path]:
|
||||||
|
"""Liste les fichiers textuels présents dans le dépôt de travail.
|
||||||
|
|
||||||
|
Les tests et la spécification historique ne font pas partie de l'artefact
|
||||||
|
déployé : leurs sentinelles et exemples intentionnels ne doivent donc pas
|
||||||
|
bloquer le déploiement.
|
||||||
|
|
||||||
|
:param root: Racine du dépôt à analyser.
|
||||||
|
:return: Chemins relatifs triés des fichiers analysables.
|
||||||
|
:rtype: list[Path]
|
||||||
|
"""
|
||||||
|
return sorted(
|
||||||
|
path.relative_to(root)
|
||||||
|
for path in root.rglob("*")
|
||||||
|
if path.is_file() and _is_candidate(path.relative_to(root))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _staged_files(root: Path, runner: CommandRunner) -> list[Path]:
|
||||||
|
"""Retourne les fichiers ajoutés ou modifiés actuellement indexés.
|
||||||
|
|
||||||
|
:param root: Racine du dépôt Git.
|
||||||
|
:param runner: Exécuteur de sous-processus injectable pour les tests.
|
||||||
|
:return: Chemins relatifs triés des fichiers indexés analysables.
|
||||||
|
:rtype: list[Path]
|
||||||
|
:raises RuntimeError: Si Git ne peut pas fournir les fichiers indexés.
|
||||||
|
"""
|
||||||
|
result = runner(
|
||||||
|
["git", "diff", "--cached", "--name-only", "-z", "--diff-filter=ACMR"],
|
||||||
|
cwd=root,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError("Impossible de lister les fichiers Git indexés") from None
|
||||||
|
paths = [Path(value) for value in result.stdout.split("\0") if value]
|
||||||
|
return sorted(path for path in paths if _is_candidate(path))
|
||||||
|
|
||||||
|
|
||||||
|
def _staged_content_provider(root: Path, runner: CommandRunner) -> ContentProvider:
|
||||||
|
"""Retourne un lecteur de contenu depuis l'index Git.
|
||||||
|
|
||||||
|
Lit le blob indexé via ``git show :<chemin>`` afin de ne pas dépendre de
|
||||||
|
l'état du working tree, dont la copie de travail peut différer de l'index.
|
||||||
|
|
||||||
|
:param root: Racine du dépôt Git.
|
||||||
|
:param runner: Exécuteur de sous-processus injectable pour les tests.
|
||||||
|
:return: Fonction de lecture du contenu indexé ; ``None`` si indisponible.
|
||||||
|
:rtype: ContentProvider
|
||||||
|
"""
|
||||||
|
|
||||||
|
def provider(relative_path: Path) -> str | None:
|
||||||
|
result = runner(
|
||||||
|
["git", "show", f":{relative_path}"],
|
||||||
|
cwd=root,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
return provider
|
||||||
|
|
||||||
|
|
||||||
|
def find_secrets(
|
||||||
|
root: Path,
|
||||||
|
files: Iterable[Path],
|
||||||
|
content_provider: ContentProvider | None = None,
|
||||||
|
) -> list[SecretFinding]:
|
||||||
|
"""Détecte les motifs de secrets littéraux dans les fichiers désignés.
|
||||||
|
|
||||||
|
Les lignes explicitement marquées ``secret-check: allow`` sont exclues :
|
||||||
|
cette échappatoire doit rester locale à une fixture ou un exemple contrôlé.
|
||||||
|
|
||||||
|
:param root: Racine du dépôt analysé.
|
||||||
|
:param files: Chemins relatifs à inspecter.
|
||||||
|
:param content_provider: Lecteur optionnel du contenu d'un fichier ; par
|
||||||
|
défaut le contenu est lu depuis le working tree via ``read_text``.
|
||||||
|
Si le lecteur retourne ``None`` ou lève une erreur d'encodage, le
|
||||||
|
fichier est ignoré.
|
||||||
|
:return: Résultats triés par chemin, ligne et règle.
|
||||||
|
:rtype: list[SecretFinding]
|
||||||
|
"""
|
||||||
|
findings: list[SecretFinding] = []
|
||||||
|
for relative_path in files:
|
||||||
|
path = root / relative_path
|
||||||
|
try:
|
||||||
|
if content_provider is not None:
|
||||||
|
content = content_provider(relative_path)
|
||||||
|
else:
|
||||||
|
content = path.read_text(encoding="utf-8")
|
||||||
|
if content is None:
|
||||||
|
continue
|
||||||
|
except (OSError, UnicodeDecodeError):
|
||||||
|
continue
|
||||||
|
for number, line in enumerate(content.splitlines(), start=1):
|
||||||
|
if _ALLOWLIST_MARKER in line:
|
||||||
|
continue
|
||||||
|
is_literal_secret = _LITERAL_SECRET_RE.search(line) or (
|
||||||
|
relative_path.suffix in _UNQUOTED_CONFIG_SUFFIXES
|
||||||
|
and _UNQUOTED_SECRET_RE.search(line)
|
||||||
|
)
|
||||||
|
if is_literal_secret:
|
||||||
|
findings.append(SecretFinding(relative_path, number, "affectation-litterale"))
|
||||||
|
if _URL_SECRET_RE.search(line):
|
||||||
|
findings.append(SecretFinding(relative_path, number, "parametre-url"))
|
||||||
|
return sorted(findings, key=lambda finding: (str(finding.path), finding.line, finding.rule))
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_arguments(arguments: Sequence[str] | None = None) -> argparse.Namespace:
|
||||||
|
"""Analyse les options de vérification.
|
||||||
|
|
||||||
|
:param arguments: Arguments explicites, ou ``None`` pour ceux du processus.
|
||||||
|
:return: Options validées.
|
||||||
|
:rtype: argparse.Namespace
|
||||||
|
"""
|
||||||
|
parser = argparse.ArgumentParser(description="Vérifie les secrets avant déploiement.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--staged",
|
||||||
|
action="store_true",
|
||||||
|
help="Analyse uniquement les fichiers ajoutés ou modifiés dans l'index Git.",
|
||||||
|
)
|
||||||
|
return parser.parse_args(arguments)
|
||||||
|
|
||||||
|
|
||||||
|
def main(
|
||||||
|
arguments: Sequence[str] | None = None,
|
||||||
|
*,
|
||||||
|
root: Path | None = None,
|
||||||
|
runner: CommandRunner = subprocess.run,
|
||||||
|
) -> int:
|
||||||
|
"""Exécute la vérification de secrets et retourne un code de sortie.
|
||||||
|
|
||||||
|
:param arguments: Arguments de ligne de commande.
|
||||||
|
:param root: Racine à analyser ; le dépôt du script par défaut.
|
||||||
|
:param runner: Exécuteur Git injectable pour les tests.
|
||||||
|
:return: ``0`` sans motif, ``1`` si un motif est trouvé, ``2`` si le contrôle échoue.
|
||||||
|
:rtype: int
|
||||||
|
"""
|
||||||
|
parsed_arguments = _parse_arguments(arguments)
|
||||||
|
repository_root = root or Path(__file__).resolve().parents[1]
|
||||||
|
try:
|
||||||
|
if parsed_arguments.staged:
|
||||||
|
files = _staged_files(repository_root, runner)
|
||||||
|
content_provider = _staged_content_provider(repository_root, runner)
|
||||||
|
else:
|
||||||
|
files = _repository_files(repository_root)
|
||||||
|
content_provider = None
|
||||||
|
except RuntimeError as error:
|
||||||
|
print(f"ERREUR: {error}")
|
||||||
|
return 2
|
||||||
|
findings = find_secrets(repository_root, files, content_provider=content_provider)
|
||||||
|
if not findings:
|
||||||
|
print("OK: aucun secret littéral détecté.")
|
||||||
|
return 0
|
||||||
|
for finding in findings:
|
||||||
|
print(f"ECHEC: {finding.path}:{finding.line} ({finding.rule})")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
249
tests/unit/test_check_secrets.py
Normal file
249
tests/unit/test_check_secrets.py
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
"""Tests unitaires du contrôle de secrets de déploiement."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from types import ModuleType
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from _pytest.capture import CaptureFixture
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def secret_checker() -> ModuleType:
|
||||||
|
"""Charge le script de vérification sans l'exécuter comme programme.
|
||||||
|
|
||||||
|
:return: Module du script de contrôle de secrets.
|
||||||
|
:rtype: ModuleType
|
||||||
|
"""
|
||||||
|
script_path = Path(__file__).parents[2] / "scripts" / "check_secrets.py"
|
||||||
|
specification = importlib.util.spec_from_file_location("check_secrets", script_path)
|
||||||
|
assert specification is not None
|
||||||
|
assert specification.loader is not None
|
||||||
|
module = importlib.util.module_from_spec(specification)
|
||||||
|
sys.modules[specification.name] = module
|
||||||
|
try:
|
||||||
|
specification.loader.exec_module(module)
|
||||||
|
finally:
|
||||||
|
del sys.modules[specification.name]
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_accepts_clean_files_and_ignores_environment_file(
|
||||||
|
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie qu'un dépôt propre réussit sans analyser le fichier d'environnement.
|
||||||
|
|
||||||
|
: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 / "application.py").write_text("value = 'safe'\n", encoding="utf-8")
|
||||||
|
ignored_environment_secret = 'password = "private-value"\n' # pragma: allowlist secret
|
||||||
|
(tmp_path / ".env").write_text(
|
||||||
|
ignored_environment_secret, encoding="utf-8"
|
||||||
|
) # secret-check: allow
|
||||||
|
|
||||||
|
assert secret_checker.main([], root=tmp_path) == 0
|
||||||
|
assert "OK:" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_reports_a_literal_secret_without_disclosing_its_value(
|
||||||
|
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie qu'un secret littéral échoue sans fuite de sa 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
|
||||||
|
"""
|
||||||
|
sentinel = "m14-literal-sentinel"
|
||||||
|
(tmp_path / "settings.py").write_text(
|
||||||
|
f'password = "{sentinel}"\n', encoding="utf-8"
|
||||||
|
) # secret-check: allow
|
||||||
|
|
||||||
|
assert secret_checker.main([], root=tmp_path) == 1
|
||||||
|
output = capsys.readouterr().out
|
||||||
|
assert "settings.py:1 (affectation-litterale)" in output
|
||||||
|
assert sentinel not in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_reports_an_unquoted_configuration_secret_without_disclosing_its_value(
|
||||||
|
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie qu'un secret de configuration non cité échoue sans fuite de sa 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
|
||||||
|
"""
|
||||||
|
sentinel = "m14-unquoted-sentinel"
|
||||||
|
(tmp_path / "settings.yaml").write_text(
|
||||||
|
f"password: {sentinel}\n", encoding="utf-8"
|
||||||
|
) # secret-check: allow
|
||||||
|
|
||||||
|
assert secret_checker.main([], root=tmp_path) == 1
|
||||||
|
output = capsys.readouterr().out
|
||||||
|
assert "settings.yaml:1 (affectation-litterale)" in output
|
||||||
|
assert sentinel not in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_detects_sensitive_url_parameter(
|
||||||
|
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie qu'un paramètre URL sensible déclenche un échec.
|
||||||
|
|
||||||
|
: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-sentinel"
|
||||||
|
(tmp_path / "settings.yaml").write_text(
|
||||||
|
f"url: https://example.invalid/calendar?icalsecurise={sentinel}\n", encoding="utf-8"
|
||||||
|
) # secret-check: allow
|
||||||
|
|
||||||
|
assert secret_checker.main([], root=tmp_path) == 1
|
||||||
|
output = capsys.readouterr().out
|
||||||
|
assert "settings.yaml:1 (parametre-url)" in output
|
||||||
|
assert sentinel not in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_staged_mode_inspects_only_paths_provided_by_git(
|
||||||
|
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie que l'option staged ignore les fichiers non indexé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.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
(tmp_path / "indexed.py").write_text("answer = 42\n", encoding="utf-8")
|
||||||
|
untracked_secret = 'api_key = "m14-untracked-sentinel"\n' # pragma: allowlist secret
|
||||||
|
(tmp_path / "untracked.py").write_text(
|
||||||
|
untracked_secret, encoding="utf-8"
|
||||||
|
) # secret-check: allow
|
||||||
|
|
||||||
|
def runner(*_args: object, **_kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||||
|
"""Simule Git avec un seul fichier indexé.
|
||||||
|
|
||||||
|
:return: Résultat Git simulé.
|
||||||
|
:rtype: subprocess.CompletedProcess[str]
|
||||||
|
"""
|
||||||
|
return subprocess.CompletedProcess([], 0, stdout="indexed.py\0", stderr="")
|
||||||
|
|
||||||
|
assert secret_checker.main(["--staged"], root=tmp_path, runner=runner) == 0
|
||||||
|
assert "OK:" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_detects_prefixed_secret_assignment(
|
||||||
|
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie qu'une variable préfixée (PRONOTE_PASSWORD) est détectée.
|
||||||
|
|
||||||
|
: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-prefixed-secret"
|
||||||
|
(tmp_path / "config.py").write_text(
|
||||||
|
f'PRONOTE_PASSWORD = "{sentinel}"\n', encoding="utf-8"
|
||||||
|
) # secret-check: allow
|
||||||
|
|
||||||
|
assert secret_checker.main([], root=tmp_path) == 1
|
||||||
|
output = capsys.readouterr().out
|
||||||
|
assert "config.py:1" in output
|
||||||
|
assert sentinel not in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_detects_short_secret_assignment(
|
||||||
|
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie qu'un secret court (< 8 caractères) est détecté.
|
||||||
|
|
||||||
|
: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 = "s3cr3t"
|
||||||
|
(tmp_path / "config.py").write_text(
|
||||||
|
f'password = "{sentinel}"\n', encoding="utf-8"
|
||||||
|
) # secret-check: allow
|
||||||
|
|
||||||
|
assert secret_checker.main([], root=tmp_path) == 1
|
||||||
|
output = capsys.readouterr().out
|
||||||
|
assert "config.py:1" in output
|
||||||
|
assert sentinel not in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_staged_mode_reads_index_content_not_working_tree(
|
||||||
|
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie que --staged lit le contenu indexé, pas le working tree.
|
||||||
|
|
||||||
|
: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
|
||||||
|
"""
|
||||||
|
indexed_secret = "m14-indexed-only-secret" # pragma: allowlist secret
|
||||||
|
(tmp_path / "staged.py").write_text(
|
||||||
|
f'password = "{indexed_secret}"\n', encoding="utf-8"
|
||||||
|
) # secret-check: allow
|
||||||
|
(tmp_path / "staged.py").write_text('value = "safe"\n', encoding="utf-8")
|
||||||
|
|
||||||
|
def runner(*args: object, **_kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||||
|
"""Simule Git en renvoyant le contenu indexé pour le blob demandé.
|
||||||
|
|
||||||
|
:return: Résultat Git simulé.
|
||||||
|
:rtype: subprocess.CompletedProcess[str]
|
||||||
|
"""
|
||||||
|
first_argument = args[0] if args else []
|
||||||
|
command = (
|
||||||
|
[str(argument) for argument in first_argument]
|
||||||
|
if isinstance(first_argument, list)
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
if "show" in command:
|
||||||
|
return subprocess.CompletedProcess(
|
||||||
|
command, 0, stdout=f'password = "{indexed_secret}"\n', stderr=""
|
||||||
|
)
|
||||||
|
return subprocess.CompletedProcess(command, 0, stdout="staged.py\0", stderr="")
|
||||||
|
|
||||||
|
assert secret_checker.main(["--staged"], root=tmp_path, runner=runner) == 1
|
||||||
|
output = capsys.readouterr().out
|
||||||
|
assert "staged.py:1" in output
|
||||||
|
assert indexed_secret not in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_scans_extensionless_deployment_file(
|
||||||
|
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie qu'un fichier de déploiement sans extension est scanné.
|
||||||
|
|
||||||
|
: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-logrotate-secret"
|
||||||
|
(tmp_path / "pronote_sync").write_text(
|
||||||
|
f'password = "{sentinel}"\n', encoding="utf-8"
|
||||||
|
) # secret-check: allow
|
||||||
|
|
||||||
|
assert secret_checker.main([], root=tmp_path) == 1
|
||||||
|
output = capsys.readouterr().out
|
||||||
|
assert "pronote_sync:1" in output
|
||||||
|
assert sentinel not in output
|
||||||
@@ -1088,6 +1088,102 @@ def test_homework_sources_auto_no_source_configured_raises(mock_fetcher: Pronote
|
|||||||
assert "ni la source iCal ni pronotepy n'est configurée" in str(exc_info.value)
|
assert "ni la source iCal ni pronotepy n'est configurée" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_agenda_auto_ical_configured_fails_fallback_to_pronotepy_without_ent(
|
||||||
|
mock_fetcher: PronoteFetcher,
|
||||||
|
) -> None:
|
||||||
|
"""Test le mode auto : iCal configuré mais échoue, repli sur pronotepy sans ent.
|
||||||
|
|
||||||
|
On mock iCal pour échouer, pronotepy configuré sans ent. On vérifie que pronotepy est appelé
|
||||||
|
et que le résultat provient de pronotepy, pas une erreur.
|
||||||
|
|
||||||
|
:param mock_fetcher: Fetcher de test.
|
||||||
|
:return: None
|
||||||
|
:rtype: None
|
||||||
|
"""
|
||||||
|
start_dt = datetime(2025, 9, 1, 8, 0)
|
||||||
|
end_dt = datetime(2025, 9, 1, 9, 30)
|
||||||
|
lessons = [
|
||||||
|
Lesson(
|
||||||
|
id="l1",
|
||||||
|
start=start_dt,
|
||||||
|
end=end_dt,
|
||||||
|
subject="Maths",
|
||||||
|
teachers=("Dupont",),
|
||||||
|
rooms=("S1",),
|
||||||
|
group="2ndeA",
|
||||||
|
status=LessonStatus.NORMAL,
|
||||||
|
content=None,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Override settings to use auto mode with ical_url configured but ent=None
|
||||||
|
mock_fetcher._settings.pronote.agenda_source = "auto"
|
||||||
|
mock_fetcher._settings.pronote.ent = None # Explicitly None
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
|
||||||
|
patch("pronote_sync.sources.pronote.fallback.parse_ical") as m_parse_ical,
|
||||||
|
):
|
||||||
|
m_fetch_ical.side_effect = OSError("iCal unreachable")
|
||||||
|
m_parse_ical.side_effect = OSError("iCal parse error")
|
||||||
|
client = MagicMock()
|
||||||
|
client.get_lessons.return_value = lessons
|
||||||
|
mock_fetcher._pronote_client = client
|
||||||
|
|
||||||
|
result_lessons, result_events = mock_fetcher.fetch_agenda()
|
||||||
|
|
||||||
|
assert result_lessons == lessons
|
||||||
|
assert result_events == []
|
||||||
|
client.get_lessons.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_homework_auto_ical_configured_fails_fallback_to_pronotepy_without_ent(
|
||||||
|
mock_fetcher: PronoteFetcher,
|
||||||
|
) -> None:
|
||||||
|
"""Test le mode auto des devoirs : iCal configuré mais échoue, repli sur pronotepy sans ent.
|
||||||
|
|
||||||
|
On mock iCal pour échouer, pronotepy configuré sans ent. On vérifie que pronotepy est appelé
|
||||||
|
et que le résultat provient de pronotepy, pas une erreur.
|
||||||
|
|
||||||
|
:param mock_fetcher: Fetcher de test.
|
||||||
|
:return: None
|
||||||
|
:rtype: None
|
||||||
|
"""
|
||||||
|
target_date = date(2025, 9, 10)
|
||||||
|
homeworks = [
|
||||||
|
Homework(
|
||||||
|
id="hw1",
|
||||||
|
subject="Physique",
|
||||||
|
teachers=(),
|
||||||
|
assigned_on=None,
|
||||||
|
due_on=target_date,
|
||||||
|
text="TP à préparer",
|
||||||
|
html="TP à préparer",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Override settings to use auto mode with ical_url configured but ent=None
|
||||||
|
mock_fetcher._settings.pronote.homework_source = "auto"
|
||||||
|
mock_fetcher._settings.pronote.ent = None # Explicitly None
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("pronote_sync.sources.pronote.fallback.fetch_ical") as m_fetch_ical,
|
||||||
|
patch("pronote_sync.sources.pronote.fallback.parse_ical") as m_parse_ical,
|
||||||
|
patch("pronote_sync.sources.pronote.fallback.collect_homeworks") as m_collect,
|
||||||
|
):
|
||||||
|
m_fetch_ical.side_effect = OSError("iCal unreachable")
|
||||||
|
m_parse_ical.side_effect = OSError("iCal parse error")
|
||||||
|
client = MagicMock()
|
||||||
|
client.get_homeworks.return_value = homeworks
|
||||||
|
mock_fetcher._pronote_client = client
|
||||||
|
m_collect.return_value = homeworks
|
||||||
|
|
||||||
|
result = mock_fetcher.fetch_homework(target_date)
|
||||||
|
|
||||||
|
assert result == homeworks
|
||||||
|
client.get_homeworks.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_homework_fallback_both_fail_raises_pipeline_critical_error(
|
def test_fetch_homework_fallback_both_fail_raises_pipeline_critical_error(
|
||||||
mock_fetcher: PronoteFetcher,
|
mock_fetcher: PronoteFetcher,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -1127,7 +1223,7 @@ def test_fetch_homework_fallback_both_fail_raises_pipeline_critical_error(
|
|||||||
def test_fetch_homework_auto_fallback_returns_empty_logs_warning(
|
def test_fetch_homework_auto_fallback_returns_empty_logs_warning(
|
||||||
mock_fetcher: PronoteFetcher, caplog: pytest.LogCaptureFixture
|
mock_fetcher: PronoteFetcher, caplog: pytest.LogCaptureFixture
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test que fetch_homework retourne [] et journalise un avertissement si le repli retourne vide.
|
"""Test que fetch_homework retourne [] et journalise un avertissement si le repli est vide.
|
||||||
|
|
||||||
On mock ICAL pour échouer, pronotepy configuré et retourne vide. On vérifie le retour et le log.
|
On mock ICAL pour échouer, pronotepy configuré et retourne vide. On vérifie le retour et le log.
|
||||||
Ce test utilise le mode AUTO pour tester le comportement de repli.
|
Ce test utilise le mode AUTO pour tester le comportement de repli.
|
||||||
@@ -1186,4 +1282,99 @@ def test_fetch_informations_logs_and_re_raises_secret(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_pronotepy_configured_without_ent_returns_true() -> None:
|
||||||
|
"""Test _is_pronotepy_configured() retourne True quand ent est None.
|
||||||
|
|
||||||
|
Les autres champs de la configuration pronotepy sont présents, donc la
|
||||||
|
fonction renvoie True.
|
||||||
|
|
||||||
|
Ce test valide que PRONOTE_ENT est optionnel pour pronotepy.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
from pronote_sync.config.settings import PronoteSettings, Settings
|
||||||
|
from pronote_sync.sources.pronote.fallback import PronoteFetcher
|
||||||
|
|
||||||
|
# Créer des settings avec pronotepy configuré mais sans ent
|
||||||
|
settings = Settings(
|
||||||
|
pronote=PronoteSettings(
|
||||||
|
pronote_url="https://pronote.example.com",
|
||||||
|
username="testuser",
|
||||||
|
password=SecretStr("testpass"),
|
||||||
|
ent=None, # Explicitement None
|
||||||
|
agenda_source="pronotepy",
|
||||||
|
homework_source="pronotepy",
|
||||||
|
),
|
||||||
|
app=Settings().app,
|
||||||
|
)
|
||||||
|
|
||||||
|
client: _MockPronoteClientProtocol = MagicMock()
|
||||||
|
fetcher = PronoteFetcher(settings=settings, pronote_client=client)
|
||||||
|
|
||||||
|
# Should return True even without ent
|
||||||
|
assert fetcher._is_pronotepy_configured() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_agenda_sources_auto_without_ical_and_without_ent_returns_pronotepy() -> None:
|
||||||
|
"""Test _agenda_sources() en mode AUTO sans iCal URL et sans ent retourne pronotepy.
|
||||||
|
|
||||||
|
Ce test valide que le mode auto peut utiliser pronotepy même sans ent configuré.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
from pronote_sync.config.settings import PronoteSettings, Settings
|
||||||
|
from pronote_sync.sources.pronote.fallback import PronoteFetcher
|
||||||
|
|
||||||
|
settings = Settings(
|
||||||
|
pronote=PronoteSettings(
|
||||||
|
pronote_url="https://pronote.example.com",
|
||||||
|
username="testuser",
|
||||||
|
password=SecretStr("testpass"),
|
||||||
|
ent=None, # Explicitement None
|
||||||
|
ical_url=None, # Pas de iCal URL
|
||||||
|
agenda_source="auto",
|
||||||
|
),
|
||||||
|
app=Settings().app,
|
||||||
|
)
|
||||||
|
|
||||||
|
client: _MockPronoteClientProtocol = MagicMock()
|
||||||
|
fetcher = PronoteFetcher(settings=settings, pronote_client=client)
|
||||||
|
|
||||||
|
primary, fallback = fetcher._agenda_sources()
|
||||||
|
|
||||||
|
assert primary == "pronotepy"
|
||||||
|
assert fallback is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_homework_sources_auto_without_ical_and_without_ent_returns_pronotepy() -> None:
|
||||||
|
"""Test _homework_sources() en mode AUTO sans iCal URL et sans ent retourne pronotepy.
|
||||||
|
|
||||||
|
Ce test valide que le mode auto peut utiliser pronotepy pour les devoirs même sans ent configuré.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
from pronote_sync.config.settings import PronoteSettings, Settings
|
||||||
|
from pronote_sync.sources.pronote.fallback import PronoteFetcher
|
||||||
|
|
||||||
|
settings = Settings(
|
||||||
|
pronote=PronoteSettings(
|
||||||
|
pronote_url="https://pronote.example.com",
|
||||||
|
username="testuser",
|
||||||
|
password=SecretStr("testpass"),
|
||||||
|
ent=None, # Explicitement None
|
||||||
|
ical_url=None, # Pas de iCal URL
|
||||||
|
homework_source="auto",
|
||||||
|
),
|
||||||
|
app=Settings().app,
|
||||||
|
)
|
||||||
|
|
||||||
|
client: _MockPronoteClientProtocol = MagicMock()
|
||||||
|
fetcher = PronoteFetcher(settings=settings, pronote_client=client)
|
||||||
|
|
||||||
|
primary, fallback = fetcher._homework_sources()
|
||||||
|
|
||||||
|
assert primary == "pronotepy"
|
||||||
|
assert fallback is None
|
||||||
|
|
||||||
|
|
||||||
# Ensure trailing newline
|
# Ensure trailing newline
|
||||||
|
|||||||
@@ -309,7 +309,9 @@ def test_missing_credentials_raises(empty_pronote_settings: PronoteSettings) ->
|
|||||||
"""
|
"""
|
||||||
client = PronoteClient(empty_pronote_settings)
|
client = PronoteClient(empty_pronote_settings)
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="pronote_url, username, password et ent sont requis"):
|
with pytest.raises(
|
||||||
|
ValueError, match="pronote_url, username et password sont requis pour pronotepy"
|
||||||
|
):
|
||||||
client._connect()
|
client._connect()
|
||||||
|
|
||||||
|
|
||||||
@@ -365,6 +367,132 @@ def test_connect_parent_account_type(
|
|||||||
pronotepy.Client.assert_not_called() # type: ignore[attr-defined]
|
pronotepy.Client.assert_not_called() # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
|
||||||
|
def test_connect_without_ent_but_with_required_credentials(
|
||||||
|
mocker: pytest_mock.MockerFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie que _connect() fonctionne sans ent mais avec les autres identifiants requis.
|
||||||
|
|
||||||
|
Ce test valide que PRONOTE_ENT est optionnel pour une connexion directe Pronote.
|
||||||
|
|
||||||
|
:param mocker: Fixture pytest-mock pour le mocking.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
from pronote_sync.config.settings import PronoteSettings
|
||||||
|
from pronote_sync.sources.pronote.client import PronoteClient
|
||||||
|
|
||||||
|
# Settings sans ent mais avec les autres champs requis
|
||||||
|
settings = PronoteSettings(
|
||||||
|
pronote_url="https://pronote.example.com",
|
||||||
|
username="testuser",
|
||||||
|
password=SecretStr("testpass"),
|
||||||
|
ent=None, # Explicitement None
|
||||||
|
account_type="parent",
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_client = mocker.MagicMock()
|
||||||
|
mock_client_class = Mock(return_value=mock_client)
|
||||||
|
mocker.patch("pronotepy.ParentClient", new=mock_client_class)
|
||||||
|
mocker.patch("pronotepy.Client")
|
||||||
|
|
||||||
|
client = PronoteClient(settings)
|
||||||
|
connected_client = client._connect()
|
||||||
|
|
||||||
|
# Should not raise ValueError about missing ent
|
||||||
|
assert connected_client is mock_client
|
||||||
|
|
||||||
|
# Verify ParentClient was called with ent=None
|
||||||
|
mock_client_class.assert_called_once_with(
|
||||||
|
pronote_url="https://pronote.example.com",
|
||||||
|
username="testuser",
|
||||||
|
password="testpass", # pragma: allowlist secret
|
||||||
|
ent=None, # ent should be None, not resolved
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_connect_missing_required_credentials_still_raises(
|
||||||
|
mocker: pytest_mock.MockerFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie que _connect() lève ValueError si pronote_url, username ou password manquent.
|
||||||
|
|
||||||
|
Ce test valide que l'erreur ne mentionne plus ent comme requis.
|
||||||
|
|
||||||
|
:param mocker: Fixture pytest-mock pour le mocking.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
from pronote_sync.config.settings import PronoteSettings
|
||||||
|
from pronote_sync.sources.pronote.client import PronoteClient
|
||||||
|
|
||||||
|
# Settings avec ent mais sans pronote_url
|
||||||
|
settings = PronoteSettings(
|
||||||
|
pronote_url=None,
|
||||||
|
username="testuser",
|
||||||
|
password=SecretStr("testpass"),
|
||||||
|
ent=None,
|
||||||
|
account_type="parent",
|
||||||
|
)
|
||||||
|
|
||||||
|
client = PronoteClient(settings)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
client._connect()
|
||||||
|
|
||||||
|
# Error should NOT mention ent as required
|
||||||
|
assert "pronote_url, username et password sont requis" in str(exc_info.value)
|
||||||
|
assert "ent" not in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_connect_missing_username_raises(mocker: pytest_mock.MockerFixture) -> None:
|
||||||
|
"""Vérifie que _connect() lève ValueError si username manque.
|
||||||
|
|
||||||
|
:param mocker: Fixture pytest-mock pour le mocking.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
from pronote_sync.config.settings import PronoteSettings
|
||||||
|
from pronote_sync.sources.pronote.client import PronoteClient
|
||||||
|
|
||||||
|
settings = PronoteSettings(
|
||||||
|
pronote_url="https://pronote.example.com",
|
||||||
|
username=None,
|
||||||
|
password=SecretStr("testpass"),
|
||||||
|
ent=None,
|
||||||
|
account_type="parent",
|
||||||
|
)
|
||||||
|
|
||||||
|
client = PronoteClient(settings)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
client._connect()
|
||||||
|
|
||||||
|
assert "pronote_url, username et password sont requis" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_connect_missing_password_raises(mocker: pytest_mock.MockerFixture) -> None:
|
||||||
|
"""Vérifie que _connect() lève ValueError si password manque.
|
||||||
|
|
||||||
|
:param mocker: Fixture pytest-mock pour le mocking.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
from pronote_sync.config.settings import PronoteSettings
|
||||||
|
from pronote_sync.sources.pronote.client import PronoteClient
|
||||||
|
|
||||||
|
settings = PronoteSettings(
|
||||||
|
pronote_url="https://pronote.example.com",
|
||||||
|
username="testuser",
|
||||||
|
password=None,
|
||||||
|
ent=None,
|
||||||
|
account_type="parent",
|
||||||
|
)
|
||||||
|
|
||||||
|
client = PronoteClient(settings)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
client._connect()
|
||||||
|
|
||||||
|
assert "pronote_url, username et password sont requis" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
def test_connect_student_account_type(
|
def test_connect_student_account_type(
|
||||||
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
|
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -398,6 +526,57 @@ def test_connect_student_account_type(
|
|||||||
pronotepy.ParentClient.assert_not_called() # type: ignore[attr-defined]
|
pronotepy.ParentClient.assert_not_called() # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
|
||||||
|
def test_connect_with_ent_resolution_still_works(
|
||||||
|
mocker: pytest_mock.MockerFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Vérifie que _resolve_ent est appelé et fonctionne quand ent est fourni.
|
||||||
|
|
||||||
|
Ce test valide que lorsque ent est fourni, il est toujours résolu via _resolve_ent.
|
||||||
|
|
||||||
|
:param mocker: Fixture pytest-mock pour le mocking.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
from pronote_sync.config.settings import PronoteSettings
|
||||||
|
from pronote_sync.sources.pronote.client import PronoteClient
|
||||||
|
|
||||||
|
settings = PronoteSettings(
|
||||||
|
pronote_url="https://pronote.example.com",
|
||||||
|
username="testuser",
|
||||||
|
password=SecretStr("testpass"),
|
||||||
|
ent="bordeaux", # ent est fourni
|
||||||
|
account_type="parent",
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_client = mocker.MagicMock()
|
||||||
|
mock_client_class = Mock(return_value=mock_client)
|
||||||
|
mocker.patch("pronotepy.ParentClient", new=mock_client_class)
|
||||||
|
|
||||||
|
# Mock _resolve_ent to return a mock resolver
|
||||||
|
mock_resolver = Mock()
|
||||||
|
mocker.patch(
|
||||||
|
"pronote_sync.sources.pronote.client._resolve_ent",
|
||||||
|
return_value=mock_resolver,
|
||||||
|
)
|
||||||
|
|
||||||
|
client = PronoteClient(settings)
|
||||||
|
_ = client._connect()
|
||||||
|
|
||||||
|
# _resolve_ent should have been called
|
||||||
|
from pronote_sync.sources.pronote.client import _resolve_ent as resolve_ent_func
|
||||||
|
|
||||||
|
resolve_ent_func.assert_called_once_with("bordeaux") # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
# ParentClient should have been called with the resolved ent
|
||||||
|
mock_client_class.assert_called_once_with(
|
||||||
|
pronote_url="https://pronote.example.com",
|
||||||
|
username="testuser",
|
||||||
|
password="testpass", # pragma: allowlist secret
|
||||||
|
ent=mock_resolver,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_get_messages_degraded_on_error(
|
def test_get_messages_degraded_on_error(
|
||||||
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
|
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user