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

@@ -1315,6 +1315,110 @@ def test_is_pronotepy_configured_without_ent_returns_true() -> None:
assert fetcher._is_pronotepy_configured() is True
def test_is_pronotepy_configured_password_mode_all_set() -> None:
"""Test _is_pronotepy_configured() en mode password avec tous les champs définis.
URL, identifiant et mot de passe sont présents : la fonction retourne True.
:return: None
:rtype: None
"""
settings = Settings(
pronote=PronoteSettings(
url="https://pronote.example.com",
username="testuser",
password=SecretStr("testpass"),
agenda_source="pronotepy",
homework_source="pronotepy",
),
app=Settings().app,
)
client: _MockPronoteClientProtocol = MagicMock()
fetcher = PronoteFetcher(settings=settings, pronote_client=client)
assert fetcher._is_pronotepy_configured() is True
def test_is_pronotepy_configured_password_mode_missing_password() -> None:
"""Test _is_pronotepy_configured() en mode password sans mot de passe.
Le mot de passe est None : la fonction retourne False.
:return: None
:rtype: None
"""
settings = Settings(
pronote=PronoteSettings(
url="https://pronote.example.com",
username="testuser",
password=None,
agenda_source="pronotepy",
homework_source="pronotepy",
),
app=Settings().app,
)
client: _MockPronoteClientProtocol = MagicMock()
fetcher = PronoteFetcher(settings=settings, pronote_client=client)
assert fetcher._is_pronotepy_configured() is False
def test_is_pronotepy_configured_qr_token_mode_url_only() -> None:
"""Test _is_pronotepy_configured() en mode qr_token avec URL uniquement.
En mode qr_token, seul l'URL est requis : l'identifiant et le mot de
passe peuvent être absents, la fonction retourne True.
:return: None
:rtype: None
"""
settings = Settings(
pronote=PronoteSettings(
url="https://pronote.example.com",
username=None,
password=None,
auth_mode="qr_token",
agenda_source="pronotepy",
homework_source="pronotepy",
),
app=Settings().app,
)
client: _MockPronoteClientProtocol = MagicMock()
fetcher = PronoteFetcher(settings=settings, pronote_client=client)
assert fetcher._is_pronotepy_configured() is True
def test_is_pronotepy_configured_qr_token_mode_no_url() -> None:
"""Test _is_pronotepy_configured() en mode qr_token sans URL.
L'URL est None : la fonction retourne False, même si le mode qr_token
ne requiert que PRONOTE_URL.
:return: None
:rtype: None
"""
settings = Settings(
pronote=PronoteSettings(
url=None,
username=None,
password=None,
auth_mode="qr_token",
agenda_source="pronotepy",
homework_source="pronotepy",
),
app=Settings().app,
)
client: _MockPronoteClientProtocol = MagicMock()
fetcher = PronoteFetcher(settings=settings, pronote_client=client)
assert fetcher._is_pronotepy_configured() is False
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.