Compare commits
4 Commits
v0.1.2
...
fix/qr-tok
| Author | SHA1 | Date | |
|---|---|---|---|
| 4228c1e636 | |||
| 8b924b55d1 | |||
| 22a662ab39 | |||
| 5188761209 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -50,6 +50,7 @@ Thumbs.db
|
|||||||
.caldav_sync_state.json
|
.caldav_sync_state.json
|
||||||
# État d'authentification pronotepy (QR code / token rotation)
|
# État d'authentification pronotepy (QR code / token rotation)
|
||||||
.pronote_auth_state.json
|
.pronote_auth_state.json
|
||||||
|
.pronote_auth_state.json.lock
|
||||||
*.state.json
|
*.state.json
|
||||||
|
|
||||||
# --- Local scratch / WIP files ---
|
# --- Local scratch / WIP files ---
|
||||||
|
|||||||
@@ -140,7 +140,7 @@
|
|||||||
"filename": "GUIDE_DEV_PYTHON.md",
|
"filename": "GUIDE_DEV_PYTHON.md",
|
||||||
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
|
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
|
||||||
"is_verified": true,
|
"is_verified": true,
|
||||||
"line_number": 5064,
|
"line_number": 5084,
|
||||||
"is_secret": false
|
"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é à
|
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`.
|
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
|
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.
|
de singleton et ne contient pas d'identifiants dupliqués.
|
||||||
|
|||||||
@@ -38,6 +38,21 @@ class PronoteAuthRotationError(PronoteSyncError):
|
|||||||
super().__init__(message)
|
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):
|
class ErrorSeverity(StrEnum):
|
||||||
"""Niveau de gravité d'une erreur produite par le pipeline."""
|
"""Niveau de gravité d'une erreur produite par le pipeline."""
|
||||||
|
|
||||||
|
|||||||
@@ -14,10 +14,13 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
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 pathlib import Path
|
||||||
from typing import Any
|
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
|
from pronote_sync.utils.redaction import redact_exception, redact_secrets
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -108,6 +111,69 @@ class PronoteAuthState:
|
|||||||
credentials[key] = value
|
credentials[key] = value
|
||||||
return credentials
|
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:
|
def save(self, credentials: dict[str, str]) -> None:
|
||||||
"""Sauvegarde les credentials dans le fichier d'état, de manière atomique.
|
"""Sauvegarde les credentials dans le fichier d'état, de manière atomique.
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from collections.abc import Generator
|
||||||
|
from contextlib import contextmanager
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Protocol
|
from typing import Any, Protocol
|
||||||
@@ -252,6 +254,26 @@ class PronoteClient:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("Échec de la persistance des credentials : %s", redact_exception(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:
|
def _connect_password(self) -> pronotepy.Client:
|
||||||
"""Connecte le client ``pronotepy`` en mode ``password``.
|
"""Connecte le client ``pronotepy`` en mode ``password``.
|
||||||
|
|
||||||
@@ -426,6 +448,7 @@ class PronoteClient:
|
|||||||
:return: Liste des messages des professeurs ; vide en cas d'erreur.
|
:return: Liste des messages des professeurs ; vide en cas d'erreur.
|
||||||
:rtype: list[Message]
|
:rtype: list[Message]
|
||||||
"""
|
"""
|
||||||
|
with self._qr_token_operation_lock():
|
||||||
try:
|
try:
|
||||||
client = self._connect()
|
client = self._connect()
|
||||||
messages: list[Message] = []
|
messages: list[Message] = []
|
||||||
@@ -467,6 +490,7 @@ class PronoteClient:
|
|||||||
:return: Liste des informations et sondages ; vide en cas d'erreur.
|
:return: Liste des informations et sondages ; vide en cas d'erreur.
|
||||||
:rtype: list[Message]
|
:rtype: list[Message]
|
||||||
"""
|
"""
|
||||||
|
with self._qr_token_operation_lock():
|
||||||
try:
|
try:
|
||||||
client = self._connect()
|
client = self._connect()
|
||||||
messages: list[Message] = []
|
messages: list[Message] = []
|
||||||
@@ -523,6 +547,7 @@ class PronoteClient:
|
|||||||
:raises ConnectionError: Si la connexion réseau échoue.
|
:raises ConnectionError: Si la connexion réseau échoue.
|
||||||
:raises TimeoutError: Si la requête réseau expire.
|
:raises TimeoutError: Si la requête réseau expire.
|
||||||
"""
|
"""
|
||||||
|
with self._qr_token_operation_lock():
|
||||||
client = self._connect()
|
client = self._connect()
|
||||||
lessons: list[Lesson] = []
|
lessons: list[Lesson] = []
|
||||||
for lesson in client.lessons(start, end):
|
for lesson in client.lessons(start, end):
|
||||||
@@ -576,6 +601,7 @@ class PronoteClient:
|
|||||||
:raises ConnectionError: Si la connexion réseau échoue.
|
:raises ConnectionError: Si la connexion réseau échoue.
|
||||||
:raises TimeoutError: Si la requête réseau expire.
|
:raises TimeoutError: Si la requête réseau expire.
|
||||||
"""
|
"""
|
||||||
|
with self._qr_token_operation_lock():
|
||||||
client = self._connect()
|
client = self._connect()
|
||||||
homeworks: list[Homework] = []
|
homeworks: list[Homework] = []
|
||||||
for hw in client.homework(start, end):
|
for hw in client.homework(start, end):
|
||||||
|
|||||||
@@ -17,10 +17,12 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from fcntl import LOCK_EX, LOCK_NB, LOCK_UN, flock
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from pronote_sync.errors import PronoteAuthStateLockError
|
||||||
from pronote_sync.sources.pronote.auth_state import PronoteAuthState
|
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_USER_ZZZ" not in caplog.text
|
||||||
assert "SENTINEL_PASSWORD_ZZZ" not in caplog.text
|
assert "SENTINEL_PASSWORD_ZZZ" not in caplog.text
|
||||||
assert "SENTINEL_UUID_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 json
|
||||||
import logging
|
import logging
|
||||||
|
from collections.abc import Generator
|
||||||
|
from contextlib import contextmanager
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
@@ -1203,6 +1205,91 @@ def test_no_raw_secrets_in_logs(
|
|||||||
# --- Persistence of credentials after data operations ---
|
# --- 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:
|
def _make_auth_state_mock(mocker: pytest_mock.MockerFixture) -> MagicMock:
|
||||||
"""Retourne un mock de PronoteAuthState sans credentials persistés.
|
"""Retourne un mock de PronoteAuthState sans credentials persistés.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user