Ajoute le mode d'authentification PRONOTE_AUTH_MODE=qr_token comme alternative au mode password pour les instances Pronote utilisant HubEduConnect/EduConnect où l'authentification par mot de passe échoue (CAPTCHA, MFA, flux SAML). Nouveaux éléments : - PronoteSettings : auth_mode, qr_code_file, qr_pin (SecretStr) - PronoteAuthState : persistance du token rotatif dans .pronote_auth_state.json (écriture atomique, permissions 0600, symlink-safe via O_EXCL|O_NOFOLLOW) - PronoteClient._connect_qr_token() : token_login avec creds persistés, qrcode_login pour l'enrôlement initial, export_credentials persisté après chaque login réussi - PronoteAuthRotationError : levée en cas d'échec de rotation du token, propagée sans wrapping à travers PronoteFetcher et fetch_step jusqu'à PipelineRunner.run() qui notifie via XMPP (si canal disponible et dry_run inactif) - _is_pronotepy_configured() mode-aware : qr_token ne requiert que PRONOTE_URL - _collect_auth_secrets() : redaction des secrets explicites (token, PIN, jeton QR) dans tous les logs du chemin d'authentification Documentation : - .env.example : PRONOTE_AUTH_MODE, PRONOTE_QR_CODE_FILE, PRONOTE_QR_PIN - AGENTS.md : contrat d'authentification QR code / token - Wiki GuidePronote : section enrôlement, exécutions suivantes, ré-enrôlement Tests (686 passés, couverture 94.87%) : - 5 tests config QR, 9 tests auth_state, 10 tests client QR, 3 tests propagation, 4 tests intégration rotation end-to-end, 4 tests fallback mode-aware - Tests de non-fuite : sentinelles distinctes pour token, PIN, jeton QR Co-authored-by: coder/litellm/coder <coder@agents.invalid>
1203 lines
39 KiB
Python
1203 lines
39 KiB
Python
"""Tests unitaires pour le client Pronote via pronotepy.
|
|
|
|
Ce module vérifie le comportement du client ``PronoteClient`` et de son
|
|
protocole ``PronoteClientProtocol``. Tous les tests sont unitaires et
|
|
utilisent des mocks pour éviter tout accès réseau réel à Pronote.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from datetime import date, datetime
|
|
from pathlib import Path
|
|
|
|
import pronotepy
|
|
import pytest
|
|
import pytest_mock
|
|
from pydantic import SecretStr
|
|
|
|
from pronote_sync.config.settings import PronoteSettings
|
|
from pronote_sync.errors import PronoteAuthRotationError
|
|
from pronote_sync.models.agenda import Lesson, LessonStatus
|
|
from pronote_sync.models.homework import Homework
|
|
from pronote_sync.models.message import Message, MessageType
|
|
from pronote_sync.sources.pronote.auth_state import PronoteAuthState
|
|
from pronote_sync.sources.pronote.client import PronoteClient, PronoteClientProtocol
|
|
|
|
# --- Protocol tests ---
|
|
|
|
|
|
def test_protocol_methods(mocker: pytest_mock.MockerFixture) -> None:
|
|
"""Vérifie que le protocole PronoteClientProtocol expose les méthodes attendues.
|
|
|
|
:param mocker: Fixture pytest-mock pour le mocking.
|
|
:return: None
|
|
"""
|
|
assert hasattr(PronoteClientProtocol, "get_messages")
|
|
assert hasattr(PronoteClientProtocol, "get_informations")
|
|
assert hasattr(PronoteClientProtocol, "get_lessons")
|
|
assert hasattr(PronoteClientProtocol, "get_homeworks")
|
|
|
|
|
|
# --- Client with mocked pronotepy ---
|
|
|
|
|
|
@pytest.fixture
|
|
def pronote_settings() -> PronoteSettings:
|
|
"""Fournit des paramètres Pronote valides pour les tests.
|
|
|
|
:return: Instance de PronoteSettings avec des valeurs par défaut.
|
|
:rtype: PronoteSettings
|
|
"""
|
|
return PronoteSettings(
|
|
url="https://pronote.example.com",
|
|
username="testuser",
|
|
password=SecretStr("testpass"),
|
|
ent="bordeaux",
|
|
account_type="parent",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def empty_pronote_settings() -> PronoteSettings:
|
|
"""Fournit des paramètres Pronote vides pour les tests.
|
|
|
|
:return: Instance de PronoteSettings avec tous les champs à None.
|
|
:rtype: PronoteSettings
|
|
"""
|
|
return PronoteSettings(username=None, password=None, ent=None)
|
|
|
|
|
|
def test_get_messages_success(
|
|
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
|
|
) -> None:
|
|
"""Vérifie que get_messages retourne une liste de Message en cas de succès.
|
|
|
|
:param mocker: Fixture pytest-mock pour le mocking.
|
|
:param pronote_settings: Paramètres Pronote valides.
|
|
:return: None
|
|
"""
|
|
# Mock du client pronotepy
|
|
mock_client = mocker.MagicMock()
|
|
mock_discussion = mocker.MagicMock()
|
|
mock_discussion.subject = "Test Subject"
|
|
mock_message = mocker.MagicMock()
|
|
mock_message.id = "msg-123"
|
|
mock_message.content = "Test message content"
|
|
mock_message.author = "Teacher Test"
|
|
mock_message.created = datetime(2024, 9, 1, 10, 0, 0)
|
|
mock_message.seen = True
|
|
mock_discussion.messages = [mock_message]
|
|
mock_client.discussions.return_value = [mock_discussion]
|
|
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
|
|
|
|
client = PronoteClient(pronote_settings)
|
|
messages = client.get_messages()
|
|
|
|
assert isinstance(messages, list)
|
|
assert len(messages) == 1
|
|
message = messages[0]
|
|
assert isinstance(message, Message)
|
|
assert message.id == "msg-123"
|
|
assert message.type == MessageType.DISCUSSION
|
|
assert message.title == "Test Subject"
|
|
assert message.content == "Test message content"
|
|
assert message.author == "Teacher Test"
|
|
assert message.date == datetime(2024, 9, 1, 10, 0, 0)
|
|
assert message.read is True
|
|
|
|
|
|
def test_get_messages_empty_on_error(
|
|
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
|
|
) -> None:
|
|
"""Vérifie que get_messages retourne une liste vide en cas d'erreur API.
|
|
|
|
:param mocker: Fixture pytest-mock pour le mocking.
|
|
:param pronote_settings: Paramètres Pronote valides.
|
|
:return: None
|
|
"""
|
|
mock_client = mocker.MagicMock()
|
|
mock_client.discussions.side_effect = pronotepy.PronoteAPIError("API error")
|
|
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
|
|
|
|
client = PronoteClient(pronote_settings)
|
|
messages = client.get_messages()
|
|
|
|
assert messages == []
|
|
|
|
|
|
def test_get_informations_success(
|
|
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
|
|
) -> None:
|
|
"""Vérifie que get_informations retourne une liste de Message en cas de succès.
|
|
|
|
:param mocker: Fixture pytest-mock pour le mocking.
|
|
:param pronote_settings: Paramètres Pronote valides.
|
|
:return: None
|
|
"""
|
|
mock_client = mocker.MagicMock()
|
|
mock_info = mocker.MagicMock()
|
|
mock_info.id = "info-456"
|
|
mock_info.title = "Important Info"
|
|
mock_info.content.return_value = "Important content"
|
|
mock_info.author = "Admin"
|
|
mock_info.creation_date = datetime(2024, 9, 2, 14, 30, 0)
|
|
mock_info.read = False
|
|
mock_info.survey = True
|
|
mock_client.information_and_surveys.return_value = [mock_info]
|
|
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
|
|
|
|
client = PronoteClient(pronote_settings)
|
|
messages = client.get_informations()
|
|
|
|
assert isinstance(messages, list)
|
|
assert len(messages) == 1
|
|
message = messages[0]
|
|
assert isinstance(message, Message)
|
|
assert message.id == "info-456"
|
|
assert message.type == MessageType.SURVEY
|
|
assert message.title == "Important Info"
|
|
assert message.content == "Important content"
|
|
assert message.author == "Admin"
|
|
assert message.date == datetime(2024, 9, 2, 14, 30, 0)
|
|
assert message.read is False
|
|
|
|
|
|
def test_get_informations_empty_on_error(
|
|
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
|
|
) -> None:
|
|
"""Vérifie que get_informations retourne une liste vide en cas d'erreur API.
|
|
|
|
:param mocker: Fixture pytest-mock pour le mocking.
|
|
:param pronote_settings: Paramètres Pronote valides.
|
|
:return: None
|
|
"""
|
|
mock_client = mocker.MagicMock()
|
|
mock_client.information_and_surveys.side_effect = pronotepy.PronoteAPIError("API error")
|
|
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
|
|
|
|
client = PronoteClient(pronote_settings)
|
|
messages = client.get_informations()
|
|
|
|
assert messages == []
|
|
|
|
|
|
def test_get_lessons_success(
|
|
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
|
|
) -> None:
|
|
"""Vérifie que get_lessons retourne une liste de Lesson en cas de succès.
|
|
|
|
:param mocker: Fixture pytest-mock pour le mocking.
|
|
:param pronote_settings: Paramètres Pronote valides.
|
|
:return: None
|
|
"""
|
|
mock_client = mocker.MagicMock()
|
|
|
|
# Mock des cours
|
|
mock_lesson = mocker.MagicMock()
|
|
mock_lesson.id = "lesson-789"
|
|
mock_lesson.start = datetime(2024, 9, 1, 8, 0, 0)
|
|
mock_lesson.end = datetime(2024, 9, 1, 9, 30, 0)
|
|
mock_lesson.subject = mocker.MagicMock()
|
|
mock_lesson.subject.name = "Maths"
|
|
mock_lesson.teacher_names = ["Prof A", "Prof B"]
|
|
mock_lesson.classrooms = ["Salle 101", "Salle 102"]
|
|
mock_lesson.group_name = "Classe 1"
|
|
mock_lesson.canceled = False
|
|
mock_content = mocker.MagicMock()
|
|
mock_content.description = "Lesson content"
|
|
mock_lesson.content = mock_content
|
|
|
|
mock_client.lessons.return_value = [mock_lesson]
|
|
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
|
|
|
|
client = PronoteClient(pronote_settings)
|
|
lessons = client.get_lessons(date(2024, 9, 1), date(2024, 9, 30))
|
|
|
|
assert isinstance(lessons, list)
|
|
assert len(lessons) == 1
|
|
lesson = lessons[0]
|
|
assert isinstance(lesson, Lesson)
|
|
assert lesson.id == "lesson-789"
|
|
assert lesson.start == datetime(2024, 9, 1, 8, 0, 0)
|
|
assert lesson.end == datetime(2024, 9, 1, 9, 30, 0)
|
|
assert lesson.subject == "Maths"
|
|
assert lesson.teachers == ("Prof A", "Prof B")
|
|
assert lesson.rooms == ("Salle 101", "Salle 102")
|
|
assert lesson.group == "Classe 1"
|
|
assert lesson.status == LessonStatus.NORMAL
|
|
assert lesson.content == "Lesson content"
|
|
|
|
|
|
def test_get_homeworks_success(
|
|
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
|
|
) -> None:
|
|
"""Vérifie que get_homeworks retourne une liste de Homework en cas de succès.
|
|
|
|
:param mocker: Fixture pytest-mock pour le mocking.
|
|
:param pronote_settings: Paramètres Pronote valides.
|
|
:return: None
|
|
"""
|
|
mock_client = mocker.MagicMock()
|
|
|
|
# Mock des devoirs
|
|
mock_hw = mocker.MagicMock()
|
|
mock_hw.id = "hw-101"
|
|
mock_hw.subject = mocker.MagicMock()
|
|
mock_hw.subject.name = "Maths"
|
|
mock_hw.date = date(2024, 9, 15)
|
|
mock_hw.description = "Do your homework"
|
|
|
|
mock_client.homework.return_value = [mock_hw]
|
|
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
|
|
|
|
client = PronoteClient(pronote_settings)
|
|
homeworks = client.get_homeworks(date(2024, 9, 1), date(2024, 9, 30))
|
|
|
|
assert isinstance(homeworks, list)
|
|
assert len(homeworks) == 1
|
|
homework = homeworks[0]
|
|
assert isinstance(homework, Homework)
|
|
assert homework.id == "hw-101"
|
|
assert homework.subject == "Maths"
|
|
assert homework.teachers == ()
|
|
assert homework.assigned_on is None
|
|
assert homework.due_on == date(2024, 9, 15)
|
|
assert homework.text == "Do your homework"
|
|
assert homework.html == "Do your homework"
|
|
|
|
|
|
def test_get_lessons_propagates_error(
|
|
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
|
|
) -> None:
|
|
"""Vérifie que get_lessons propage les exceptions API.
|
|
|
|
:param mocker: Fixture pytest-mock pour le mocking.
|
|
:param pronote_settings: Paramètres Pronote valides.
|
|
:return: None
|
|
"""
|
|
mock_client = mocker.MagicMock()
|
|
mock_client.lessons.side_effect = pronotepy.PronoteAPIError("API error")
|
|
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
|
|
|
|
client = PronoteClient(pronote_settings)
|
|
|
|
with pytest.raises(pronotepy.PronoteAPIError):
|
|
client.get_lessons(date(2024, 9, 1), date(2024, 9, 30))
|
|
|
|
|
|
def test_get_homeworks_propagates_error(
|
|
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
|
|
) -> None:
|
|
"""Vérifie que get_homeworks propage les exceptions API.
|
|
|
|
:param mocker: Fixture pytest-mock pour le mocking.
|
|
:param pronote_settings: Paramètres Pronote valides.
|
|
:return: None
|
|
"""
|
|
mock_client = mocker.MagicMock()
|
|
mock_client.homework.side_effect = pronotepy.PronoteAPIError("API error")
|
|
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
|
|
|
|
client = PronoteClient(pronote_settings)
|
|
|
|
with pytest.raises(pronotepy.PronoteAPIError):
|
|
client.get_homeworks(date(2024, 9, 1), date(2024, 9, 30))
|
|
|
|
|
|
def test_missing_credentials_raises(empty_pronote_settings: PronoteSettings) -> None:
|
|
"""Vérifie que les appels échouent avec ValueError si les identifiants sont manquants.
|
|
|
|
:param empty_pronote_settings: Paramètres Pronote avec tous les champs à None.
|
|
:return: None
|
|
"""
|
|
client = PronoteClient(empty_pronote_settings)
|
|
|
|
with pytest.raises(ValueError, match="url, username et password sont requis pour pronotepy"):
|
|
client._connect()
|
|
|
|
|
|
def test_connect_with_ent_resolution(mocker: pytest_mock.MockerFixture) -> None:
|
|
"""Vérifie que _resolve_ent retourne le callable attendu pour un ENT connu.
|
|
|
|
:param mocker: Fixture pytest-mock pour le mocking.
|
|
:return: None
|
|
"""
|
|
from pronote_sync.sources.pronote.client import _resolve_ent
|
|
|
|
resolver = _resolve_ent("bordeaux")
|
|
assert resolver is not None
|
|
|
|
|
|
def test_connect_with_unknown_ent_raises(mocker: pytest_mock.MockerFixture) -> None:
|
|
"""Vérifie que _resolve_ent lève ValueError pour un ENT inconnu.
|
|
|
|
:param mocker: Fixture pytest-mock pour le mocking.
|
|
:return: None
|
|
"""
|
|
from pronote_sync.sources.pronote.client import _resolve_ent
|
|
|
|
with pytest.raises(ValueError) as exc_info:
|
|
_resolve_ent("inconnu")
|
|
assert "ENT inconnu : 'inconnu'" in str(exc_info.value)
|
|
assert "ENT supportés :" in str(exc_info.value)
|
|
|
|
|
|
def test_connect_parent_account_type(
|
|
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
|
|
) -> None:
|
|
"""Vérifie que account_type='parent' utilise pronotepy.ParentClient.
|
|
|
|
:param mocker: Fixture pytest-mock pour le mocking.
|
|
:param pronote_settings: Paramètres Pronote valides.
|
|
:return: None
|
|
"""
|
|
from unittest.mock import Mock
|
|
|
|
from pronote_sync.sources.pronote.client import PronoteClient
|
|
|
|
mock_client = mocker.MagicMock()
|
|
mock_client_class = Mock(return_value=mock_client)
|
|
mocker.patch("pronotepy.ParentClient", new=mock_client_class)
|
|
mocker.patch("pronotepy.Client")
|
|
|
|
client = PronoteClient(pronote_settings)
|
|
_ = client._connect()
|
|
|
|
# Verify ParentClient was used
|
|
assert mock_client_class.call_count == 1
|
|
pronotepy.Client.assert_not_called() # type: ignore[attr-defined]
|
|
|
|
|
|
def test_connect_without_ent_but_with_required_credentials(
|
|
mocker: pytest_mock.MockerFixture,
|
|
) -> None:
|
|
"""Vérifie que _connect() fonctionne sans ent mais avec les autres identifiants requis.
|
|
|
|
Ce test valide que PRONOTE_ENT est optionnel pour une connexion directe Pronote.
|
|
|
|
:param mocker: Fixture pytest-mock pour le mocking.
|
|
:return: None
|
|
"""
|
|
from unittest.mock import Mock
|
|
|
|
from pronote_sync.config.settings import PronoteSettings
|
|
from pronote_sync.sources.pronote.client import PronoteClient
|
|
|
|
# Settings sans ent mais avec les autres champs requis
|
|
settings = PronoteSettings(
|
|
url="https://pronote.example.com",
|
|
username="testuser",
|
|
password=SecretStr("testpass"),
|
|
ent=None, # Explicitement None
|
|
account_type="parent",
|
|
)
|
|
|
|
mock_client = mocker.MagicMock()
|
|
mock_client_class = Mock(return_value=mock_client)
|
|
mocker.patch("pronotepy.ParentClient", new=mock_client_class)
|
|
mocker.patch("pronotepy.Client")
|
|
|
|
client = PronoteClient(settings)
|
|
connected_client = client._connect()
|
|
|
|
# Should not raise ValueError about missing ent
|
|
assert connected_client is mock_client
|
|
|
|
# Verify ParentClient was called with ent=None
|
|
mock_client_class.assert_called_once_with(
|
|
pronote_url="https://pronote.example.com",
|
|
username="testuser",
|
|
password="testpass", # pragma: allowlist secret
|
|
ent=None, # ent should be None, not resolved
|
|
)
|
|
|
|
|
|
def test_connect_missing_required_credentials_still_raises(
|
|
mocker: pytest_mock.MockerFixture,
|
|
) -> None:
|
|
"""Vérifie que _connect() lève ValueError si url, username ou password manquent.
|
|
|
|
Ce test valide que l'erreur ne mentionne plus ent comme requis.
|
|
|
|
:param mocker: Fixture pytest-mock pour le mocking.
|
|
:return: None
|
|
"""
|
|
from pronote_sync.config.settings import PronoteSettings
|
|
from pronote_sync.sources.pronote.client import PronoteClient
|
|
|
|
# Settings avec ent mais sans url
|
|
settings = PronoteSettings(
|
|
url=None,
|
|
username="testuser",
|
|
password=SecretStr("testpass"),
|
|
ent=None,
|
|
account_type="parent",
|
|
)
|
|
|
|
client = PronoteClient(settings)
|
|
|
|
with pytest.raises(ValueError) as exc_info:
|
|
client._connect()
|
|
|
|
# Error should NOT mention ent as required
|
|
assert "url, username et password sont requis" in str(exc_info.value)
|
|
assert "ent" not in str(exc_info.value)
|
|
|
|
|
|
def test_connect_missing_username_raises(mocker: pytest_mock.MockerFixture) -> None:
|
|
"""Vérifie que _connect() lève ValueError si username manque.
|
|
|
|
:param mocker: Fixture pytest-mock pour le mocking.
|
|
:return: None
|
|
"""
|
|
from pronote_sync.config.settings import PronoteSettings
|
|
from pronote_sync.sources.pronote.client import PronoteClient
|
|
|
|
settings = PronoteSettings(
|
|
url="https://pronote.example.com",
|
|
username=None,
|
|
password=SecretStr("testpass"),
|
|
ent=None,
|
|
account_type="parent",
|
|
)
|
|
|
|
client = PronoteClient(settings)
|
|
|
|
with pytest.raises(ValueError) as exc_info:
|
|
client._connect()
|
|
|
|
assert "url, username et password sont requis" in str(exc_info.value)
|
|
|
|
|
|
def test_connect_missing_password_raises(mocker: pytest_mock.MockerFixture) -> None:
|
|
"""Vérifie que _connect() lève ValueError si password manque.
|
|
|
|
:param mocker: Fixture pytest-mock pour le mocking.
|
|
:return: None
|
|
"""
|
|
from pronote_sync.config.settings import PronoteSettings
|
|
from pronote_sync.sources.pronote.client import PronoteClient
|
|
|
|
settings = PronoteSettings(
|
|
url="https://pronote.example.com",
|
|
username="testuser",
|
|
password=None,
|
|
ent=None,
|
|
account_type="parent",
|
|
)
|
|
|
|
client = PronoteClient(settings)
|
|
|
|
with pytest.raises(ValueError) as exc_info:
|
|
client._connect()
|
|
|
|
assert "url, username et password sont requis" in str(exc_info.value)
|
|
|
|
|
|
def test_connect_student_account_type(
|
|
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
|
|
) -> None:
|
|
"""Vérifie que account_type='student' utilise pronotepy.Client.
|
|
|
|
:param mocker: Fixture pytest-mock pour le mocking.
|
|
:param pronote_settings: Paramètres Pronote valides.
|
|
:return: None
|
|
"""
|
|
from unittest.mock import Mock
|
|
|
|
from pronote_sync.sources.pronote.client import PronoteClient
|
|
|
|
pronote_settings_student = PronoteSettings(
|
|
url="https://pronote.example.com",
|
|
username="testuser",
|
|
password=SecretStr("testpass"),
|
|
ent="bordeaux",
|
|
account_type="student",
|
|
)
|
|
mock_client = mocker.MagicMock()
|
|
mock_client_class = Mock(return_value=mock_client)
|
|
mocker.patch("pronotepy.Client", new=mock_client_class)
|
|
mocker.patch("pronotepy.ParentClient")
|
|
|
|
client = PronoteClient(pronote_settings_student)
|
|
_ = client._connect()
|
|
|
|
# Verify Client was used
|
|
assert mock_client_class.call_count == 1
|
|
pronotepy.ParentClient.assert_not_called() # type: ignore[attr-defined]
|
|
|
|
|
|
def test_connect_with_ent_resolution_still_works(
|
|
mocker: pytest_mock.MockerFixture,
|
|
) -> None:
|
|
"""Vérifie que _resolve_ent est appelé et fonctionne quand ent est fourni.
|
|
|
|
Ce test valide que lorsque ent est fourni, il est toujours résolu via _resolve_ent.
|
|
|
|
:param mocker: Fixture pytest-mock pour le mocking.
|
|
:return: None
|
|
"""
|
|
from unittest.mock import Mock
|
|
|
|
from pronote_sync.config.settings import PronoteSettings
|
|
from pronote_sync.sources.pronote.client import PronoteClient
|
|
|
|
settings = PronoteSettings(
|
|
url="https://pronote.example.com",
|
|
username="testuser",
|
|
password=SecretStr("testpass"),
|
|
ent="bordeaux", # ent est fourni
|
|
account_type="parent",
|
|
)
|
|
|
|
mock_client = mocker.MagicMock()
|
|
mock_client_class = Mock(return_value=mock_client)
|
|
mocker.patch("pronotepy.ParentClient", new=mock_client_class)
|
|
|
|
# Mock _resolve_ent to return a mock resolver
|
|
mock_resolver = Mock()
|
|
mocker.patch(
|
|
"pronote_sync.sources.pronote.client._resolve_ent",
|
|
return_value=mock_resolver,
|
|
)
|
|
|
|
client = PronoteClient(settings)
|
|
_ = client._connect()
|
|
|
|
# _resolve_ent should have been called
|
|
from pronote_sync.sources.pronote.client import _resolve_ent as resolve_ent_func
|
|
|
|
resolve_ent_func.assert_called_once_with("bordeaux") # type: ignore[attr-defined]
|
|
|
|
# ParentClient should have been called with the resolved ent
|
|
mock_client_class.assert_called_once_with(
|
|
pronote_url="https://pronote.example.com",
|
|
username="testuser",
|
|
password="testpass", # pragma: allowlist secret
|
|
ent=mock_resolver,
|
|
)
|
|
|
|
|
|
def test_get_messages_degraded_on_error(
|
|
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
|
|
) -> None:
|
|
"""Vérifie que get_messages retourne une liste vide en cas d'erreur réseau.
|
|
|
|
:param mocker: Fixture pytest-mock pour le mocking.
|
|
:param pronote_settings: Paramètres Pronote valides.
|
|
:return: None
|
|
"""
|
|
mock_client = mocker.MagicMock()
|
|
mock_client.discussions.side_effect = ConnectionError("Network error")
|
|
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
|
|
|
|
client = PronoteClient(pronote_settings)
|
|
messages = client.get_messages()
|
|
|
|
assert messages == []
|
|
|
|
|
|
def test_get_informations_degraded_on_error(
|
|
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
|
|
) -> None:
|
|
"""Vérifie que get_informations retourne une liste vide en cas d'erreur réseau.
|
|
|
|
:param mocker: Fixture pytest-mock pour le mocking.
|
|
:param pronote_settings: Paramètres Pronote valides.
|
|
:return: None
|
|
"""
|
|
mock_client = mocker.MagicMock()
|
|
mock_client.information_and_surveys.side_effect = TimeoutError("Timeout")
|
|
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
|
|
|
|
client = PronoteClient(pronote_settings)
|
|
messages = client.get_informations()
|
|
|
|
assert messages == []
|
|
|
|
|
|
# --- QR code / token authentication tests ---
|
|
|
|
|
|
def test_connect_password_mode_unchanged(
|
|
mocker: pytest_mock.MockerFixture,
|
|
pronote_settings: PronoteSettings,
|
|
) -> None:
|
|
"""Vérifie que le mode password conserve le comportement historique.
|
|
|
|
:param mocker: Fixture pytest-mock pour le mocking.
|
|
:param pronote_settings: Paramètres Pronote valides en mode password.
|
|
:return: None
|
|
"""
|
|
from unittest.mock import Mock
|
|
|
|
mock_client = mocker.MagicMock()
|
|
mock_client_class = Mock(return_value=mock_client)
|
|
mocker.patch("pronotepy.ParentClient", new=mock_client_class)
|
|
mocker.patch("pronotepy.Client")
|
|
|
|
client = PronoteClient(pronote_settings, auth_state=None)
|
|
connected = client._connect()
|
|
|
|
assert connected is mock_client
|
|
mock_client_class.assert_called_once_with(
|
|
pronote_url="https://pronote.example.com",
|
|
username="testuser",
|
|
password="testpass", # pragma: allowlist secret
|
|
ent=mocker.ANY,
|
|
)
|
|
# Connexion paresseuse : un second appel réutilise le client déjà créé
|
|
client._connect()
|
|
assert mock_client_class.call_count == 1
|
|
|
|
|
|
def test_connect_qr_token_with_persisted_creds(
|
|
mocker: pytest_mock.MockerFixture,
|
|
) -> None:
|
|
"""Vérifie le login par token persisté en mode qr_token.
|
|
|
|
Les credentials chargés depuis :class:`PronoteAuthState` sont rejoués via
|
|
``token_login`` et le token rotate est resauvegardé.
|
|
|
|
:param mocker: Fixture pytest-mock pour le mocking.
|
|
:return: None
|
|
"""
|
|
creds = {
|
|
"pronote_url": "https://pronote.example.com",
|
|
"username": "testuser",
|
|
"password": "persisted-token", # pragma: allowlist secret
|
|
"uuid": "persisted-uuid",
|
|
}
|
|
rotated_creds = {**creds, "uuid": "rotated-uuid"}
|
|
auth_state = mocker.MagicMock(spec=PronoteAuthState)
|
|
auth_state.load.return_value = creds
|
|
|
|
mock_client = mocker.MagicMock()
|
|
mock_client.logged_in = True
|
|
mock_client.export_credentials.return_value = rotated_creds
|
|
mocker.patch("pronotepy.ParentClient.token_login", return_value=mock_client)
|
|
|
|
settings = PronoteSettings(
|
|
url="https://pronote.example.com",
|
|
username="testuser",
|
|
password=SecretStr("testpass"),
|
|
ent=None,
|
|
account_type="parent",
|
|
auth_mode="qr_token",
|
|
)
|
|
client = PronoteClient(settings, auth_state=auth_state)
|
|
connected = client._connect()
|
|
|
|
assert connected is mock_client
|
|
pronotepy.ParentClient.token_login.assert_called_once_with(**creds) # type: ignore[attr-defined]
|
|
auth_state.save.assert_called_once_with(rotated_creds)
|
|
|
|
|
|
def test_connect_qr_token_no_creds_with_qr_code(
|
|
mocker: pytest_mock.MockerFixture,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Vérifie l'enrôlement initial par QR code quand aucun token n'est persisté.
|
|
|
|
:param mocker: Fixture pytest-mock pour le mocking.
|
|
:param tmp_path: Répertoire temporaire de test.
|
|
:return: None
|
|
"""
|
|
qr_file = tmp_path / "qr_code.json"
|
|
qr_file.write_text(
|
|
json.dumps(
|
|
{
|
|
"login": "testuser",
|
|
"jeton": "qr-jeton",
|
|
"url": "https://pronote.example.com",
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
auth_state = mocker.MagicMock(spec=PronoteAuthState)
|
|
auth_state.load.return_value = None
|
|
|
|
creds = {
|
|
"pronote_url": "https://pronote.example.com",
|
|
"username": "testuser",
|
|
"password": "new-token", # pragma: allowlist secret
|
|
"uuid": "new-uuid",
|
|
}
|
|
mock_client = mocker.MagicMock()
|
|
mock_client.logged_in = True
|
|
mock_client.export_credentials.return_value = creds
|
|
mocker.patch("pronotepy.ParentClient.qrcode_login", return_value=mock_client)
|
|
|
|
settings = PronoteSettings(
|
|
url="https://pronote.example.com",
|
|
username="testuser",
|
|
password=SecretStr("testpass"),
|
|
ent=None,
|
|
account_type="parent",
|
|
auth_mode="qr_token",
|
|
qr_code_file=str(qr_file),
|
|
qr_pin=SecretStr("123456"),
|
|
)
|
|
client = PronoteClient(settings, auth_state=auth_state)
|
|
connected = client._connect()
|
|
|
|
assert connected is mock_client
|
|
qrcode_login = pronotepy.ParentClient.qrcode_login
|
|
qrcode_login.assert_called_once() # type: ignore[attr-defined]
|
|
kwargs = qrcode_login.call_args.kwargs # type: ignore[attr-defined]
|
|
assert kwargs["pin"] == "123456"
|
|
assert kwargs["qr_code"] == {
|
|
"login": "testuser",
|
|
"jeton": "qr-jeton",
|
|
"url": "https://pronote.example.com",
|
|
}
|
|
assert kwargs["uuid"].startswith("pronote-sync-")
|
|
auth_state.save.assert_called_once_with(creds)
|
|
|
|
|
|
def test_connect_qr_token_token_login_fails_raises_rotation_error(
|
|
mocker: pytest_mock.MockerFixture,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Vérifie la levée de PronoteAuthRotationError quand le token persisté est invalide.
|
|
|
|
En cas d'échec du login par token, aucun repli vers l'enrôlement QR
|
|
n'est tenté : l'erreur de rotation est levée immédiatement, même si un
|
|
fichier QR est disponible.
|
|
|
|
:param mocker: Fixture pytest-mock pour le mocking.
|
|
:param tmp_path: Répertoire temporaire de test.
|
|
:return: None
|
|
"""
|
|
qr_file = tmp_path / "qr_code.json"
|
|
qr_file.write_text(
|
|
json.dumps(
|
|
{
|
|
"login": "testuser",
|
|
"jeton": "qr-jeton",
|
|
"url": "https://pronote.example.com",
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
auth_state = mocker.MagicMock(spec=PronoteAuthState)
|
|
auth_state.load.return_value = {
|
|
"pronote_url": "https://pronote.example.com",
|
|
"username": "testuser",
|
|
"password": "expired-token", # pragma: allowlist secret
|
|
"uuid": "old-uuid",
|
|
}
|
|
|
|
token_login = mocker.patch("pronotepy.ParentClient.token_login")
|
|
token_login.side_effect = pronotepy.PronoteAPIError("token invalide")
|
|
qrcode_login = mocker.patch("pronotepy.ParentClient.qrcode_login")
|
|
|
|
settings = PronoteSettings(
|
|
url="https://pronote.example.com",
|
|
username="testuser",
|
|
password=SecretStr("testpass"),
|
|
ent=None,
|
|
account_type="parent",
|
|
auth_mode="qr_token",
|
|
qr_code_file=str(qr_file),
|
|
qr_pin=SecretStr("123456"),
|
|
)
|
|
client = PronoteClient(settings, auth_state=auth_state)
|
|
|
|
with pytest.raises(PronoteAuthRotationError) as exc_info:
|
|
client._connect()
|
|
|
|
token_login.assert_called_once()
|
|
qrcode_login.assert_not_called()
|
|
auth_state.save.assert_not_called()
|
|
message = str(exc_info.value)
|
|
assert "expiré ou invalide" in message
|
|
assert ".pronote_auth_state.json" in message
|
|
assert "PRONOTE_QR_CODE_FILE" in message
|
|
|
|
|
|
def test_token_login_failure_raises_rotation_not_enroll(
|
|
mocker: pytest_mock.MockerFixture,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Vérifie qu'un login par token non connecté lève PronoteAuthRotationError sans enrôlement QR.
|
|
|
|
``token_login`` retourne un client non connecté (``logged_in`` False) :
|
|
l'erreur de rotation est levée immédiatement et ``qrcode_login`` n'est
|
|
jamais appelé, même avec un QR code disponible.
|
|
|
|
:param mocker: Fixture pytest-mock pour le mocking.
|
|
:param tmp_path: Répertoire temporaire de test.
|
|
:return: None
|
|
"""
|
|
qr_file = tmp_path / "qr_code.json"
|
|
qr_file.write_text(
|
|
json.dumps(
|
|
{
|
|
"login": "testuser",
|
|
"jeton": "qr-jeton",
|
|
"url": "https://pronote.example.com",
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
auth_state = mocker.MagicMock(spec=PronoteAuthState)
|
|
auth_state.load.return_value = {
|
|
"pronote_url": "https://pronote.example.com",
|
|
"username": "testuser",
|
|
"password": "expired-token", # pragma: allowlist secret
|
|
"uuid": "old-uuid",
|
|
}
|
|
|
|
mock_client = mocker.MagicMock()
|
|
mock_client.logged_in = False
|
|
token_login = mocker.patch("pronotepy.ParentClient.token_login", return_value=mock_client)
|
|
qrcode_login = mocker.patch("pronotepy.ParentClient.qrcode_login")
|
|
|
|
settings = PronoteSettings(
|
|
url="https://pronote.example.com",
|
|
username="testuser",
|
|
password=SecretStr("testpass"),
|
|
ent=None,
|
|
account_type="parent",
|
|
auth_mode="qr_token",
|
|
qr_code_file=str(qr_file),
|
|
qr_pin=SecretStr("123456"),
|
|
)
|
|
client = PronoteClient(settings, auth_state=auth_state)
|
|
|
|
with pytest.raises(PronoteAuthRotationError) as exc_info:
|
|
client._connect()
|
|
|
|
token_login.assert_called_once()
|
|
qrcode_login.assert_not_called()
|
|
auth_state.save.assert_not_called()
|
|
message = str(exc_info.value)
|
|
assert "non connecté" in message
|
|
assert ".pronote_auth_state.json" in message
|
|
|
|
|
|
def test_connect_qr_token_no_creds_no_qr_raises_rotation_error(
|
|
mocker: pytest_mock.MockerFixture,
|
|
) -> None:
|
|
"""Vérifie la levée de PronoteAuthRotationError sans token persisté ni QR code.
|
|
|
|
:param mocker: Fixture pytest-mock pour le mocking.
|
|
:return: None
|
|
"""
|
|
auth_state = mocker.MagicMock(spec=PronoteAuthState)
|
|
auth_state.load.return_value = None
|
|
|
|
settings = PronoteSettings(
|
|
url="https://pronote.example.com",
|
|
username="testuser",
|
|
password=SecretStr("testpass"),
|
|
ent=None,
|
|
account_type="parent",
|
|
auth_mode="qr_token",
|
|
)
|
|
client = PronoteClient(settings, auth_state=auth_state)
|
|
|
|
with pytest.raises(PronoteAuthRotationError) as exc_info:
|
|
client._connect()
|
|
|
|
message = str(exc_info.value)
|
|
assert "PRONOTE_QR_CODE_FILE" in message
|
|
assert "PRONOTE_QR_PIN" in message
|
|
assert ".pronote_auth_state.json" in message
|
|
|
|
|
|
def test_connect_qr_token_token_login_fails_no_qr_raises_rotation_error(
|
|
mocker: pytest_mock.MockerFixture,
|
|
) -> None:
|
|
"""Vérifie la levée de PronoteAuthRotationError quand le token échoue sans QR.
|
|
|
|
:param mocker: Fixture pytest-mock pour le mocking.
|
|
:return: None
|
|
"""
|
|
auth_state = mocker.MagicMock(spec=PronoteAuthState)
|
|
auth_state.load.return_value = {
|
|
"pronote_url": "https://pronote.example.com",
|
|
"username": "testuser",
|
|
"password": "expired-token", # pragma: allowlist secret
|
|
"uuid": "old-uuid",
|
|
}
|
|
token_login = mocker.patch("pronotepy.ParentClient.token_login")
|
|
token_login.side_effect = pronotepy.PronoteAPIError("token invalide")
|
|
|
|
settings = PronoteSettings(
|
|
url="https://pronote.example.com",
|
|
username="testuser",
|
|
password=SecretStr("testpass"),
|
|
ent=None,
|
|
account_type="parent",
|
|
auth_mode="qr_token",
|
|
)
|
|
client = PronoteClient(settings, auth_state=auth_state)
|
|
|
|
with pytest.raises(PronoteAuthRotationError) as exc_info:
|
|
client._connect()
|
|
|
|
message = str(exc_info.value)
|
|
assert "expiré ou invalide" in message
|
|
assert ".pronote_auth_state.json" in message
|
|
assert "PRONOTE_QR_CODE_FILE" in message
|
|
|
|
|
|
def test_connect_qr_token_invalid_qr_json_raises_rotation_error(
|
|
mocker: pytest_mock.MockerFixture,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Vérifie la levée de PronoteAuthRotationError pour un fichier QR illisible.
|
|
|
|
:param mocker: Fixture pytest-mock pour le mocking.
|
|
:param tmp_path: Répertoire temporaire de test.
|
|
:return: None
|
|
"""
|
|
qr_file = tmp_path / "qr_code.json"
|
|
qr_file.write_text("{json invalide", encoding="utf-8")
|
|
auth_state = mocker.MagicMock(spec=PronoteAuthState)
|
|
auth_state.load.return_value = None
|
|
|
|
settings = PronoteSettings(
|
|
url="https://pronote.example.com",
|
|
username="testuser",
|
|
password=SecretStr("testpass"),
|
|
ent=None,
|
|
account_type="parent",
|
|
auth_mode="qr_token",
|
|
qr_code_file=str(qr_file),
|
|
qr_pin=SecretStr("123456"),
|
|
)
|
|
client = PronoteClient(settings, auth_state=auth_state)
|
|
|
|
with pytest.raises(PronoteAuthRotationError) as exc_info:
|
|
client._connect()
|
|
|
|
assert "Impossible de lire le fichier QR code" in str(exc_info.value)
|
|
|
|
|
|
def test_connect_qr_token_missing_qr_key_raises_rotation_error(
|
|
mocker: pytest_mock.MockerFixture,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Vérifie la levée de PronoteAuthRotationError quand une clé QR requise manque.
|
|
|
|
:param mocker: Fixture pytest-mock pour le mocking.
|
|
:param tmp_path: Répertoire temporaire de test.
|
|
:return: None
|
|
"""
|
|
qr_file = tmp_path / "qr_code.json"
|
|
qr_file.write_text(
|
|
json.dumps({"login": "testuser", "url": "https://pronote.example.com"}),
|
|
encoding="utf-8",
|
|
)
|
|
auth_state = mocker.MagicMock(spec=PronoteAuthState)
|
|
auth_state.load.return_value = None
|
|
|
|
settings = PronoteSettings(
|
|
url="https://pronote.example.com",
|
|
username="testuser",
|
|
password=SecretStr("testpass"),
|
|
ent=None,
|
|
account_type="parent",
|
|
auth_mode="qr_token",
|
|
qr_code_file=str(qr_file),
|
|
qr_pin=SecretStr("123456"),
|
|
)
|
|
client = PronoteClient(settings, auth_state=auth_state)
|
|
|
|
with pytest.raises(PronoteAuthRotationError) as exc_info:
|
|
client._connect()
|
|
|
|
assert "jeton" in str(exc_info.value)
|
|
|
|
|
|
def test_connect_qr_token_qrcode_login_fails_raises_rotation_error(
|
|
mocker: pytest_mock.MockerFixture,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Vérifie la levée de PronoteAuthRotationError quand le login QR échoue.
|
|
|
|
:param mocker: Fixture pytest-mock pour le mocking.
|
|
:param tmp_path: Répertoire temporaire de test.
|
|
:return: None
|
|
"""
|
|
qr_file = tmp_path / "qr_code.json"
|
|
qr_file.write_text(
|
|
json.dumps(
|
|
{
|
|
"login": "testuser",
|
|
"jeton": "qr-jeton",
|
|
"url": "https://pronote.example.com",
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
auth_state = mocker.MagicMock(spec=PronoteAuthState)
|
|
auth_state.load.return_value = None
|
|
mocker.patch(
|
|
"pronotepy.ParentClient.qrcode_login",
|
|
side_effect=pronotepy.exceptions.QRCodeDecryptError("PIN incorrect"),
|
|
)
|
|
|
|
settings = PronoteSettings(
|
|
url="https://pronote.example.com",
|
|
username="testuser",
|
|
password=SecretStr("testpass"),
|
|
ent=None,
|
|
account_type="parent",
|
|
auth_mode="qr_token",
|
|
qr_code_file=str(qr_file),
|
|
qr_pin=SecretStr("123456"),
|
|
)
|
|
client = PronoteClient(settings, auth_state=auth_state)
|
|
|
|
with pytest.raises(PronoteAuthRotationError) as exc_info:
|
|
client._connect()
|
|
|
|
message = str(exc_info.value)
|
|
assert "PIN invalide ou QR code expiré" in message
|
|
assert "PRONOTE_QR_CODE_FILE" in message
|
|
|
|
|
|
def test_no_secrets_in_rotation_error_messages(
|
|
mocker: pytest_mock.MockerFixture,
|
|
tmp_path: Path,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""Vérifie qu'aucun secret ne fuit dans les erreurs ni les logs de rotation.
|
|
|
|
:param mocker: Fixture pytest-mock pour le mocking.
|
|
:param tmp_path: Répertoire temporaire de test.
|
|
:param caplog: Fixture pytest de capture des logs.
|
|
:return: None
|
|
"""
|
|
sentinel_pin = "SENTINEL_PIN_42"
|
|
sentinel_token = "SENTINEL_TOKEN_7"
|
|
sentinel_url = "https://sentinel-url.pronote.example.com"
|
|
|
|
qr_file = tmp_path / "qr_code.json"
|
|
qr_file.write_text(
|
|
json.dumps(
|
|
{
|
|
"login": "testuser",
|
|
"jeton": sentinel_token,
|
|
"url": sentinel_url,
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
auth_state = mocker.MagicMock(spec=PronoteAuthState)
|
|
auth_state.load.return_value = None
|
|
|
|
mocker.patch(
|
|
"pronotepy.ParentClient.qrcode_login",
|
|
side_effect=pronotepy.exceptions.QRCodeDecryptError(
|
|
f"token: {sentinel_token} password: {sentinel_pin}"
|
|
),
|
|
)
|
|
|
|
settings = PronoteSettings(
|
|
url="https://pronote.example.com",
|
|
username="testuser",
|
|
password=SecretStr("testpass"),
|
|
ent=None,
|
|
account_type="parent",
|
|
auth_mode="qr_token",
|
|
qr_code_file=str(qr_file),
|
|
qr_pin=SecretStr(sentinel_pin),
|
|
)
|
|
client = PronoteClient(settings, auth_state=auth_state)
|
|
|
|
with caplog.at_level(logging.ERROR, logger="pronote_sync.sources.pronote.client"):
|
|
with pytest.raises(PronoteAuthRotationError) as exc_info:
|
|
client._connect()
|
|
|
|
message = str(exc_info.value)
|
|
assert sentinel_pin not in message
|
|
assert sentinel_token not in message
|
|
assert "sentinel-url" not in message
|
|
assert caplog.text
|
|
assert sentinel_pin not in caplog.text
|
|
assert sentinel_token not in caplog.text
|
|
assert "sentinel-url" not in caplog.text
|
|
|
|
|
|
def test_no_raw_secrets_in_logs(
|
|
mocker: pytest_mock.MockerFixture,
|
|
tmp_path: Path,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""Vérifie l'expurgation de secrets bruts sans motif reconnaissable dans les logs.
|
|
|
|
Des sentinelles distinctes pour le token persisté, le PIN QR et le jeton
|
|
QR sont injectées dans le message d'exception de ``token_login`` sans
|
|
motif ``cle=valeur`` ni format d'URL ; elles ne doivent apparaître ni
|
|
dans les logs ni dans l'erreur de rotation levée.
|
|
|
|
:param mocker: Fixture pytest-mock pour le mocking.
|
|
:param tmp_path: Répertoire temporaire de test.
|
|
:param caplog: Fixture pytest de capture des logs.
|
|
:return: None
|
|
"""
|
|
sentinel_token = "SENTINEL_RAW_TOKEN_ALPHA"
|
|
sentinel_pin = "SENTINEL_RAW_PIN_BRAVO"
|
|
sentinel_jeton = "SENTINEL_RAW_JETON_CHARLIE"
|
|
|
|
qr_file = tmp_path / "qr_code.json"
|
|
qr_file.write_text(
|
|
json.dumps(
|
|
{
|
|
"login": "testuser",
|
|
"jeton": sentinel_jeton,
|
|
"url": "https://pronote.example.com",
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
auth_state = mocker.MagicMock(spec=PronoteAuthState)
|
|
auth_state.load.return_value = {
|
|
"pronote_url": "https://pronote.example.com",
|
|
"username": "testuser",
|
|
"password": sentinel_token,
|
|
"uuid": "old-uuid",
|
|
}
|
|
|
|
mocker.patch(
|
|
"pronotepy.ParentClient.token_login",
|
|
side_effect=pronotepy.PronoteAPIError(
|
|
f"login refusé {sentinel_token} puis {sentinel_pin} puis {sentinel_jeton}"
|
|
),
|
|
)
|
|
|
|
settings = PronoteSettings(
|
|
url="https://pronote.example.com",
|
|
username="testuser",
|
|
password=SecretStr("testpass"),
|
|
ent=None,
|
|
account_type="parent",
|
|
auth_mode="qr_token",
|
|
qr_code_file=str(qr_file),
|
|
qr_pin=SecretStr(sentinel_pin),
|
|
)
|
|
client = PronoteClient(settings, auth_state=auth_state)
|
|
|
|
with caplog.at_level(logging.ERROR, logger="pronote_sync.sources.pronote.client"):
|
|
with pytest.raises(PronoteAuthRotationError) as exc_info:
|
|
client._connect()
|
|
|
|
message = str(exc_info.value)
|
|
assert sentinel_token not in message
|
|
assert sentinel_pin not in message
|
|
assert sentinel_jeton not in message
|
|
assert caplog.text
|
|
assert sentinel_token not in caplog.text
|
|
assert sentinel_pin not in caplog.text
|
|
assert sentinel_jeton not in caplog.text
|
|
|
|
|
|
# Ensure trailing newline
|