Cause racine : quand pronotepy reçoit une PronoteAPIError (ex. code 20 « page expirée »), il appelle refresh() en interne, qui peut roter le token en mémoire (client.password mis à jour avec un nouveau jetonConnexionAppliMobile). Le retry peut aussi échouer — l'exception atteint get_informations()/get_messages() qui l'attrapent et retournent [] (mode dégradé). Mais _persist_credentials() n'était appelé que dans le chemin de SUCCÈS — le token rafraîchi en mémoire n'était jamais persisté. Au run suivant, token_login utilisait le token périmé → échec KeyError 'dataSec' → PronoteAuthRotationError. Correction : appeler _persist_credentials() aussi dans le chemin d'erreur de get_informations() et get_messages(), avant le return []. L'ancien token est déjà invalidé côté serveur lors du refresh — ne pas persister le nouveau token garantit la perte du seul token valide. Tests : 3 nouveaux tests (persistance dans le chemin d'erreur, sauvegarde du token rafraîchi sur erreur). 694 passés, couverture 94.93%.
1494 lines
50 KiB
Python
1494 lines
50 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
|
|
|
|
|
|
def test_persist_credentials_in_error_path_get_informations(
|
|
mocker: pytest_mock.MockerFixture,
|
|
pronote_settings: PronoteSettings,
|
|
) -> None:
|
|
"""Vérifie que get_informations persiste les credentials même en mode dégradé.
|
|
|
|
Le refresh pronotepy peut avoir roté le token en mémoire alors que la
|
|
requête échoue ; la persistance doit intervenir aussi dans le chemin
|
|
d'erreur, avant le ``return []``.
|
|
|
|
: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")
|
|
mock_client.export_credentials.return_value = {
|
|
"password": "token-apres-refresh", # pragma: allowlist secret
|
|
}
|
|
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
|
|
messages = client.get_informations()
|
|
|
|
assert messages == []
|
|
auth_state.save.assert_called_once_with(
|
|
{"password": "token-apres-refresh"} # pragma: allowlist secret
|
|
)
|
|
|
|
|
|
def test_persist_credentials_in_error_path_get_messages(
|
|
mocker: pytest_mock.MockerFixture,
|
|
pronote_settings: PronoteSettings,
|
|
) -> None:
|
|
"""Vérifie que get_messages persiste les credentials même en mode dégradé.
|
|
|
|
: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")
|
|
mock_client.export_credentials.return_value = {
|
|
"password": "token-apres-refresh", # pragma: allowlist secret
|
|
}
|
|
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
|
|
messages = client.get_messages()
|
|
|
|
assert messages == []
|
|
auth_state.save.assert_called_once_with(
|
|
{"password": "token-apres-refresh"} # pragma: allowlist secret
|
|
)
|
|
|
|
|
|
def test_persist_credentials_saves_refreshed_token_on_error(
|
|
mocker: pytest_mock.MockerFixture,
|
|
pronote_settings: PronoteSettings,
|
|
) -> None:
|
|
"""Vérifie que le token roté par le refresh pronotepy est persisté en cas d'erreur.
|
|
|
|
``export_credentials()`` retourne un mot de passe (token) différent du
|
|
token initial, simulant la rotation opérée par ``refresh()`` : c'est ce
|
|
nouveau credential qui doit être passé à ``save``.
|
|
|
|
:param mocker: Fixture pytest-mock pour le mocking.
|
|
:param pronote_settings: Paramètres Pronote valides.
|
|
:return: None
|
|
"""
|
|
initial_creds = {"password": "token-initial"} # pragma: allowlist secret
|
|
refreshed_creds = {"password": "token-rafraichi"} # pragma: allowlist secret
|
|
mock_client = mocker.MagicMock()
|
|
mock_client.information_and_surveys.side_effect = pronotepy.PronoteAPIError("API error")
|
|
mock_client.export_credentials.return_value = refreshed_creds
|
|
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
|
|
messages = client.get_informations()
|
|
|
|
assert messages == []
|
|
assert auth_state.save.call_count == 1
|
|
saved = auth_state.save.call_args.args[0]
|
|
assert saved == refreshed_creds
|
|
assert saved != initial_creds
|
|
|
|
|
|
# Ensure trailing newline
|