Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79858a0849 |
@@ -23,8 +23,6 @@ PRONOTE_AUTH_MODE=password
|
||||
# PRONOTE_AUTH_MODE=qr_token
|
||||
# PRONOTE_QR_CODE_FILE=/path/to/qr_code.json
|
||||
# PRONOTE_QR_PIN=1234
|
||||
# PIN de second facteur du compte Pronote, distinct du PIN de déchiffrement du QR code
|
||||
# PRONOTE_ACCOUNT_PIN=
|
||||
|
||||
# --- CalDAV ---
|
||||
CALDAV_URL=https://caldav.example.com/calendars/user/pronote/
|
||||
|
||||
+2
-2
@@ -140,7 +140,7 @@
|
||||
"filename": "GUIDE_DEV_PYTHON.md",
|
||||
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
|
||||
"is_verified": false,
|
||||
"line_number": 5125
|
||||
"line_number": 5133
|
||||
}
|
||||
],
|
||||
"tests/unit/test_caldav_gateway.py": [
|
||||
@@ -185,5 +185,5 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"generated_at": "2026-09-12T12:04:07Z"
|
||||
"generated_at": "2026-09-12T17:18:46Z"
|
||||
}
|
||||
|
||||
@@ -159,9 +159,12 @@ pronote-sync --dry-run
|
||||
- Après chaque login réussi, les credentials exportées par `pronotepy.export_credentials()` sont
|
||||
persistées dans `.pronote_auth_state.json` (permissions `0600`, format JSON versionné, écriture
|
||||
atomique). Le token rotate à chaque session et peut également être rafraîchi pendant l'exécution
|
||||
(refresh automatique pronotepy après une `PronoteAPIError`). Les credentials sont persistées après
|
||||
chaque login réussi **et après chaque opération de données réussie** (agenda, devoirs, messages,
|
||||
informations) pour garantir la persistance du token valide.
|
||||
(refresh automatique pronotepy après une `PronoteAPIError`). La persistance s'applique après
|
||||
chaque login réussi **et après chaque opération de données réussie** (agenda, devoirs, messages)
|
||||
pour garantir la persistance du token valide ; seules les opérations qui se connectent réellement
|
||||
et récupèrent des données déclenchent la persistance. En mode `qr_token`, `get_informations()`
|
||||
est ignorée (retour immédiat `[]` sans connexion ni verrou) et ne déclenche donc aucune
|
||||
persistance.
|
||||
- Les logins suivants utilisent `pronotepy.token_login(**credentials)` avec le token persisté.
|
||||
- En cas d'échec de `token_login` (token expiré/invalide), une `PronoteAuthRotationError` est levée.
|
||||
Cette erreur se propage sans wrapping à travers `PronoteFetcher` et `fetch_step` jusqu'à
|
||||
@@ -172,6 +175,11 @@ pronote-sync --dry-run
|
||||
- `PronoteAuthRotationError` est re-levée telle quelle (`except PronoteAuthRotationError: raise`)
|
||||
dans toutes les couches d'enveloppement du chemin critique (fetch_agenda, fetch_homework,
|
||||
fetch_step). Ne pas l'attraper avec `except Exception` sans la re-léver d'abord.
|
||||
- En mode `qr_token`, `get_informations()` retourne **inconditionnellement** une liste vide
|
||||
(`[]`) sans connexion, verrou, chargement d'état ni appel réseau, et journalise un message
|
||||
INFO unique : l'endpoint `PageActualites` renvoie une erreur pronotepy 20 sur les instances
|
||||
HubEduConnect testées, provoquant un refresh redondant du token. Ce contournement n'est pas
|
||||
configurable ; aucun état anti-répétition n'est conservé.
|
||||
- Le fichier `.pronote_auth_state.json` ne doit jamais être committé (couvert par `.gitignore`).
|
||||
Son contenu (token vivant) ne doit jamais apparaître dans les logs, les messages d'erreur ou
|
||||
les notifications XMPP.
|
||||
|
||||
+1
-5
@@ -25,17 +25,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- `PRONOTE_URL` ignoré à cause du double préfixe `env_prefix` (renommage `pronote_url` → `url` dans `PronoteSettings`)
|
||||
- `PRONOTE_ENT` rendu optionnel pour les connexions pronotepy directes
|
||||
- `.env.example` corrigé (`eleve.html` → `parent.html`)
|
||||
- #20/#21 — `get_informations()` ignorée en mode `qr_token` : retourne `[]` immédiatement, sans connexion, verrou ni appel réseau, évitant l'échec systématique de l'endpoint `PageActualites` (erreur pronotepy 20 sur les instances HubEduConnect testées) et le refresh redondant du token associé.
|
||||
|
||||
### Changed
|
||||
|
||||
- Wiki `GuidePronote` enrichi : section "Quand l'ENT est obligatoire" (EduConnect/HubEduConnect), exemple Bordeaux
|
||||
- `AGENTS.md` : ajout de la section §13 "Versionnage et releases"
|
||||
|
||||
### Known Issues
|
||||
|
||||
- #20 — Triple authentification pronotepy (double INIT + refresh) lors d'un run
|
||||
- #21 — Erreur pronotepy 20 « La page a expiré ! (11) » sur `get_informations`
|
||||
|
||||
### Tests
|
||||
|
||||
- 694 tests passés, couverture 94.93%
|
||||
|
||||
+13
-5
@@ -2279,6 +2279,12 @@ méthodes agenda/devoirs ne transforment jamais une erreur en liste vide : elles
|
||||
version expurgée puis lèvent une erreur expurgée avec `from None`. Les méthodes de messages et
|
||||
d'informations sont non critiques et peuvent retourner une liste vide avec un warning.
|
||||
|
||||
En mode `qr_token`, `get_informations()` est ignorée : elle retourne immédiatement `[]` sans
|
||||
connexion, verrou ni appel réseau, et journalise un message INFO unique. L'endpoint
|
||||
`PageActualites` renvoie en effet une erreur pronotepy 20 sur les instances HubEduConnect
|
||||
testées, provoquant un refresh redondant du token. Ce comportement n'est pas configurable ;
|
||||
`get_messages()` n'est pas concernée par ce contournement.
|
||||
|
||||
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`.
|
||||
|
||||
@@ -2290,11 +2296,13 @@ d'authentification et de récupération par un verrou POSIX local non bloquant,
|
||||
`.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.
|
||||
enrôlement QR initial, opération de données (agenda, devoirs, messages ; informations hors mode
|
||||
`qr_token`), puis persistance des credentials actualisées. En mode `qr_token`, `get_informations()`
|
||||
est ignorée (retour immédiat `[]` sans connexion ni verrou) : elle n'acquiert pas le verrou et ne
|
||||
déclenche aucune persistance. 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
|
||||
|
||||
@@ -66,11 +66,6 @@ processus. Le mode `PRONOTE_AUTH_MODE=qr_token` est incompatible avec cette gara
|
||||
le refuse avant toute connexion afin de ne pas désynchroniser le token local du token distant.
|
||||
Le dry-run ne remplace pas une vérification des paramètres réellement chargés.
|
||||
|
||||
En mode `PRONOTE_AUTH_MODE=qr_token`, `PRONOTE_QR_PIN` déchiffre le QR code
|
||||
exporté depuis le site web Pronote. Si le compte exige un second facteur,
|
||||
configurez aussi `PRONOTE_ACCOUNT_PIN` avec le PIN du compte. Ce PIN est
|
||||
transmis uniquement à `pronotepy` lors de l'enrôlement QR et des connexions par
|
||||
token ; il n'est jamais écrit dans `.pronote_auth_state.json` ni dans les logs.
|
||||
En mode `PRONOTE_AUTH_MODE=qr_token`, le fichier
|
||||
`.pronote_auth_state.json` et son verrou frère sont créés dans le répertoire
|
||||
de travail du service (par exemple `/var/lib/pronote-sync`) avec le mode
|
||||
|
||||
@@ -47,7 +47,6 @@ class PronoteSettings(BaseSettings):
|
||||
auth_mode: Literal["password", "qr_token"] = "password"
|
||||
qr_code_file: str | None = None
|
||||
qr_pin: SecretStr | None = None
|
||||
account_pin: SecretStr | None = None
|
||||
|
||||
@field_serializer("ical_url")
|
||||
def _serialize_ical_url(self, value: SecretStr | None) -> str | None:
|
||||
@@ -73,18 +72,6 @@ class PronoteSettings(BaseSettings):
|
||||
return None
|
||||
return "**********"
|
||||
|
||||
@field_serializer("account_pin")
|
||||
def _serialize_account_pin(self, value: SecretStr | None) -> str | None:
|
||||
"""Masque le PIN du compte lors de la sérialisation.
|
||||
|
||||
:param value: Valeur du PIN de second facteur du compte.
|
||||
:return: ``"**********"`` si la valeur est définie, ``None`` sinon.
|
||||
:rtype: str | None
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
return "**********"
|
||||
|
||||
|
||||
class CalDAVSettings(BaseSettings):
|
||||
"""Paramètres d'accès au serveur CalDAV de destination.
|
||||
@@ -341,7 +328,6 @@ class Settings(BaseSettings):
|
||||
self.pronote.ical_url,
|
||||
self.pronote.password,
|
||||
self.pronote.qr_pin,
|
||||
self.pronote.account_pin,
|
||||
self.caldav.url,
|
||||
self.caldav.password,
|
||||
self.xmpp.password,
|
||||
|
||||
@@ -119,9 +119,6 @@ def _collect_auth_secrets(client: PronoteClient) -> list[str]:
|
||||
# PIN QR
|
||||
if settings.qr_pin is not None:
|
||||
secrets.append(settings.qr_pin.get_secret_value())
|
||||
# PIN de second facteur du compte
|
||||
if settings.account_pin is not None:
|
||||
secrets.append(settings.account_pin.get_secret_value())
|
||||
# Contenu du fichier QR (jeton, login, url)
|
||||
if settings.qr_code_file is not None:
|
||||
try:
|
||||
@@ -340,12 +337,7 @@ class PronoteClient:
|
||||
creds = self._auth_state.load()
|
||||
if creds is not None:
|
||||
try:
|
||||
account_pin = (
|
||||
self._settings.account_pin.get_secret_value()
|
||||
if self._settings.account_pin is not None
|
||||
else None
|
||||
)
|
||||
client = client_class.token_login(**creds, account_pin=account_pin)
|
||||
client = client_class.token_login(**creds)
|
||||
if client.logged_in:
|
||||
self._client = client
|
||||
self._persist_credentials()
|
||||
@@ -429,16 +421,10 @@ class PronoteClient:
|
||||
app_uuid = f"pronote-sync-{uuid4().hex}"
|
||||
|
||||
try:
|
||||
account_pin = (
|
||||
self._settings.account_pin.get_secret_value()
|
||||
if self._settings.account_pin is not None
|
||||
else None
|
||||
)
|
||||
client = client_class.qrcode_login(
|
||||
qr_code=qr_data,
|
||||
pin=pin_value,
|
||||
uuid=app_uuid,
|
||||
account_pin=account_pin,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
@@ -502,9 +488,23 @@ class PronoteClient:
|
||||
Chaque entrée est mappée sur un modèle :class:`Message` de type
|
||||
``SURVEY`` si c'est un sondage, ``INFORMATION`` sinon.
|
||||
|
||||
:return: Liste des informations et sondages ; vide en cas d'erreur.
|
||||
En mode ``qr_token``, la récupération est ignorée sans connexion ni
|
||||
appel réseau : l'endpoint ``PageActualites`` renvoie une erreur
|
||||
pronotepy 20 sur les instances HubEduConnect testées, provoquant un
|
||||
refresh redondant du token. La méthode retourne alors immédiatement
|
||||
une liste vide et journalise un message INFO unique ; ce comportement
|
||||
n'est pas configurable.
|
||||
|
||||
:return: Liste des informations et sondages ; vide en cas d'erreur ou
|
||||
en mode ``qr_token``.
|
||||
:rtype: list[Message]
|
||||
"""
|
||||
if self._settings.auth_mode == "qr_token":
|
||||
logger.info(
|
||||
"Récupération des informations Pronote ignorée : endpoint "
|
||||
"PageActualites indisponible en mode d'authentification qr_token."
|
||||
)
|
||||
return []
|
||||
with self._qr_token_operation_lock():
|
||||
try:
|
||||
client = self._connect()
|
||||
|
||||
@@ -207,21 +207,4 @@ def test_qr_pin_in_redaction_secrets(monkeypatch: MonkeyPatch) -> None:
|
||||
assert "**********" in repr(settings.pronote.qr_pin)
|
||||
|
||||
|
||||
def test_account_pin_loaded_as_secretstr_and_redacted(monkeypatch: MonkeyPatch) -> None:
|
||||
"""Vérifie que ``PRONOTE_ACCOUNT_PIN`` est secret et expurgé.
|
||||
|
||||
:param monkeypatch: Fixture pytest pour modifier temporairement l'environnement.
|
||||
:return: None
|
||||
"""
|
||||
monkeypatch.setenv("PRONOTE_ACCOUNT_PIN", "account-pin-42")
|
||||
settings = load_settings()
|
||||
|
||||
assert isinstance(settings.pronote.account_pin, SecretStr)
|
||||
assert settings.pronote.account_pin.get_secret_value() == "account-pin-42"
|
||||
assert settings.pronote.account_pin in settings.redaction_secrets()
|
||||
assert "account-pin-42" not in str(settings)
|
||||
assert "account-pin-42" not in settings.model_dump_json()
|
||||
assert "**********" in settings.model_dump_json()
|
||||
|
||||
|
||||
# Ensure trailing newline
|
||||
|
||||
@@ -621,12 +621,17 @@ def test_get_messages_degraded_on_error(
|
||||
|
||||
|
||||
def test_get_informations_degraded_on_error(
|
||||
mocker: pytest_mock.MockerFixture, pronote_settings: PronoteSettings
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
pronote_settings: PronoteSettings,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Vérifie que get_informations retourne une liste vide en cas d'erreur réseau.
|
||||
|
||||
Assert que le chemin d'erreur retourne toujours [] avec un log ERROR.
|
||||
|
||||
:param mocker: Fixture pytest-mock pour le mocking.
|
||||
:param pronote_settings: Paramètres Pronote valides.
|
||||
:param caplog: Fixture pour capturer les logs.
|
||||
:return: None
|
||||
"""
|
||||
mock_client = mocker.MagicMock()
|
||||
@@ -634,14 +639,254 @@ def test_get_informations_degraded_on_error(
|
||||
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
|
||||
|
||||
client = PronoteClient(pronote_settings)
|
||||
messages = client.get_informations()
|
||||
|
||||
with caplog.at_level(logging.ERROR, logger="pronote_sync.sources.pronote.client"):
|
||||
messages = client.get_informations()
|
||||
|
||||
assert messages == []
|
||||
# Assert ERROR log is present
|
||||
error_records = [r for r in caplog.records if r.levelno == logging.ERROR]
|
||||
assert len(error_records) >= 1
|
||||
assert any(
|
||||
"Échec de la récupération des informations Pronote" in r.message for r in error_records
|
||||
)
|
||||
|
||||
|
||||
# --- QR code / token authentication tests ---
|
||||
|
||||
|
||||
# --- get_informations qr_token mode guard tests ---
|
||||
|
||||
|
||||
def test_get_informations_skips_in_qr_token_mode(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
) -> None:
|
||||
"""Vérifie que get_informations retourne [] immédiatement en mode qr_token.
|
||||
|
||||
:param mocker: Fixture pytest-mock pour le mocking.
|
||||
:return: None
|
||||
"""
|
||||
settings = PronoteSettings(
|
||||
url="https://pronote.example.com",
|
||||
username="testuser",
|
||||
password=SecretStr("testpass"),
|
||||
ent="bordeaux",
|
||||
account_type="parent",
|
||||
auth_mode="qr_token",
|
||||
)
|
||||
client = PronoteClient(settings)
|
||||
assert client._client is None
|
||||
|
||||
messages = client.get_informations()
|
||||
|
||||
assert messages == []
|
||||
assert client._client is None
|
||||
|
||||
|
||||
def test_get_informations_no_connect_in_qr_token_mode(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
) -> None:
|
||||
"""Vérifie que _connect n'est jamais appelé en mode qr_token pour get_informations.
|
||||
|
||||
:param mocker: Fixture pytest-mock pour le mocking.
|
||||
:return: None
|
||||
"""
|
||||
settings = PronoteSettings(
|
||||
url="https://pronote.example.com",
|
||||
username="testuser",
|
||||
password=SecretStr("testpass"),
|
||||
ent="bordeaux",
|
||||
account_type="parent",
|
||||
auth_mode="qr_token",
|
||||
)
|
||||
connect_spy = mocker.spy(PronoteClient, "_connect")
|
||||
|
||||
client = PronoteClient(settings)
|
||||
messages = client.get_informations()
|
||||
|
||||
assert messages == []
|
||||
connect_spy.assert_not_called()
|
||||
|
||||
|
||||
def test_get_informations_no_information_and_surveys_in_qr_token_mode(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
) -> None:
|
||||
"""Vérifie que information_and_surveys n'est jamais appelé en mode qr_token.
|
||||
|
||||
:param mocker: Fixture pytest-mock pour le mocking.
|
||||
:return: None
|
||||
"""
|
||||
settings = PronoteSettings(
|
||||
url="https://pronote.example.com",
|
||||
username="testuser",
|
||||
password=SecretStr("testpass"),
|
||||
ent="bordeaux",
|
||||
account_type="parent",
|
||||
auth_mode="qr_token",
|
||||
)
|
||||
mock_client = mocker.MagicMock()
|
||||
mock_client.information_and_surveys = mocker.MagicMock()
|
||||
mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
|
||||
|
||||
client = PronoteClient(settings)
|
||||
messages = client.get_informations()
|
||||
|
||||
assert messages == []
|
||||
mock_client.information_and_surveys.assert_not_called()
|
||||
|
||||
|
||||
def test_get_informations_qr_token_no_side_effects(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Prouve le contrat complet sans effet de bordure du chemin de saut qr_token.
|
||||
|
||||
Avec PronoteAuthState et _qr_token_operation_lock et _persist_credentials
|
||||
mockés, assert que sur le saut : le gestionnaire de contexte de verrou n'est
|
||||
PAS entré, auth-state load() n'est PAS appelé, _persist_credentials() n'est
|
||||
PAS appelé, et l'export des credentials n'est PAS invoqué. Assert aussi que
|
||||
self._client est inchangé.
|
||||
|
||||
:param mocker: Fixture pytest-mock pour le mocking.
|
||||
:param caplog: Fixture pour capturer les logs.
|
||||
:return: None
|
||||
"""
|
||||
auth_state = mocker.MagicMock(spec=PronoteAuthState)
|
||||
auth_state.load = mocker.MagicMock()
|
||||
auth_state.lock = mocker.MagicMock()
|
||||
|
||||
settings = PronoteSettings(
|
||||
url="https://pronote.example.com",
|
||||
username="testuser",
|
||||
password=SecretStr("testpass"),
|
||||
ent="bordeaux",
|
||||
account_type="parent",
|
||||
auth_mode="qr_token",
|
||||
)
|
||||
|
||||
client = PronoteClient(settings, auth_state=auth_state)
|
||||
sentinel = MagicMock()
|
||||
client._client = sentinel
|
||||
persist_spy = mocker.spy(client, "_persist_credentials")
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="pronote_sync.sources.pronote.client"):
|
||||
messages = client.get_informations()
|
||||
|
||||
# Assert no side effects
|
||||
assert messages == []
|
||||
assert client._client is sentinel
|
||||
assert isinstance(sentinel, MagicMock)
|
||||
sentinel.export_credentials.assert_not_called()
|
||||
persist_spy.assert_not_called()
|
||||
auth_state.load.assert_not_called()
|
||||
auth_state.lock.assert_not_called()
|
||||
|
||||
# Assert exactly one INFO log record with the exact message
|
||||
info_records = [r for r in caplog.records if r.levelno == logging.INFO]
|
||||
assert len(info_records) == 1
|
||||
assert (
|
||||
info_records[0].message
|
||||
== "Récupération des informations Pronote ignorée : endpoint PageActualites "
|
||||
"indisponible en mode d'authentification qr_token."
|
||||
)
|
||||
# Assert no secret sentinel appears in any log record
|
||||
for record in caplog.records:
|
||||
assert "testpass" not in record.message
|
||||
assert "testuser" not in record.message
|
||||
assert "pronote.example.com" not in record.message
|
||||
|
||||
|
||||
def test_get_informations_logs_info_in_qr_token_mode(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Vérifie que get_informations log un message INFO exact en mode qr_token.
|
||||
|
||||
Assert exactement un enregistrement logging.INFO avec le message exact
|
||||
(inspection de caplog.records, pas seulement caplog.text), et qu'aucune
|
||||
sentinelle de secret n'apparaît.
|
||||
|
||||
:param mocker: Fixture pytest-mock pour le mocking.
|
||||
:param caplog: Fixture pour capturer les logs.
|
||||
:return: None
|
||||
"""
|
||||
settings = PronoteSettings(
|
||||
url="https://pronote.example.com",
|
||||
username="testuser",
|
||||
password=SecretStr("testpass"),
|
||||
ent="bordeaux",
|
||||
account_type="parent",
|
||||
auth_mode="qr_token",
|
||||
)
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="pronote_sync.sources.pronote.client"):
|
||||
client = PronoteClient(settings)
|
||||
messages = client.get_informations()
|
||||
|
||||
assert messages == []
|
||||
# Assert exactly one INFO record with the exact message
|
||||
info_records = [r for r in caplog.records if r.levelno == logging.INFO]
|
||||
assert len(info_records) == 1
|
||||
assert (
|
||||
info_records[0].message
|
||||
== "Récupération des informations Pronote ignorée : endpoint PageActualites "
|
||||
"indisponible en mode d'authentification qr_token."
|
||||
)
|
||||
# Assert no secret sentinel appears in any record
|
||||
for record in caplog.records:
|
||||
assert "testpass" not in record.message
|
||||
assert "testuser" not in record.message
|
||||
assert "pronote.example.com" not in record.message
|
||||
|
||||
|
||||
def test_get_informations_unchanged_in_password_mode(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
pronote_settings: PronoteSettings,
|
||||
) -> None:
|
||||
"""Vérifie que get_informations en mode password reste inchangé (régression).
|
||||
|
||||
Assert que _connect() A ÉTÉ appelé et information_and_surveys() A ÉTÉ appelé
|
||||
(en cas de succès), en conservant les assertions de mappage existantes.
|
||||
|
||||
:param mocker: Fixture pytest-mock pour le mocking.
|
||||
:param pronote_settings: Paramètres Pronote valides en mode password.
|
||||
: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]
|
||||
|
||||
# Patch _connect to return mock_client and track calls
|
||||
connect_patch = mocker.patch.object(PronoteClient, "_connect", return_value=mock_client)
|
||||
|
||||
client = PronoteClient(pronote_settings)
|
||||
messages = client.get_informations()
|
||||
|
||||
# Assert _connect() WAS called and information_and_surveys() WAS called
|
||||
connect_patch.assert_called_once()
|
||||
mock_client.information_and_surveys.assert_called_once()
|
||||
|
||||
# Retain existing mapping assertions
|
||||
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_connect_password_mode_unchanged(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
pronote_settings: PronoteSettings,
|
||||
@@ -674,10 +919,8 @@ def test_connect_password_mode_unchanged(
|
||||
assert mock_client_class.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("account_pin", [None, "account-pin-42"])
|
||||
def test_connect_qr_token_with_persisted_creds(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
account_pin: str | None,
|
||||
) -> None:
|
||||
"""Vérifie le login par token persisté en mode qr_token.
|
||||
|
||||
@@ -685,7 +928,6 @@ def test_connect_qr_token_with_persisted_creds(
|
||||
``token_login`` et le token rotate est resauvegardé.
|
||||
|
||||
:param mocker: Fixture pytest-mock pour le mocking.
|
||||
:param account_pin: PIN de second facteur facultatif.
|
||||
:return: None
|
||||
"""
|
||||
creds = {
|
||||
@@ -710,29 +952,23 @@ def test_connect_qr_token_with_persisted_creds(
|
||||
ent=None,
|
||||
account_type="parent",
|
||||
auth_mode="qr_token",
|
||||
account_pin=SecretStr(account_pin) if account_pin is not None else None,
|
||||
)
|
||||
client = PronoteClient(settings, auth_state=auth_state)
|
||||
connected = client._connect()
|
||||
|
||||
assert connected is mock_client
|
||||
pronotepy.ParentClient.token_login.assert_called_once_with( # type: ignore[attr-defined]
|
||||
**creds, account_pin=account_pin
|
||||
)
|
||||
pronotepy.ParentClient.token_login.assert_called_once_with(**creds) # type: ignore[attr-defined]
|
||||
auth_state.save.assert_called_once_with(rotated_creds)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("account_pin", [None, "account-pin-42"])
|
||||
def test_connect_qr_token_no_creds_with_qr_code(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
tmp_path: Path,
|
||||
account_pin: str | None,
|
||||
) -> 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.
|
||||
:param account_pin: PIN de second facteur facultatif.
|
||||
:return: None
|
||||
"""
|
||||
qr_file = tmp_path / "qr_code.json"
|
||||
@@ -769,7 +1005,6 @@ def test_connect_qr_token_no_creds_with_qr_code(
|
||||
auth_mode="qr_token",
|
||||
qr_code_file=str(qr_file),
|
||||
qr_pin=SecretStr("123456"),
|
||||
account_pin=SecretStr(account_pin) if account_pin is not None else None,
|
||||
)
|
||||
client = PronoteClient(settings, auth_state=auth_state)
|
||||
connected = client._connect()
|
||||
@@ -779,7 +1014,6 @@ def test_connect_qr_token_no_creds_with_qr_code(
|
||||
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["account_pin"] == account_pin
|
||||
assert kwargs["qr_code"] == {
|
||||
"login": "testuser",
|
||||
"jeton": "qr-jeton",
|
||||
@@ -1178,7 +1412,6 @@ def test_no_raw_secrets_in_logs(
|
||||
sentinel_token = "SENTINEL_RAW_TOKEN_ALPHA"
|
||||
sentinel_pin = "SENTINEL_RAW_PIN_BRAVO"
|
||||
sentinel_jeton = "SENTINEL_RAW_JETON_CHARLIE"
|
||||
sentinel_account_pin = "SENTINEL_RAW_ACCOUNT_PIN_DELTA"
|
||||
|
||||
qr_file = tmp_path / "qr_code.json"
|
||||
qr_file.write_text(
|
||||
@@ -1202,8 +1435,7 @@ def test_no_raw_secrets_in_logs(
|
||||
mocker.patch(
|
||||
"pronotepy.ParentClient.token_login",
|
||||
side_effect=pronotepy.PronoteAPIError(
|
||||
f"login refusé {sentinel_token} puis {sentinel_pin} puis {sentinel_jeton} "
|
||||
f"puis {sentinel_account_pin}"
|
||||
f"login refusé {sentinel_token} puis {sentinel_pin} puis {sentinel_jeton}"
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1216,7 +1448,6 @@ def test_no_raw_secrets_in_logs(
|
||||
auth_mode="qr_token",
|
||||
qr_code_file=str(qr_file),
|
||||
qr_pin=SecretStr(sentinel_pin),
|
||||
account_pin=SecretStr(sentinel_account_pin),
|
||||
)
|
||||
client = PronoteClient(settings, auth_state=auth_state)
|
||||
|
||||
@@ -1228,12 +1459,10 @@ def test_no_raw_secrets_in_logs(
|
||||
assert sentinel_token not in message
|
||||
assert sentinel_pin not in message
|
||||
assert sentinel_jeton not in message
|
||||
assert sentinel_account_pin 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
|
||||
assert sentinel_account_pin not in caplog.text
|
||||
|
||||
|
||||
# --- Persistence of credentials after data operations ---
|
||||
|
||||
Reference in New Issue
Block a user