"""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