Cause racine : pronotepy peut rafraîchir (rotater) le token en mémoire pendant l'exécution via refresh() automatique après une PronoteAPIError. L'ancien code ne persistait les credentials qu'après le login initial, pas après les opérations de données. Le token roté en mémoire était perdu → au run suivant, token_login échouait avec le token périmé (KeyError 'dataSec'). Correction : - PronoteClient._persist_credentials() : méthode centralisée qui persiste export_credentials() après chaque opération réussie (get_lessons, get_homeworks, get_messages, get_informations) - Le token rafraîchi par le serveur pendant l'exécution est maintenant toujours persisté, même si le pipeline échoue ensuite Documentation : - .env.example : variables QR plus visibles (exemple qr_token décommentable) - AGENTS.md : QR code depuis le site web Pronote (pas l'app mobile), persistance après chaque opération de données - Wiki GuidePronote : procédure corrigée (site web, pas app Android/iOS), mention de la persistance après chaque opération Tests : 5 nouveaux tests de persistance (691 passés, couverture 94.92%)
1401 lines
47 KiB
Python
1401 lines
47 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
|
|
from unittest.mock import MagicMock
|
|
|
|
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, # pragma: allowlist secret
|
|
"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
|
|
|
|
|
|
# --- Persistence of credentials after data operations ---
|
|
|
|
|
|
def _make_auth_state_mock(mocker: pytest_mock.MockerFixture) -> MagicMock:
|
|
"""Retourne un mock de PronoteAuthState sans credentials persistés.
|
|
|
|
:param mocker: Fixture pytest-mock pour le mocking.
|
|
:return: Mock de PronoteAuthState (load → None).
|
|
"""
|
|
auth_state = mocker.MagicMock(spec=PronoteAuthState)
|
|
auth_state.load.return_value = None
|
|
return auth_state # type: ignore[no-any-return]
|
|
|
|
|
|
def _make_lessons_mock_client(mocker: pytest_mock.MockerFixture) -> MagicMock:
|
|
"""Retourne un mock client pronotepy retournant un cours exploitable.
|
|
|
|
:param mocker: Fixture pytest-mock pour le mocking.
|
|
:return: Mock client avec une leçon mockée.
|
|
"""
|
|
mock_client = mocker.MagicMock()
|
|
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"]
|
|
mock_lesson.classrooms = ["Salle 101"]
|
|
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]
|
|
return mock_client # type: ignore[no-any-return]
|
|
|
|
|
|
def test_persist_credentials_after_get_lessons(
|
|
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
|
|
) -> None:
|
|
"""Vérifie que get_lessons persiste les credentials (token rotaté) après succès.
|
|
|
|
``export_credentials()`` retourne des valeurs différentes à chaque appel :
|
|
la dernière valeur (token rotaté) doit être celle passée à ``save``.
|
|
|
|
:param mocker: Fixture pytest-mock pour le mocking.
|
|
:param pronote_settings: Paramètres Pronote valides.
|
|
:return: None
|
|
"""
|
|
mock_client = _make_lessons_mock_client(mocker)
|
|
creds_sequence = iter(
|
|
[
|
|
{"password": "token-1"}, # pragma: allowlist secret
|
|
{"password": "token-2"}, # pragma: allowlist secret
|
|
]
|
|
)
|
|
mock_client.export_credentials.side_effect = lambda: next(creds_sequence)
|
|
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
|
|
|
|
auth_state = _make_auth_state_mock(mocker)
|
|
client = PronoteClient(pronote_settings, auth_state=auth_state)
|
|
client._client = mock_client
|
|
lessons = client.get_lessons(date(2024, 9, 1), date(2024, 9, 30))
|
|
assert len(lessons) == 1
|
|
# Deuxième opération sur le même client : export_credentials() retourne
|
|
# alors le token rotaté, qui doit être celui persisté.
|
|
client.get_lessons(date(2024, 9, 1), date(2024, 9, 30))
|
|
|
|
assert auth_state.save.call_count == 2
|
|
auth_state.save.assert_called_with({"password": "token-2"}) # pragma: allowlist secret
|
|
|
|
|
|
def test_persist_credentials_after_get_homeworks(
|
|
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
|
|
) -> None:
|
|
"""Vérifie que get_homeworks persiste les credentials (token rotaté) après succès.
|
|
|
|
:param mocker: Fixture pytest-mock pour le mocking.
|
|
:param pronote_settings: Paramètres Pronote valides.
|
|
:return: None
|
|
"""
|
|
mock_client = mocker.MagicMock()
|
|
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]
|
|
creds_sequence = iter(
|
|
[
|
|
{"password": "token-1"}, # pragma: allowlist secret
|
|
{"password": "token-2"}, # pragma: allowlist secret
|
|
]
|
|
)
|
|
mock_client.export_credentials.side_effect = lambda: next(creds_sequence)
|
|
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
|
|
|
|
auth_state = _make_auth_state_mock(mocker)
|
|
client = PronoteClient(pronote_settings, auth_state=auth_state)
|
|
client._client = mock_client
|
|
homeworks = client.get_homeworks(date(2024, 9, 1), date(2024, 9, 30))
|
|
assert len(homeworks) == 1
|
|
# Deuxième opération sur le même client : export_credentials() retourne
|
|
# alors le token rotaté, qui doit être celui persisté.
|
|
client.get_homeworks(date(2024, 9, 1), date(2024, 9, 30))
|
|
|
|
assert auth_state.save.call_count == 2
|
|
auth_state.save.assert_called_with({"password": "token-2"}) # pragma: allowlist secret
|
|
|
|
|
|
def test_persist_credentials_not_called_in_password_mode(
|
|
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
|
|
) -> None:
|
|
"""Vérifie qu'aucune persistance n'est tentée sans PronoteAuthState (mode password).
|
|
|
|
:param mocker: Fixture pytest-mock pour le mocking.
|
|
:param pronote_settings: Paramètres Pronote valides.
|
|
:return: None
|
|
"""
|
|
mock_client = _make_lessons_mock_client(mocker)
|
|
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
|
|
|
|
client = PronoteClient(pronote_settings, auth_state=None)
|
|
client._client = mock_client
|
|
lessons = client.get_lessons(date(2024, 9, 1), date(2024, 9, 30))
|
|
|
|
assert len(lessons) == 1 # Aucune exception levée malgré auth_state=None
|
|
|
|
|
|
def test_persist_credentials_called_after_login(
|
|
mocker: pytest_mock.MockerFixture,
|
|
) -> None:
|
|
"""Vérifie que _connect_qr_token persiste toujours les credentials après login.
|
|
|
|
Comportement historique préservé via la nouvelle méthode
|
|
``_persist_credentials()``.
|
|
|
|
: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",
|
|
}
|
|
auth_state = _make_auth_state_mock(mocker)
|
|
auth_state.load.return_value = creds
|
|
|
|
mock_client = mocker.MagicMock()
|
|
mock_client.logged_in = True
|
|
mock_client.export_credentials.return_value = {**creds, "uuid": "rotated-uuid"}
|
|
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)
|
|
client._connect()
|
|
|
|
auth_state.save.assert_called_once_with({**creds, "uuid": "rotated-uuid"})
|
|
|
|
|
|
def test_persist_credentials_failure_does_not_crash(
|
|
mocker: pytest_mock.MockerFixture,
|
|
pronote_settings: PronoteSettings,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""Vérifie qu'un échec de persistance n'interrompt pas la récupération des cours.
|
|
|
|
:param mocker: Fixture pytest-mock pour le mocking.
|
|
:param pronote_settings: Paramètres Pronote valides.
|
|
:param caplog: Fixture pytest de capture des logs.
|
|
:return: None
|
|
"""
|
|
mock_client = _make_lessons_mock_client(mocker)
|
|
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
|
|
|
|
auth_state = _make_auth_state_mock(mocker)
|
|
auth_state.save.side_effect = OSError("disque plein")
|
|
client = PronoteClient(pronote_settings, auth_state=auth_state)
|
|
client._client = mock_client
|
|
|
|
with caplog.at_level(logging.DEBUG, logger="pronote_sync.sources.pronote.client"):
|
|
lessons = client.get_lessons(date(2024, 9, 1), date(2024, 9, 30))
|
|
|
|
assert len(lessons) == 1
|
|
assert "persistance des credentials" in caplog.text
|
|
|
|
|
|
# Ensure trailing newline
|