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:
@@ -7,7 +7,10 @@ utilisent des mocks pour éviter tout accès réseau réel à Pronote.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pronotepy
|
||||
import pytest
|
||||
@@ -15,9 +18,11 @@ import pytest_mock
|
||||
from pydantic import SecretStr
|
||||
|
||||
from pronote_sync.config.settings import PronoteSettings
|
||||
from pronote_sync.errors import PronoteAuthRotationError
|
||||
from pronote_sync.models.agenda import Lesson, LessonStatus
|
||||
from pronote_sync.models.homework import Homework
|
||||
from pronote_sync.models.message import Message, MessageType
|
||||
from pronote_sync.sources.pronote.auth_state import PronoteAuthState
|
||||
from pronote_sync.sources.pronote.client import PronoteClient, PronoteClientProtocol
|
||||
|
||||
# --- Protocol tests ---
|
||||
@@ -613,4 +618,585 @@ def test_get_informations_degraded_on_error(
|
||||
assert messages == []
|
||||
|
||||
|
||||
# --- QR code / token authentication tests ---
|
||||
|
||||
|
||||
def test_connect_password_mode_unchanged(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
pronote_settings: PronoteSettings,
|
||||
) -> None:
|
||||
"""Vérifie que le mode password conserve le comportement historique.
|
||||
|
||||
:param mocker: Fixture pytest-mock pour le mocking.
|
||||
:param pronote_settings: Paramètres Pronote valides en mode password.
|
||||
:return: None
|
||||
"""
|
||||
from unittest.mock import Mock
|
||||
|
||||
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(pronote_settings, auth_state=None)
|
||||
connected = client._connect()
|
||||
|
||||
assert connected is mock_client
|
||||
mock_client_class.assert_called_once_with(
|
||||
pronote_url="https://pronote.example.com",
|
||||
username="testuser",
|
||||
password="testpass", # pragma: allowlist secret
|
||||
ent=mocker.ANY,
|
||||
)
|
||||
# Connexion paresseuse : un second appel réutilise le client déjà créé
|
||||
client._connect()
|
||||
assert mock_client_class.call_count == 1
|
||||
|
||||
|
||||
def test_connect_qr_token_with_persisted_creds(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
) -> None:
|
||||
"""Vérifie le login par token persisté en mode qr_token.
|
||||
|
||||
Les credentials chargés depuis :class:`PronoteAuthState` sont rejoués via
|
||||
``token_login`` et le token rotate est resauvegardé.
|
||||
|
||||
:param mocker: Fixture pytest-mock pour le mocking.
|
||||
:return: None
|
||||
"""
|
||||
creds = {
|
||||
"pronote_url": "https://pronote.example.com",
|
||||
"username": "testuser",
|
||||
"password": "persisted-token", # pragma: allowlist secret
|
||||
"uuid": "persisted-uuid",
|
||||
}
|
||||
rotated_creds = {**creds, "uuid": "rotated-uuid"}
|
||||
auth_state = mocker.MagicMock(spec=PronoteAuthState)
|
||||
auth_state.load.return_value = creds
|
||||
|
||||
mock_client = mocker.MagicMock()
|
||||
mock_client.logged_in = True
|
||||
mock_client.export_credentials.return_value = rotated_creds
|
||||
mocker.patch("pronotepy.ParentClient.token_login", return_value=mock_client)
|
||||
|
||||
settings = PronoteSettings(
|
||||
url="https://pronote.example.com",
|
||||
username="testuser",
|
||||
password=SecretStr("testpass"),
|
||||
ent=None,
|
||||
account_type="parent",
|
||||
auth_mode="qr_token",
|
||||
)
|
||||
client = PronoteClient(settings, auth_state=auth_state)
|
||||
connected = client._connect()
|
||||
|
||||
assert connected is mock_client
|
||||
pronotepy.ParentClient.token_login.assert_called_once_with(**creds) # type: ignore[attr-defined]
|
||||
auth_state.save.assert_called_once_with(rotated_creds)
|
||||
|
||||
|
||||
def test_connect_qr_token_no_creds_with_qr_code(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Vérifie l'enrôlement initial par QR code quand aucun token n'est persisté.
|
||||
|
||||
:param mocker: Fixture pytest-mock pour le mocking.
|
||||
:param tmp_path: Répertoire temporaire de test.
|
||||
:return: None
|
||||
"""
|
||||
qr_file = tmp_path / "qr_code.json"
|
||||
qr_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"login": "testuser",
|
||||
"jeton": "qr-jeton",
|
||||
"url": "https://pronote.example.com",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
auth_state = mocker.MagicMock(spec=PronoteAuthState)
|
||||
auth_state.load.return_value = None
|
||||
|
||||
creds = {
|
||||
"pronote_url": "https://pronote.example.com",
|
||||
"username": "testuser",
|
||||
"password": "new-token", # pragma: allowlist secret
|
||||
"uuid": "new-uuid",
|
||||
}
|
||||
mock_client = mocker.MagicMock()
|
||||
mock_client.logged_in = True
|
||||
mock_client.export_credentials.return_value = creds
|
||||
mocker.patch("pronotepy.ParentClient.qrcode_login", return_value=mock_client)
|
||||
|
||||
settings = PronoteSettings(
|
||||
url="https://pronote.example.com",
|
||||
username="testuser",
|
||||
password=SecretStr("testpass"),
|
||||
ent=None,
|
||||
account_type="parent",
|
||||
auth_mode="qr_token",
|
||||
qr_code_file=str(qr_file),
|
||||
qr_pin=SecretStr("123456"),
|
||||
)
|
||||
client = PronoteClient(settings, auth_state=auth_state)
|
||||
connected = client._connect()
|
||||
|
||||
assert connected is mock_client
|
||||
qrcode_login = pronotepy.ParentClient.qrcode_login
|
||||
qrcode_login.assert_called_once() # type: ignore[attr-defined]
|
||||
kwargs = qrcode_login.call_args.kwargs # type: ignore[attr-defined]
|
||||
assert kwargs["pin"] == "123456"
|
||||
assert kwargs["qr_code"] == {
|
||||
"login": "testuser",
|
||||
"jeton": "qr-jeton",
|
||||
"url": "https://pronote.example.com",
|
||||
}
|
||||
assert kwargs["uuid"].startswith("pronote-sync-")
|
||||
auth_state.save.assert_called_once_with(creds)
|
||||
|
||||
|
||||
def test_connect_qr_token_token_login_fails_raises_rotation_error(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Vérifie la levée de PronoteAuthRotationError quand le token persisté est invalide.
|
||||
|
||||
En cas d'échec du login par token, aucun repli vers l'enrôlement QR
|
||||
n'est tenté : l'erreur de rotation est levée immédiatement, même si un
|
||||
fichier QR est disponible.
|
||||
|
||||
:param mocker: Fixture pytest-mock pour le mocking.
|
||||
:param tmp_path: Répertoire temporaire de test.
|
||||
:return: None
|
||||
"""
|
||||
qr_file = tmp_path / "qr_code.json"
|
||||
qr_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"login": "testuser",
|
||||
"jeton": "qr-jeton",
|
||||
"url": "https://pronote.example.com",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
auth_state = mocker.MagicMock(spec=PronoteAuthState)
|
||||
auth_state.load.return_value = {
|
||||
"pronote_url": "https://pronote.example.com",
|
||||
"username": "testuser",
|
||||
"password": "expired-token", # pragma: allowlist secret
|
||||
"uuid": "old-uuid",
|
||||
}
|
||||
|
||||
token_login = mocker.patch("pronotepy.ParentClient.token_login")
|
||||
token_login.side_effect = pronotepy.PronoteAPIError("token invalide")
|
||||
qrcode_login = mocker.patch("pronotepy.ParentClient.qrcode_login")
|
||||
|
||||
settings = PronoteSettings(
|
||||
url="https://pronote.example.com",
|
||||
username="testuser",
|
||||
password=SecretStr("testpass"),
|
||||
ent=None,
|
||||
account_type="parent",
|
||||
auth_mode="qr_token",
|
||||
qr_code_file=str(qr_file),
|
||||
qr_pin=SecretStr("123456"),
|
||||
)
|
||||
client = PronoteClient(settings, auth_state=auth_state)
|
||||
|
||||
with pytest.raises(PronoteAuthRotationError) as exc_info:
|
||||
client._connect()
|
||||
|
||||
token_login.assert_called_once()
|
||||
qrcode_login.assert_not_called()
|
||||
auth_state.save.assert_not_called()
|
||||
message = str(exc_info.value)
|
||||
assert "expiré ou invalide" in message
|
||||
assert ".pronote_auth_state.json" in message
|
||||
assert "PRONOTE_QR_CODE_FILE" in message
|
||||
|
||||
|
||||
def test_token_login_failure_raises_rotation_not_enroll(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Vérifie qu'un login par token non connecté lève PronoteAuthRotationError sans enrôlement QR.
|
||||
|
||||
``token_login`` retourne un client non connecté (``logged_in`` False) :
|
||||
l'erreur de rotation est levée immédiatement et ``qrcode_login`` n'est
|
||||
jamais appelé, même avec un QR code disponible.
|
||||
|
||||
:param mocker: Fixture pytest-mock pour le mocking.
|
||||
:param tmp_path: Répertoire temporaire de test.
|
||||
:return: None
|
||||
"""
|
||||
qr_file = tmp_path / "qr_code.json"
|
||||
qr_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"login": "testuser",
|
||||
"jeton": "qr-jeton",
|
||||
"url": "https://pronote.example.com",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
auth_state = mocker.MagicMock(spec=PronoteAuthState)
|
||||
auth_state.load.return_value = {
|
||||
"pronote_url": "https://pronote.example.com",
|
||||
"username": "testuser",
|
||||
"password": "expired-token", # pragma: allowlist secret
|
||||
"uuid": "old-uuid",
|
||||
}
|
||||
|
||||
mock_client = mocker.MagicMock()
|
||||
mock_client.logged_in = False
|
||||
token_login = mocker.patch("pronotepy.ParentClient.token_login", return_value=mock_client)
|
||||
qrcode_login = mocker.patch("pronotepy.ParentClient.qrcode_login")
|
||||
|
||||
settings = PronoteSettings(
|
||||
url="https://pronote.example.com",
|
||||
username="testuser",
|
||||
password=SecretStr("testpass"),
|
||||
ent=None,
|
||||
account_type="parent",
|
||||
auth_mode="qr_token",
|
||||
qr_code_file=str(qr_file),
|
||||
qr_pin=SecretStr("123456"),
|
||||
)
|
||||
client = PronoteClient(settings, auth_state=auth_state)
|
||||
|
||||
with pytest.raises(PronoteAuthRotationError) as exc_info:
|
||||
client._connect()
|
||||
|
||||
token_login.assert_called_once()
|
||||
qrcode_login.assert_not_called()
|
||||
auth_state.save.assert_not_called()
|
||||
message = str(exc_info.value)
|
||||
assert "non connecté" in message
|
||||
assert ".pronote_auth_state.json" in message
|
||||
|
||||
|
||||
def test_connect_qr_token_no_creds_no_qr_raises_rotation_error(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
) -> None:
|
||||
"""Vérifie la levée de PronoteAuthRotationError sans token persisté ni QR code.
|
||||
|
||||
:param mocker: Fixture pytest-mock pour le mocking.
|
||||
:return: None
|
||||
"""
|
||||
auth_state = mocker.MagicMock(spec=PronoteAuthState)
|
||||
auth_state.load.return_value = None
|
||||
|
||||
settings = PronoteSettings(
|
||||
url="https://pronote.example.com",
|
||||
username="testuser",
|
||||
password=SecretStr("testpass"),
|
||||
ent=None,
|
||||
account_type="parent",
|
||||
auth_mode="qr_token",
|
||||
)
|
||||
client = PronoteClient(settings, auth_state=auth_state)
|
||||
|
||||
with pytest.raises(PronoteAuthRotationError) as exc_info:
|
||||
client._connect()
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "PRONOTE_QR_CODE_FILE" in message
|
||||
assert "PRONOTE_QR_PIN" in message
|
||||
assert ".pronote_auth_state.json" in message
|
||||
|
||||
|
||||
def test_connect_qr_token_token_login_fails_no_qr_raises_rotation_error(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
) -> None:
|
||||
"""Vérifie la levée de PronoteAuthRotationError quand le token échoue sans QR.
|
||||
|
||||
:param mocker: Fixture pytest-mock pour le mocking.
|
||||
:return: None
|
||||
"""
|
||||
auth_state = mocker.MagicMock(spec=PronoteAuthState)
|
||||
auth_state.load.return_value = {
|
||||
"pronote_url": "https://pronote.example.com",
|
||||
"username": "testuser",
|
||||
"password": "expired-token", # pragma: allowlist secret
|
||||
"uuid": "old-uuid",
|
||||
}
|
||||
token_login = mocker.patch("pronotepy.ParentClient.token_login")
|
||||
token_login.side_effect = pronotepy.PronoteAPIError("token invalide")
|
||||
|
||||
settings = PronoteSettings(
|
||||
url="https://pronote.example.com",
|
||||
username="testuser",
|
||||
password=SecretStr("testpass"),
|
||||
ent=None,
|
||||
account_type="parent",
|
||||
auth_mode="qr_token",
|
||||
)
|
||||
client = PronoteClient(settings, auth_state=auth_state)
|
||||
|
||||
with pytest.raises(PronoteAuthRotationError) as exc_info:
|
||||
client._connect()
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "expiré ou invalide" in message
|
||||
assert ".pronote_auth_state.json" in message
|
||||
assert "PRONOTE_QR_CODE_FILE" in message
|
||||
|
||||
|
||||
def test_connect_qr_token_invalid_qr_json_raises_rotation_error(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Vérifie la levée de PronoteAuthRotationError pour un fichier QR illisible.
|
||||
|
||||
:param mocker: Fixture pytest-mock pour le mocking.
|
||||
:param tmp_path: Répertoire temporaire de test.
|
||||
:return: None
|
||||
"""
|
||||
qr_file = tmp_path / "qr_code.json"
|
||||
qr_file.write_text("{json invalide", encoding="utf-8")
|
||||
auth_state = mocker.MagicMock(spec=PronoteAuthState)
|
||||
auth_state.load.return_value = None
|
||||
|
||||
settings = PronoteSettings(
|
||||
url="https://pronote.example.com",
|
||||
username="testuser",
|
||||
password=SecretStr("testpass"),
|
||||
ent=None,
|
||||
account_type="parent",
|
||||
auth_mode="qr_token",
|
||||
qr_code_file=str(qr_file),
|
||||
qr_pin=SecretStr("123456"),
|
||||
)
|
||||
client = PronoteClient(settings, auth_state=auth_state)
|
||||
|
||||
with pytest.raises(PronoteAuthRotationError) as exc_info:
|
||||
client._connect()
|
||||
|
||||
assert "Impossible de lire le fichier QR code" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_connect_qr_token_missing_qr_key_raises_rotation_error(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Vérifie la levée de PronoteAuthRotationError quand une clé QR requise manque.
|
||||
|
||||
:param mocker: Fixture pytest-mock pour le mocking.
|
||||
:param tmp_path: Répertoire temporaire de test.
|
||||
:return: None
|
||||
"""
|
||||
qr_file = tmp_path / "qr_code.json"
|
||||
qr_file.write_text(
|
||||
json.dumps({"login": "testuser", "url": "https://pronote.example.com"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
auth_state = mocker.MagicMock(spec=PronoteAuthState)
|
||||
auth_state.load.return_value = None
|
||||
|
||||
settings = PronoteSettings(
|
||||
url="https://pronote.example.com",
|
||||
username="testuser",
|
||||
password=SecretStr("testpass"),
|
||||
ent=None,
|
||||
account_type="parent",
|
||||
auth_mode="qr_token",
|
||||
qr_code_file=str(qr_file),
|
||||
qr_pin=SecretStr("123456"),
|
||||
)
|
||||
client = PronoteClient(settings, auth_state=auth_state)
|
||||
|
||||
with pytest.raises(PronoteAuthRotationError) as exc_info:
|
||||
client._connect()
|
||||
|
||||
assert "jeton" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_connect_qr_token_qrcode_login_fails_raises_rotation_error(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Vérifie la levée de PronoteAuthRotationError quand le login QR échoue.
|
||||
|
||||
:param mocker: Fixture pytest-mock pour le mocking.
|
||||
:param tmp_path: Répertoire temporaire de test.
|
||||
:return: None
|
||||
"""
|
||||
qr_file = tmp_path / "qr_code.json"
|
||||
qr_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"login": "testuser",
|
||||
"jeton": "qr-jeton",
|
||||
"url": "https://pronote.example.com",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
auth_state = mocker.MagicMock(spec=PronoteAuthState)
|
||||
auth_state.load.return_value = None
|
||||
mocker.patch(
|
||||
"pronotepy.ParentClient.qrcode_login",
|
||||
side_effect=pronotepy.exceptions.QRCodeDecryptError("PIN incorrect"),
|
||||
)
|
||||
|
||||
settings = PronoteSettings(
|
||||
url="https://pronote.example.com",
|
||||
username="testuser",
|
||||
password=SecretStr("testpass"),
|
||||
ent=None,
|
||||
account_type="parent",
|
||||
auth_mode="qr_token",
|
||||
qr_code_file=str(qr_file),
|
||||
qr_pin=SecretStr("123456"),
|
||||
)
|
||||
client = PronoteClient(settings, auth_state=auth_state)
|
||||
|
||||
with pytest.raises(PronoteAuthRotationError) as exc_info:
|
||||
client._connect()
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "PIN invalide ou QR code expiré" in message
|
||||
assert "PRONOTE_QR_CODE_FILE" in message
|
||||
|
||||
|
||||
def test_no_secrets_in_rotation_error_messages(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Vérifie qu'aucun secret ne fuit dans les erreurs ni les logs de rotation.
|
||||
|
||||
:param mocker: Fixture pytest-mock pour le mocking.
|
||||
:param tmp_path: Répertoire temporaire de test.
|
||||
:param caplog: Fixture pytest de capture des logs.
|
||||
:return: None
|
||||
"""
|
||||
sentinel_pin = "SENTINEL_PIN_42"
|
||||
sentinel_token = "SENTINEL_TOKEN_7"
|
||||
sentinel_url = "https://sentinel-url.pronote.example.com"
|
||||
|
||||
qr_file = tmp_path / "qr_code.json"
|
||||
qr_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"login": "testuser",
|
||||
"jeton": sentinel_token,
|
||||
"url": sentinel_url,
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
auth_state = mocker.MagicMock(spec=PronoteAuthState)
|
||||
auth_state.load.return_value = None
|
||||
|
||||
mocker.patch(
|
||||
"pronotepy.ParentClient.qrcode_login",
|
||||
side_effect=pronotepy.exceptions.QRCodeDecryptError(
|
||||
f"token: {sentinel_token} password: {sentinel_pin}"
|
||||
),
|
||||
)
|
||||
|
||||
settings = PronoteSettings(
|
||||
url="https://pronote.example.com",
|
||||
username="testuser",
|
||||
password=SecretStr("testpass"),
|
||||
ent=None,
|
||||
account_type="parent",
|
||||
auth_mode="qr_token",
|
||||
qr_code_file=str(qr_file),
|
||||
qr_pin=SecretStr(sentinel_pin),
|
||||
)
|
||||
client = PronoteClient(settings, auth_state=auth_state)
|
||||
|
||||
with caplog.at_level(logging.ERROR, logger="pronote_sync.sources.pronote.client"):
|
||||
with pytest.raises(PronoteAuthRotationError) as exc_info:
|
||||
client._connect()
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert sentinel_pin not in message
|
||||
assert sentinel_token not in message
|
||||
assert "sentinel-url" not in message
|
||||
assert caplog.text
|
||||
assert sentinel_pin not in caplog.text
|
||||
assert sentinel_token not in caplog.text
|
||||
assert "sentinel-url" not in caplog.text
|
||||
|
||||
|
||||
def test_no_raw_secrets_in_logs(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Vérifie l'expurgation de secrets bruts sans motif reconnaissable dans les logs.
|
||||
|
||||
Des sentinelles distinctes pour le token persisté, le PIN QR et le jeton
|
||||
QR sont injectées dans le message d'exception de ``token_login`` sans
|
||||
motif ``cle=valeur`` ni format d'URL ; elles ne doivent apparaître ni
|
||||
dans les logs ni dans l'erreur de rotation levée.
|
||||
|
||||
:param mocker: Fixture pytest-mock pour le mocking.
|
||||
:param tmp_path: Répertoire temporaire de test.
|
||||
:param caplog: Fixture pytest de capture des logs.
|
||||
:return: None
|
||||
"""
|
||||
sentinel_token = "SENTINEL_RAW_TOKEN_ALPHA"
|
||||
sentinel_pin = "SENTINEL_RAW_PIN_BRAVO"
|
||||
sentinel_jeton = "SENTINEL_RAW_JETON_CHARLIE"
|
||||
|
||||
qr_file = tmp_path / "qr_code.json"
|
||||
qr_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"login": "testuser",
|
||||
"jeton": sentinel_jeton,
|
||||
"url": "https://pronote.example.com",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
auth_state = mocker.MagicMock(spec=PronoteAuthState)
|
||||
auth_state.load.return_value = {
|
||||
"pronote_url": "https://pronote.example.com",
|
||||
"username": "testuser",
|
||||
"password": sentinel_token,
|
||||
"uuid": "old-uuid",
|
||||
}
|
||||
|
||||
mocker.patch(
|
||||
"pronotepy.ParentClient.token_login",
|
||||
side_effect=pronotepy.PronoteAPIError(
|
||||
f"login refusé {sentinel_token} puis {sentinel_pin} puis {sentinel_jeton}"
|
||||
),
|
||||
)
|
||||
|
||||
settings = PronoteSettings(
|
||||
url="https://pronote.example.com",
|
||||
username="testuser",
|
||||
password=SecretStr("testpass"),
|
||||
ent=None,
|
||||
account_type="parent",
|
||||
auth_mode="qr_token",
|
||||
qr_code_file=str(qr_file),
|
||||
qr_pin=SecretStr(sentinel_pin),
|
||||
)
|
||||
client = PronoteClient(settings, auth_state=auth_state)
|
||||
|
||||
with caplog.at_level(logging.ERROR, logger="pronote_sync.sources.pronote.client"):
|
||||
with pytest.raises(PronoteAuthRotationError) as exc_info:
|
||||
client._connect()
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert sentinel_token not in message
|
||||
assert sentinel_pin not in message
|
||||
assert sentinel_jeton not in message
|
||||
assert caplog.text
|
||||
assert sentinel_token not in caplog.text
|
||||
assert sentinel_pin not in caplog.text
|
||||
assert sentinel_jeton not in caplog.text
|
||||
|
||||
|
||||
# Ensure trailing newline
|
||||
|
||||
Reference in New Issue
Block a user