Compare commits
3 Commits
v0.1.2
...
fix/qr-tok
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b924b55d1 | |||
| 22a662ab39 | |||
| 5188761209 |
@@ -140,7 +140,7 @@
|
||||
"filename": "GUIDE_DEV_PYTHON.md",
|
||||
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
|
||||
"is_verified": true,
|
||||
"line_number": 5064,
|
||||
"line_number": 5084,
|
||||
"is_secret": false
|
||||
}
|
||||
],
|
||||
@@ -177,5 +177,5 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"generated_at": "2026-09-08T10:45:46Z"
|
||||
"generated_at": "2026-09-10T19:26:08Z"
|
||||
}
|
||||
|
||||
@@ -2259,7 +2259,27 @@ d'informations sont non critiques et peuvent retourner une liste vide avec un wa
|
||||
Les objets renvoyés par `client.homework(start, end)` couvrent une fenêtre. Le résultat destiné à
|
||||
un jour cible est donc filtré explicitement sur `homework.date == target_date`.
|
||||
|
||||
#### 5.1.8 Logique de repli (`sources/pronote/fallback.py`)
|
||||
#### 5.1.8 Verrou du cycle d'authentification QR/token
|
||||
|
||||
En mode `qr_token`, le token Pronote est un état partagé et rotatif. Afin d'éviter que deux
|
||||
exécutions ne réutilisent ou n'écrasent cet état simultanément, le client protège chaque cycle
|
||||
d'authentification et de récupération par un verrou POSIX local non bloquant, situé dans
|
||||
`.pronote_auth_state.json.lock`, à côté de `.pronote_auth_state.json`.
|
||||
|
||||
Le verrou couvre l'ensemble du cycle QR/token : chargement de l'état, connexion par token ou
|
||||
enrôlement QR initial, opération de données (agenda, devoirs, messages ou informations), puis
|
||||
persistance des credentials actualisées. Une tentative concurrente échoue immédiatement avec une
|
||||
erreur d'état d'authentification expurgée ; elle ne patiente pas et ne relance pas
|
||||
l'authentification. Le contenu du token, le PIN et les autres credentials ne sont jamais inclus
|
||||
dans les logs ni dans ce message d'erreur.
|
||||
|
||||
Ce mécanisme est un contrat **local** : il coordonne des processus sur le même hôte Linux et un
|
||||
filesystem local. Pour des déploiements conteneurisés, les conteneurs qui partagent le même compte
|
||||
Pronote doivent également partager le fichier d'état et son fichier de verrou. Le verrou ne fournit
|
||||
aucune exclusion fiable entre plusieurs hôtes ou via NFS ; dans ces cas, l'opérateur doit prévoir
|
||||
une exclusion externe ou utiliser un token distinct par instance.
|
||||
|
||||
#### 5.1.9 Logique de repli (`sources/pronote/fallback.py`)
|
||||
|
||||
Le `PronoteFetcher` dépend de `Settings` et d'un protocole de client injecté ; il ne construit pas
|
||||
de singleton et ne contient pas d'identifiants dupliqués.
|
||||
|
||||
@@ -38,6 +38,21 @@ class PronoteAuthRotationError(PronoteSyncError):
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class PronoteAuthStateLockError(PronoteSyncError):
|
||||
"""Erreur levée lorsqu'un autre processus détient l'état d'authentification.
|
||||
|
||||
Cette erreur indique qu'une opération QR code / token concurrente est en
|
||||
cours. Son message ne contient ni chemin local sensible ni credential.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
"""Initialise l'erreur de contention du verrou d'état.
|
||||
|
||||
:param message: Message actionnable expurgé décrivant la contention.
|
||||
"""
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class ErrorSeverity(StrEnum):
|
||||
"""Niveau de gravité d'une erreur produite par le pipeline."""
|
||||
|
||||
|
||||
@@ -14,10 +14,13 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
from fcntl import LOCK_EX, LOCK_NB, LOCK_UN, flock
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pronote_sync.errors import PronoteSyncError
|
||||
from pronote_sync.errors import PronoteAuthStateLockError, PronoteSyncError
|
||||
from pronote_sync.utils.redaction import redact_exception, redact_secrets
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -108,6 +111,69 @@ class PronoteAuthState:
|
||||
credentials[key] = value
|
||||
return credentials
|
||||
|
||||
@contextmanager
|
||||
def lock(self) -> Generator[None]:
|
||||
"""Protège une opération d'état par un verrou POSIX non bloquant.
|
||||
|
||||
Le verrou est conservé dans le fichier frère ``<state_file>.lock`` afin
|
||||
de survivre à l'écriture atomique du fichier d'état. Le fichier de
|
||||
verrou reste présent après libération et est créé en ``0600`` pour ne
|
||||
pas élargir l'accès aux métadonnées de l'état sensible.
|
||||
|
||||
:return: Un gestionnaire de contexte qui tient le verrou exclusif.
|
||||
:rtype: collections.abc.Generator[None, None, None]
|
||||
:raises PronoteAuthStateLockError: Si un autre processus détient déjà
|
||||
le verrou ou si son acquisition échoue.
|
||||
"""
|
||||
lock_file = self._state_file.with_name(f"{self._state_file.name}.lock")
|
||||
descriptor: int | None = None
|
||||
try:
|
||||
descriptor = os.open(
|
||||
str(lock_file),
|
||||
os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW,
|
||||
0o600,
|
||||
)
|
||||
os.fchmod(descriptor, 0o600)
|
||||
except OSError:
|
||||
logger.error("Impossible d'ouvrir le verrou d'état d'authentification Pronote.")
|
||||
if descriptor is not None:
|
||||
os.close(descriptor)
|
||||
|
||||
if descriptor is None:
|
||||
raise PronoteAuthStateLockError(
|
||||
"Impossible d'acquérir le verrou d'état d'authentification Pronote."
|
||||
) from None
|
||||
|
||||
is_contended = False
|
||||
lock_acquisition_failed = False
|
||||
try:
|
||||
flock(descriptor, LOCK_EX | LOCK_NB)
|
||||
except BlockingIOError:
|
||||
is_contended = True
|
||||
except OSError:
|
||||
logger.error("Impossible d'acquérir le verrou d'état d'authentification Pronote.")
|
||||
os.close(descriptor)
|
||||
lock_acquisition_failed = True
|
||||
|
||||
if lock_acquisition_failed:
|
||||
raise PronoteAuthStateLockError(
|
||||
"Impossible d'acquérir le verrou d'état d'authentification Pronote."
|
||||
) from None
|
||||
|
||||
if is_contended:
|
||||
os.close(descriptor)
|
||||
raise PronoteAuthStateLockError(
|
||||
"Une autre opération d'authentification Pronote est déjà en cours."
|
||||
)
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
flock(descriptor, LOCK_UN)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
def save(self, credentials: dict[str, str]) -> None:
|
||||
"""Sauvegarde les credentials dans le fichier d'état, de manière atomique.
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
@@ -252,6 +254,26 @@ class PronoteClient:
|
||||
except Exception as exc:
|
||||
logger.debug("Échec de la persistance des credentials : %s", redact_exception(exc))
|
||||
|
||||
@contextmanager
|
||||
def _qr_token_operation_lock(self) -> Generator[None]:
|
||||
"""Verrouille un cycle d'authentification et de récupération QR/token.
|
||||
|
||||
Le verrou englobe le chargement du token, le login, l'opération de
|
||||
données et la persistance qui suit. Il est volontairement absent du
|
||||
mode ``password``, qui ne partage pas de fichier d'état de token.
|
||||
|
||||
:return: Un gestionnaire de contexte protégeant le cycle QR/token.
|
||||
:rtype: collections.abc.Generator[None, None, None]
|
||||
:raises PronoteAuthStateLockError: Si l'état QR/token est déjà utilisé
|
||||
par une autre opération.
|
||||
"""
|
||||
if self._settings.auth_mode != "qr_token" or self._auth_state is None:
|
||||
yield
|
||||
return
|
||||
|
||||
with self._auth_state.lock():
|
||||
yield
|
||||
|
||||
def _connect_password(self) -> pronotepy.Client:
|
||||
"""Connecte le client ``pronotepy`` en mode ``password``.
|
||||
|
||||
@@ -426,37 +448,38 @@ class PronoteClient:
|
||||
:return: Liste des messages des professeurs ; vide en cas d'erreur.
|
||||
:rtype: list[Message]
|
||||
"""
|
||||
try:
|
||||
client = self._connect()
|
||||
messages: list[Message] = []
|
||||
for discussion in client.discussions():
|
||||
for message in discussion.messages:
|
||||
messages.append(
|
||||
Message(
|
||||
id=message.id,
|
||||
type=MessageType.DISCUSSION,
|
||||
title=discussion.subject,
|
||||
content=message.content,
|
||||
author=message.author or "",
|
||||
date=message.created,
|
||||
read=message.seen,
|
||||
with self._qr_token_operation_lock():
|
||||
try:
|
||||
client = self._connect()
|
||||
messages: list[Message] = []
|
||||
for discussion in client.discussions():
|
||||
for message in discussion.messages:
|
||||
messages.append(
|
||||
Message(
|
||||
id=message.id,
|
||||
type=MessageType.DISCUSSION,
|
||||
title=discussion.subject,
|
||||
content=message.content,
|
||||
author=message.author or "",
|
||||
date=message.created,
|
||||
read=message.seen,
|
||||
)
|
||||
)
|
||||
)
|
||||
self._persist_credentials()
|
||||
return messages
|
||||
except (
|
||||
pronotepy.PronoteAPIError,
|
||||
ValueError,
|
||||
requests.RequestException,
|
||||
ConnectionError,
|
||||
TimeoutError,
|
||||
) as exc:
|
||||
logger.error(
|
||||
"Échec de la récupération des messages Pronote : %s",
|
||||
redact_exception(exc),
|
||||
)
|
||||
self._persist_credentials()
|
||||
return []
|
||||
self._persist_credentials()
|
||||
return messages
|
||||
except (
|
||||
pronotepy.PronoteAPIError,
|
||||
ValueError,
|
||||
requests.RequestException,
|
||||
ConnectionError,
|
||||
TimeoutError,
|
||||
) as exc:
|
||||
logger.error(
|
||||
"Échec de la récupération des messages Pronote : %s",
|
||||
redact_exception(exc),
|
||||
)
|
||||
self._persist_credentials()
|
||||
return []
|
||||
|
||||
def get_informations(self) -> list[Message]:
|
||||
"""Récupère les informations et sondages Pronote.
|
||||
@@ -467,36 +490,37 @@ class PronoteClient:
|
||||
:return: Liste des informations et sondages ; vide en cas d'erreur.
|
||||
:rtype: list[Message]
|
||||
"""
|
||||
try:
|
||||
client = self._connect()
|
||||
messages: list[Message] = []
|
||||
for info in client.information_and_surveys():
|
||||
messages.append(
|
||||
Message(
|
||||
id=info.id,
|
||||
type=MessageType.SURVEY if info.survey else MessageType.INFORMATION,
|
||||
title=info.title or "",
|
||||
content=info.content(),
|
||||
author=info.author,
|
||||
date=info.creation_date,
|
||||
read=info.read,
|
||||
with self._qr_token_operation_lock():
|
||||
try:
|
||||
client = self._connect()
|
||||
messages: list[Message] = []
|
||||
for info in client.information_and_surveys():
|
||||
messages.append(
|
||||
Message(
|
||||
id=info.id,
|
||||
type=MessageType.SURVEY if info.survey else MessageType.INFORMATION,
|
||||
title=info.title or "",
|
||||
content=info.content(),
|
||||
author=info.author,
|
||||
date=info.creation_date,
|
||||
read=info.read,
|
||||
)
|
||||
)
|
||||
self._persist_credentials()
|
||||
return messages
|
||||
except (
|
||||
pronotepy.PronoteAPIError,
|
||||
ValueError,
|
||||
requests.RequestException,
|
||||
ConnectionError,
|
||||
TimeoutError,
|
||||
) as exc:
|
||||
logger.error(
|
||||
"Échec de la récupération des informations Pronote : %s",
|
||||
redact_exception(exc),
|
||||
)
|
||||
self._persist_credentials()
|
||||
return messages
|
||||
except (
|
||||
pronotepy.PronoteAPIError,
|
||||
ValueError,
|
||||
requests.RequestException,
|
||||
ConnectionError,
|
||||
TimeoutError,
|
||||
) as exc:
|
||||
logger.error(
|
||||
"Échec de la récupération des informations Pronote : %s",
|
||||
redact_exception(exc),
|
||||
)
|
||||
self._persist_credentials()
|
||||
return []
|
||||
self._persist_credentials()
|
||||
return []
|
||||
|
||||
def get_lessons(self, start: date, end: date) -> list[Lesson]:
|
||||
"""Récupère les cours via ``pronotepy`` (repli iCal).
|
||||
@@ -523,37 +547,38 @@ class PronoteClient:
|
||||
:raises ConnectionError: Si la connexion réseau échoue.
|
||||
:raises TimeoutError: Si la requête réseau expire.
|
||||
"""
|
||||
client = self._connect()
|
||||
lessons: list[Lesson] = []
|
||||
for lesson in client.lessons(start, end):
|
||||
content = lesson.content
|
||||
raw_uid = lesson.id
|
||||
if raw_uid:
|
||||
uid = normalize_pronote_uid(raw_uid)
|
||||
else:
|
||||
uid = generate_deterministic_uid(
|
||||
start=lesson.start,
|
||||
end=lesson.end,
|
||||
subject=lesson.subject.name if lesson.subject is not None else "",
|
||||
teachers=list(lesson.teacher_names or ()),
|
||||
rooms=list(lesson.classrooms or ()),
|
||||
group=lesson.group_name,
|
||||
with self._qr_token_operation_lock():
|
||||
client = self._connect()
|
||||
lessons: list[Lesson] = []
|
||||
for lesson in client.lessons(start, end):
|
||||
content = lesson.content
|
||||
raw_uid = lesson.id
|
||||
if raw_uid:
|
||||
uid = normalize_pronote_uid(raw_uid)
|
||||
else:
|
||||
uid = generate_deterministic_uid(
|
||||
start=lesson.start,
|
||||
end=lesson.end,
|
||||
subject=lesson.subject.name if lesson.subject is not None else "",
|
||||
teachers=list(lesson.teacher_names or ()),
|
||||
rooms=list(lesson.classrooms or ()),
|
||||
group=lesson.group_name,
|
||||
)
|
||||
lessons.append(
|
||||
Lesson(
|
||||
id=uid,
|
||||
start=lesson.start,
|
||||
end=lesson.end,
|
||||
subject=lesson.subject.name if lesson.subject is not None else "",
|
||||
teachers=tuple(lesson.teacher_names or ()),
|
||||
rooms=tuple(lesson.classrooms or ()),
|
||||
group=lesson.group_name,
|
||||
status=(LessonStatus.CANCELLED if lesson.canceled else LessonStatus.NORMAL),
|
||||
content=content.description if content is not None else None,
|
||||
)
|
||||
)
|
||||
lessons.append(
|
||||
Lesson(
|
||||
id=uid,
|
||||
start=lesson.start,
|
||||
end=lesson.end,
|
||||
subject=lesson.subject.name if lesson.subject is not None else "",
|
||||
teachers=tuple(lesson.teacher_names or ()),
|
||||
rooms=tuple(lesson.classrooms or ()),
|
||||
group=lesson.group_name,
|
||||
status=(LessonStatus.CANCELLED if lesson.canceled else LessonStatus.NORMAL),
|
||||
content=content.description if content is not None else None,
|
||||
)
|
||||
)
|
||||
self._persist_credentials()
|
||||
return lessons
|
||||
self._persist_credentials()
|
||||
return lessons
|
||||
|
||||
def get_homeworks(self, start: date, end: date) -> list[Homework]:
|
||||
"""Récupère les devoirs via ``pronotepy``.
|
||||
@@ -576,19 +601,20 @@ class PronoteClient:
|
||||
:raises ConnectionError: Si la connexion réseau échoue.
|
||||
:raises TimeoutError: Si la requête réseau expire.
|
||||
"""
|
||||
client = self._connect()
|
||||
homeworks: list[Homework] = []
|
||||
for hw in client.homework(start, end):
|
||||
homeworks.append(
|
||||
Homework(
|
||||
id=hw.id,
|
||||
subject=hw.subject.name,
|
||||
teachers=(),
|
||||
assigned_on=None,
|
||||
due_on=hw.date,
|
||||
text=hw.description,
|
||||
html=hw.description,
|
||||
with self._qr_token_operation_lock():
|
||||
client = self._connect()
|
||||
homeworks: list[Homework] = []
|
||||
for hw in client.homework(start, end):
|
||||
homeworks.append(
|
||||
Homework(
|
||||
id=hw.id,
|
||||
subject=hw.subject.name,
|
||||
teachers=(),
|
||||
assigned_on=None,
|
||||
due_on=hw.date,
|
||||
text=hw.description,
|
||||
html=hw.description,
|
||||
)
|
||||
)
|
||||
)
|
||||
self._persist_credentials()
|
||||
return homeworks
|
||||
self._persist_credentials()
|
||||
return homeworks
|
||||
|
||||
@@ -17,10 +17,12 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from fcntl import LOCK_EX, LOCK_NB, LOCK_UN, flock
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from pronote_sync.errors import PronoteAuthStateLockError
|
||||
from pronote_sync.sources.pronote.auth_state import PronoteAuthState
|
||||
|
||||
|
||||
@@ -206,3 +208,76 @@ def test_no_credentials_in_logs(tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
assert "SENTINEL_USER_ZZZ" not in caplog.text
|
||||
assert "SENTINEL_PASSWORD_ZZZ" not in caplog.text
|
||||
assert "SENTINEL_UUID_ZZZ" not in caplog.text
|
||||
|
||||
|
||||
def test_lock_rejects_concurrent_access_with_a_redacted_dedicated_error(tmp_path: Path) -> None:
|
||||
"""Vérifie qu'un verrou concurrent échoue immédiatement sans fuite interne.
|
||||
|
||||
:param tmp_path: Fixture pytest pour un répertoire temporaire.
|
||||
:return: None
|
||||
"""
|
||||
state_file = tmp_path / ".pronote_auth_state.json"
|
||||
state = PronoteAuthState(state_file)
|
||||
competing_state = PronoteAuthState(state_file)
|
||||
|
||||
with state.lock():
|
||||
assert state_file.with_name(f"{state_file.name}.lock").exists()
|
||||
with pytest.raises(PronoteAuthStateLockError) as exc_info:
|
||||
with competing_state.lock():
|
||||
pass
|
||||
|
||||
assert "BlockingIOError" not in str(exc_info.value)
|
||||
assert exc_info.value.__cause__ is None
|
||||
assert exc_info.value.__context__ is None
|
||||
|
||||
|
||||
def test_lock_open_failure_does_not_log_sensitive_lock_path(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Vérifie qu'un échec d'ouverture du verrou ne divulgue pas son chemin.
|
||||
|
||||
:param tmp_path: Fixture pytest pour un répertoire temporaire.
|
||||
:param caplog: Fixture pytest pour capturer les logs.
|
||||
:param monkeypatch: Fixture pytest pour remplacer l'ouverture du verrou.
|
||||
:return: None
|
||||
"""
|
||||
sentinel_path = "/SENTINEL_LOCK_PATH_ZZZ/.pronote_auth_state.json.lock"
|
||||
|
||||
def raise_lock_open_error(*args: object, **kwargs: object) -> int:
|
||||
"""Simule un refus d'ouverture portant un chemin sensible."""
|
||||
del args, kwargs
|
||||
raise OSError(13, "Permission denied", sentinel_path)
|
||||
|
||||
monkeypatch.setattr(os, "open", raise_lock_open_error)
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
with pytest.raises(PronoteAuthStateLockError) as exc_info:
|
||||
with PronoteAuthState(tmp_path / ".pronote_auth_state.json").lock():
|
||||
pass
|
||||
|
||||
assert "Impossible d'ouvrir le verrou d'état d'authentification Pronote" in caplog.text
|
||||
assert "SENTINEL_LOCK_PATH_ZZZ" not in caplog.text
|
||||
assert exc_info.value.__cause__ is None
|
||||
assert exc_info.value.__context__ is None
|
||||
|
||||
|
||||
def test_lock_is_released_when_the_protected_operation_raises(tmp_path: Path) -> None:
|
||||
"""Vérifie que le verrou est libéré même si le bloc protégé échoue.
|
||||
|
||||
:param tmp_path: Fixture pytest pour un répertoire temporaire.
|
||||
:return: None
|
||||
"""
|
||||
state_file = tmp_path / ".pronote_auth_state.json"
|
||||
lock_file = state_file.with_name(f"{state_file.name}.lock")
|
||||
state = PronoteAuthState(state_file)
|
||||
|
||||
with pytest.raises(RuntimeError, match="échec simulé"):
|
||||
with state.lock():
|
||||
raise RuntimeError("échec simulé")
|
||||
|
||||
descriptor = os.open(lock_file, os.O_RDWR)
|
||||
try:
|
||||
flock(descriptor, LOCK_EX | LOCK_NB)
|
||||
flock(descriptor, LOCK_UN)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
@@ -9,6 +9,8 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
@@ -1203,6 +1205,91 @@ def test_no_raw_secrets_in_logs(
|
||||
# --- Persistence of credentials after data operations ---
|
||||
|
||||
|
||||
def test_qr_token_lock_covers_login_retrieval_and_credential_persistence(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
) -> None:
|
||||
"""Vérifie que le verrou d'état couvre tout le cycle QR/token des cours.
|
||||
|
||||
:param mocker: Fixture pytest-mock pour le mocking.
|
||||
:return: None
|
||||
"""
|
||||
events: list[str] = []
|
||||
credentials = {
|
||||
"pronote_url": "https://pronote.example.com",
|
||||
"username": "testuser",
|
||||
"password": "persisted-token", # pragma: allowlist secret
|
||||
"uuid": "persisted-uuid",
|
||||
}
|
||||
auth_state = mocker.MagicMock(spec=PronoteAuthState)
|
||||
|
||||
@contextmanager
|
||||
def record_lock() -> Generator[None]:
|
||||
events.append("lock_acquired")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
events.append("lock_released")
|
||||
|
||||
def load_credentials() -> dict[str, str]:
|
||||
"""Simule le chargement des credentials persistés."""
|
||||
events.append("load")
|
||||
return credentials
|
||||
|
||||
def get_no_lessons(*_: object) -> list[object]:
|
||||
"""Simule une récupération de cours vide."""
|
||||
events.append("lessons")
|
||||
return []
|
||||
|
||||
def export_credentials() -> dict[str, str]:
|
||||
"""Simule l'export des credentials courantes."""
|
||||
events.append("export")
|
||||
return credentials
|
||||
|
||||
def token_login(**_: object) -> MagicMock:
|
||||
"""Simule le login par token."""
|
||||
events.append("token_login")
|
||||
return mock_client
|
||||
|
||||
auth_state.lock.side_effect = record_lock
|
||||
auth_state.load.side_effect = load_credentials
|
||||
auth_state.save.side_effect = lambda _: events.append("save")
|
||||
|
||||
mock_client = _make_lessons_mock_client(mocker)
|
||||
mock_client.logged_in = True
|
||||
mock_client.lessons.side_effect = get_no_lessons
|
||||
mock_client.export_credentials.side_effect = export_credentials
|
||||
mocker.patch(
|
||||
"pronotepy.ParentClient.token_login",
|
||||
side_effect=token_login,
|
||||
)
|
||||
|
||||
settings = PronoteSettings(
|
||||
url="https://pronote.example.com",
|
||||
username="testuser",
|
||||
password=SecretStr("testpass"),
|
||||
ent=None,
|
||||
account_type="parent",
|
||||
auth_mode="qr_token",
|
||||
)
|
||||
|
||||
lessons = PronoteClient(settings, auth_state=auth_state).get_lessons(
|
||||
date(2024, 9, 1), date(2024, 9, 30)
|
||||
)
|
||||
|
||||
assert lessons == []
|
||||
assert events == [
|
||||
"lock_acquired",
|
||||
"load",
|
||||
"token_login",
|
||||
"export",
|
||||
"save",
|
||||
"lessons",
|
||||
"export",
|
||||
"save",
|
||||
"lock_released",
|
||||
]
|
||||
|
||||
|
||||
def _make_auth_state_mock(mocker: pytest_mock.MockerFixture) -> MagicMock:
|
||||
"""Retourne un mock de PronoteAuthState sans credentials persistés.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user