feat: authentification QR code / token pour Pronote

Ajoute le mode d'authentification PRONOTE_AUTH_MODE=qr_token comme alternative
au mode password pour les instances Pronote utilisant HubEduConnect/EduConnect
où l'authentification par mot de passe échoue (CAPTCHA, MFA, flux SAML).

Nouveaux éléments :
- PronoteSettings : auth_mode, qr_code_file, qr_pin (SecretStr)
- PronoteAuthState : persistance du token rotatif dans .pronote_auth_state.json
  (écriture atomique, permissions 0600, symlink-safe via O_EXCL|O_NOFOLLOW)
- PronoteClient._connect_qr_token() : token_login avec creds persistés,
  qrcode_login pour l'enrôlement initial, export_credentials persisté après
  chaque login réussi
- PronoteAuthRotationError : levée en cas d'échec de rotation du token,
  propagée sans wrapping à travers PronoteFetcher et fetch_step jusqu'à
  PipelineRunner.run() qui notifie via XMPP (si canal disponible et dry_run inactif)
- _is_pronotepy_configured() mode-aware : qr_token ne requiert que PRONOTE_URL
- _collect_auth_secrets() : redaction des secrets explicites (token, PIN, jeton QR)
  dans tous les logs du chemin d'authentification

Documentation :
- .env.example : PRONOTE_AUTH_MODE, PRONOTE_QR_CODE_FILE, PRONOTE_QR_PIN
- AGENTS.md : contrat d'authentification QR code / token
- Wiki GuidePronote : section enrôlement, exécutions suivantes, ré-enrôlement

Tests (686 passés, couverture 94.87%) :
- 5 tests config QR, 9 tests auth_state, 10 tests client QR, 3 tests propagation,
  4 tests intégration rotation end-to-end, 4 tests fallback mode-aware
- Tests de non-fuite : sentinelles distinctes pour token, PIN, jeton QR

Co-authored-by: coder/litellm/coder <coder@agents.invalid>
This commit is contained in:
2026-09-08 23:15:06 +02:00
parent 4a6207f716
commit 0363898669
16 changed files with 2128 additions and 39 deletions

View File

@@ -0,0 +1,208 @@
"""Tests unitaires pour le gestionnaire d'état d'authentification Pronote.
Ce module valide le comportement de :class:`PronoteAuthState` dans
:mod:`pronote_sync.sources.pronote.auth_state`. Les tests couvrent :
- Le chargement des credentials (absent, corrompu, version invalide),
- La persistance et le rechargement des credentials,
- Les permissions ``0600`` du fichier d'état,
- La suppression via :meth:`clear`,
- L'absence de fuite des credentials dans les journaux.
Tous les tests utilisent des fichiers temporaires via la fixture ``tmp_path``.
"""
from __future__ import annotations
import json
import logging
import os
from pathlib import Path
import pytest
from pronote_sync.sources.pronote.auth_state import PronoteAuthState
def test_load_no_file_returns_none(tmp_path: Path) -> None:
"""Vérifie qu'un fichier d'état absent renvoie ``None``.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state = PronoteAuthState(tmp_path / "missing.json")
assert state.load() is None
def test_save_then_load_roundtrip(tmp_path: Path) -> None:
"""Vérifie que des credentials sauvegardés sont rechargés à l'identique.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "auth.json"
credentials = {
"pronote_url": "https://example.com/pronote",
"username": "parent-1",
"password": "token-123", # pragma: allowlist secret
"uuid": "uuid-456",
}
state = PronoteAuthState(state_file)
state.save(credentials)
loaded = PronoteAuthState(state_file).load()
assert loaded == credentials
def test_load_corrupted_json_returns_none(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
"""Vérifie qu'un fichier JSON corrompu renvoie ``None`` et journalise un avertissement.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:param caplog: Fixture pytest pour capturer les logs.
:return: None
"""
state_file = tmp_path / "corrupt.json"
state_file.write_text("not json{", encoding="utf-8")
with caplog.at_level("WARNING"):
result = PronoteAuthState(state_file).load()
assert result is None
assert "Impossible de charger le fichier d'état d'authentification Pronote" in caplog.text
def test_load_wrong_version_returns_none(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
"""Vérifie qu'une version non supportée renvoie ``None`` et journalise un avertissement.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:param caplog: Fixture pytest pour capturer les logs.
:return: None
"""
state_file = tmp_path / "wrong_version.json"
state_file.write_text(
json.dumps(
{
"version": 2,
"credentials": {
"pronote_url": "https://example.com",
"username": "u",
"password": "t",
"uuid": "i",
},
}
),
encoding="utf-8",
)
with caplog.at_level("WARNING"):
result = PronoteAuthState(state_file).load()
assert result is None
assert "version absente ou non supportée" in caplog.text
def test_load_missing_version_returns_none(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Vérifie qu'un fichier sans champ version renvoie ``None``.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:param caplog: Fixture pytest pour capturer les logs.
:return: None
"""
state_file = tmp_path / "missing_version.json"
state_file.write_text(
json.dumps(
{
"credentials": {
"pronote_url": "https://example.com",
"username": "u",
"password": "t",
"uuid": "i",
}
}
),
encoding="utf-8",
)
with caplog.at_level("WARNING"):
result = PronoteAuthState(state_file).load()
assert result is None
def test_clear_removes_file(tmp_path: Path) -> None:
"""Vérifie que clear supprime le fichier d'état existant.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "auth.json"
state = PronoteAuthState(state_file)
state.save({"pronote_url": "u", "username": "u", "password": "t", "uuid": "i"})
assert state_file.exists()
state.clear()
assert not state_file.exists()
def test_clear_no_file_noop(tmp_path: Path) -> None:
"""Vérifie que clear ne fait rien quand le fichier n'existe pas.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state = PronoteAuthState(tmp_path / "missing.json")
state.clear()
def test_save_creates_file_with_0600_permissions(tmp_path: Path) -> None:
"""Vérifie que le fichier d'état est créé avec les permissions ``0600``.
Le fichier contient un token vivant : il doit être lisible uniquement
par le propriétaire.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "auth.json"
state = PronoteAuthState(state_file)
state.save({"pronote_url": "u", "username": "u", "password": "t", "uuid": "i"})
assert os.stat(state_file).st_mode & 0o777 == 0o600
def test_no_credentials_in_logs(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
"""Vérifie qu'aucun contenu des credentials n'apparaît dans les journaux.
Des sentinelles distinctes sont utilisées pour ``pronote_url``,
``username``, ``password`` et ``uuid`` ; aucun de ces marqueurs ne doit
apparaître dans les messages journalisés lors d'une sauvegarde, d'un
chargement et d'une suppression.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:param caplog: Fixture pytest pour capturer les logs.
:return: None
"""
state_file = tmp_path / "auth.json"
credentials = {
"pronote_url": "https://SENTINEL_URL_ZZZ.example/pronote",
"username": "SENTINEL_USER_ZZZ",
"password": "SENTINEL_PASSWORD_ZZZ", # pragma: allowlist secret
"uuid": "SENTINEL_UUID_ZZZ",
}
state = PronoteAuthState(state_file)
with caplog.at_level(logging.DEBUG):
state.save(credentials)
state.load()
state.clear()
assert "SENTINEL_URL_ZZZ" not in caplog.text
assert "SENTINEL_USER_ZZZ" not in caplog.text
assert "SENTINEL_PASSWORD_ZZZ" not in caplog.text
assert "SENTINEL_UUID_ZZZ" not in caplog.text