Files
college-infos/tests/unit/test_pronote_client.py
Antoine Van Elstraete 0363898669 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>
2026-09-08 23:15:06 +02:00

1203 lines
39 KiB
Python

"""Tests unitaires pour le client Pronote via pronotepy.
Ce module vérifie le comportement du client ``PronoteClient`` et de son
protocole ``PronoteClientProtocol``. Tous les tests sont unitaires et
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
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 ---
def test_protocol_methods(mocker: pytest_mock.MockerFixture) -> None:
"""Vérifie que le protocole PronoteClientProtocol expose les méthodes attendues.
:param mocker: Fixture pytest-mock pour le mocking.
:return: None
"""
assert hasattr(PronoteClientProtocol, "get_messages")
assert hasattr(PronoteClientProtocol, "get_informations")
assert hasattr(PronoteClientProtocol, "get_lessons")
assert hasattr(PronoteClientProtocol, "get_homeworks")
# --- Client with mocked pronotepy ---
@pytest.fixture
def pronote_settings() -> PronoteSettings:
"""Fournit des paramètres Pronote valides pour les tests.
:return: Instance de PronoteSettings avec des valeurs par défaut.
:rtype: PronoteSettings
"""
return PronoteSettings(
url="https://pronote.example.com",
username="testuser",
password=SecretStr("testpass"),
ent="bordeaux",
account_type="parent",
)
@pytest.fixture
def empty_pronote_settings() -> PronoteSettings:
"""Fournit des paramètres Pronote vides pour les tests.
:return: Instance de PronoteSettings avec tous les champs à None.
:rtype: PronoteSettings
"""
return PronoteSettings(username=None, password=None, ent=None)
def test_get_messages_success(
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None:
"""Vérifie que get_messages retourne une liste de Message en cas de succès.
:param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides.
:return: None
"""
# Mock du client pronotepy
mock_client = mocker.MagicMock()
mock_discussion = mocker.MagicMock()
mock_discussion.subject = "Test Subject"
mock_message = mocker.MagicMock()
mock_message.id = "msg-123"
mock_message.content = "Test message content"
mock_message.author = "Teacher Test"
mock_message.created = datetime(2024, 9, 1, 10, 0, 0)
mock_message.seen = True
mock_discussion.messages = [mock_message]
mock_client.discussions.return_value = [mock_discussion]
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
client = PronoteClient(pronote_settings)
messages = client.get_messages()
assert isinstance(messages, list)
assert len(messages) == 1
message = messages[0]
assert isinstance(message, Message)
assert message.id == "msg-123"
assert message.type == MessageType.DISCUSSION
assert message.title == "Test Subject"
assert message.content == "Test message content"
assert message.author == "Teacher Test"
assert message.date == datetime(2024, 9, 1, 10, 0, 0)
assert message.read is True
def test_get_messages_empty_on_error(
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None:
"""Vérifie que get_messages retourne une liste vide en cas d'erreur API.
:param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides.
:return: None
"""
mock_client = mocker.MagicMock()
mock_client.discussions.side_effect = pronotepy.PronoteAPIError("API error")
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
client = PronoteClient(pronote_settings)
messages = client.get_messages()
assert messages == []
def test_get_informations_success(
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None:
"""Vérifie que get_informations retourne une liste de Message en cas de succès.
:param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides.
:return: None
"""
mock_client = mocker.MagicMock()
mock_info = mocker.MagicMock()
mock_info.id = "info-456"
mock_info.title = "Important Info"
mock_info.content.return_value = "Important content"
mock_info.author = "Admin"
mock_info.creation_date = datetime(2024, 9, 2, 14, 30, 0)
mock_info.read = False
mock_info.survey = True
mock_client.information_and_surveys.return_value = [mock_info]
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
client = PronoteClient(pronote_settings)
messages = client.get_informations()
assert isinstance(messages, list)
assert len(messages) == 1
message = messages[0]
assert isinstance(message, Message)
assert message.id == "info-456"
assert message.type == MessageType.SURVEY
assert message.title == "Important Info"
assert message.content == "Important content"
assert message.author == "Admin"
assert message.date == datetime(2024, 9, 2, 14, 30, 0)
assert message.read is False
def test_get_informations_empty_on_error(
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None:
"""Vérifie que get_informations retourne une liste vide en cas d'erreur API.
:param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides.
:return: None
"""
mock_client = mocker.MagicMock()
mock_client.information_and_surveys.side_effect = pronotepy.PronoteAPIError("API error")
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
client = PronoteClient(pronote_settings)
messages = client.get_informations()
assert messages == []
def test_get_lessons_success(
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None:
"""Vérifie que get_lessons retourne une liste de Lesson en cas de succès.
:param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides.
:return: None
"""
mock_client = mocker.MagicMock()
# Mock des cours
mock_lesson = mocker.MagicMock()
mock_lesson.id = "lesson-789"
mock_lesson.start = datetime(2024, 9, 1, 8, 0, 0)
mock_lesson.end = datetime(2024, 9, 1, 9, 30, 0)
mock_lesson.subject = mocker.MagicMock()
mock_lesson.subject.name = "Maths"
mock_lesson.teacher_names = ["Prof A", "Prof B"]
mock_lesson.classrooms = ["Salle 101", "Salle 102"]
mock_lesson.group_name = "Classe 1"
mock_lesson.canceled = False
mock_content = mocker.MagicMock()
mock_content.description = "Lesson content"
mock_lesson.content = mock_content
mock_client.lessons.return_value = [mock_lesson]
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
client = PronoteClient(pronote_settings)
lessons = client.get_lessons(date(2024, 9, 1), date(2024, 9, 30))
assert isinstance(lessons, list)
assert len(lessons) == 1
lesson = lessons[0]
assert isinstance(lesson, Lesson)
assert lesson.id == "lesson-789"
assert lesson.start == datetime(2024, 9, 1, 8, 0, 0)
assert lesson.end == datetime(2024, 9, 1, 9, 30, 0)
assert lesson.subject == "Maths"
assert lesson.teachers == ("Prof A", "Prof B")
assert lesson.rooms == ("Salle 101", "Salle 102")
assert lesson.group == "Classe 1"
assert lesson.status == LessonStatus.NORMAL
assert lesson.content == "Lesson content"
def test_get_homeworks_success(
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None:
"""Vérifie que get_homeworks retourne une liste de Homework en cas de succès.
:param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides.
:return: None
"""
mock_client = mocker.MagicMock()
# Mock des devoirs
mock_hw = mocker.MagicMock()
mock_hw.id = "hw-101"
mock_hw.subject = mocker.MagicMock()
mock_hw.subject.name = "Maths"
mock_hw.date = date(2024, 9, 15)
mock_hw.description = "Do your homework"
mock_client.homework.return_value = [mock_hw]
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
client = PronoteClient(pronote_settings)
homeworks = client.get_homeworks(date(2024, 9, 1), date(2024, 9, 30))
assert isinstance(homeworks, list)
assert len(homeworks) == 1
homework = homeworks[0]
assert isinstance(homework, Homework)
assert homework.id == "hw-101"
assert homework.subject == "Maths"
assert homework.teachers == ()
assert homework.assigned_on is None
assert homework.due_on == date(2024, 9, 15)
assert homework.text == "Do your homework"
assert homework.html == "Do your homework"
def test_get_lessons_propagates_error(
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None:
"""Vérifie que get_lessons propage les exceptions API.
:param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides.
:return: None
"""
mock_client = mocker.MagicMock()
mock_client.lessons.side_effect = pronotepy.PronoteAPIError("API error")
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
client = PronoteClient(pronote_settings)
with pytest.raises(pronotepy.PronoteAPIError):
client.get_lessons(date(2024, 9, 1), date(2024, 9, 30))
def test_get_homeworks_propagates_error(
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None:
"""Vérifie que get_homeworks propage les exceptions API.
:param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides.
:return: None
"""
mock_client = mocker.MagicMock()
mock_client.homework.side_effect = pronotepy.PronoteAPIError("API error")
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
client = PronoteClient(pronote_settings)
with pytest.raises(pronotepy.PronoteAPIError):
client.get_homeworks(date(2024, 9, 1), date(2024, 9, 30))
def test_missing_credentials_raises(empty_pronote_settings: PronoteSettings) -> None:
"""Vérifie que les appels échouent avec ValueError si les identifiants sont manquants.
:param empty_pronote_settings: Paramètres Pronote avec tous les champs à None.
:return: None
"""
client = PronoteClient(empty_pronote_settings)
with pytest.raises(ValueError, match="url, username et password sont requis pour pronotepy"):
client._connect()
def test_connect_with_ent_resolution(mocker: pytest_mock.MockerFixture) -> None:
"""Vérifie que _resolve_ent retourne le callable attendu pour un ENT connu.
:param mocker: Fixture pytest-mock pour le mocking.
:return: None
"""
from pronote_sync.sources.pronote.client import _resolve_ent
resolver = _resolve_ent("bordeaux")
assert resolver is not None
def test_connect_with_unknown_ent_raises(mocker: pytest_mock.MockerFixture) -> None:
"""Vérifie que _resolve_ent lève ValueError pour un ENT inconnu.
:param mocker: Fixture pytest-mock pour le mocking.
:return: None
"""
from pronote_sync.sources.pronote.client import _resolve_ent
with pytest.raises(ValueError) as exc_info:
_resolve_ent("inconnu")
assert "ENT inconnu : 'inconnu'" in str(exc_info.value)
assert "ENT supportés :" in str(exc_info.value)
def test_connect_parent_account_type(
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None:
"""Vérifie que account_type='parent' utilise pronotepy.ParentClient.
:param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides.
:return: None
"""
from unittest.mock import Mock
from pronote_sync.sources.pronote.client import PronoteClient
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)
_ = client._connect()
# Verify ParentClient was used
assert mock_client_class.call_count == 1
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(
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 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 url
settings = PronoteSettings(
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 "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(
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 "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(
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 "url, username et password sont requis" in str(exc_info.value)
def test_connect_student_account_type(
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None:
"""Vérifie que account_type='student' utilise pronotepy.Client.
:param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides.
:return: None
"""
from unittest.mock import Mock
from pronote_sync.sources.pronote.client import PronoteClient
pronote_settings_student = PronoteSettings(
url="https://pronote.example.com",
username="testuser",
password=SecretStr("testpass"),
ent="bordeaux",
account_type="student",
)
mock_client = mocker.MagicMock()
mock_client_class = Mock(return_value=mock_client)
mocker.patch("pronotepy.Client", new=mock_client_class)
mocker.patch("pronotepy.ParentClient")
client = PronoteClient(pronote_settings_student)
_ = client._connect()
# Verify Client was used
assert mock_client_class.call_count == 1
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(
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(
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None:
"""Vérifie que get_messages retourne une liste vide en cas d'erreur réseau.
:param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides.
:return: None
"""
mock_client = mocker.MagicMock()
mock_client.discussions.side_effect = ConnectionError("Network error")
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
client = PronoteClient(pronote_settings)
messages = client.get_messages()
assert messages == []
def test_get_informations_degraded_on_error(
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
) -> None:
"""Vérifie que get_informations retourne une liste vide en cas d'erreur réseau.
:param mocker: Fixture pytest-mock pour le mocking.
:param pronote_settings: Paramètres Pronote valides.
:return: None
"""
mock_client = mocker.MagicMock()
mock_client.information_and_surveys.side_effect = TimeoutError("Timeout")
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
client = PronoteClient(pronote_settings)
messages = client.get_informations()
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