feat(auth) : prendre en charge le PIN de compte Pronote #37
@@ -24,6 +24,8 @@ PRONOTE_AUTH_MODE=password
|
|||||||
# PRONOTE_QR_CODE_FILE=/path/to/qr_code.json
|
# PRONOTE_QR_CODE_FILE=/path/to/qr_code.json
|
||||||
# PRONOTE_QR_PIN=
|
# PRONOTE_QR_PIN=
|
||||||
# Valeur à définir localement dans .env ; ne jamais la committer.
|
# Valeur à définir localement dans .env ; ne jamais la committer.
|
||||||
|
# PIN de second facteur du compte Pronote, distinct du PIN de déchiffrement du QR code
|
||||||
|
# PRONOTE_ACCOUNT_PIN=
|
||||||
|
|
||||||
# --- CalDAV ---
|
# --- CalDAV ---
|
||||||
CALDAV_URL=https://caldav.example.com/calendars/user/pronote/
|
CALDAV_URL=https://caldav.example.com/calendars/user/pronote/
|
||||||
|
|||||||
@@ -230,7 +230,8 @@ def fetch_ical(url: str) -> str:
|
|||||||
- **Masquage** : Utiliser systématiquement `redact_url()`, `redact_secrets()`, et `redact_exception()` depuis `utils/redaction.py`.
|
- **Masquage** : Utiliser systématiquement `redact_url()`, `redact_secrets()`, et `redact_exception()` depuis `utils/redaction.py`.
|
||||||
- **Chaînage d'exceptions** : Ne jamais conserver comme `__cause__` ou `__context__` une exception
|
- **Chaînage d'exceptions** : Ne jamais conserver comme `__cause__` ou `__context__` une exception
|
||||||
externe brute susceptible de contenir un secret. Journaliser la version expurgée puis utiliser
|
externe brute susceptible de contenir un secret. Journaliser la version expurgée puis utiliser
|
||||||
`raise ... from None`, ou chaîner une cause elle-même expurgée.
|
`raise ... from None` hors du bloc `except` (car `from None` seul laisse
|
||||||
|
l'exception externe dans `__context__`), ou chaîner une cause elle-même expurgée.
|
||||||
- **Tests de non-fuite** : Vérifier les messages, les logs, `__cause__`, `__context__` et le
|
- **Tests de non-fuite** : Vérifier les messages, les logs, `__cause__`, `__context__` et le
|
||||||
traceback complet avec des sentinelles distinctes pour chaque secret.
|
traceback complet avec des sentinelles distinctes pour chaque secret.
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,12 @@ 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 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.
|
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.
|
||||||
|
|
||||||
Si le blog RSS est activé, ses GUID ne sont acquittés qu'après confirmation de
|
Si le blog RSS est activé, ses GUID ne sont acquittés qu'après confirmation de
|
||||||
l'envoi XMPP. Un refus, une exception, l'absence de canal ou un `--dry-run`
|
l'envoi XMPP. Un refus, une exception, l'absence de canal ou un `--dry-run`
|
||||||
laisse donc les articles récupérables à l'exécution suivante ; les en-têtes
|
laisse donc les articles récupérables à l'exécution suivante ; les en-têtes
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ class PronoteSettings(BaseSettings):
|
|||||||
auth_mode: Literal["password", "qr_token"] = "password"
|
auth_mode: Literal["password", "qr_token"] = "password"
|
||||||
qr_code_file: str | None = None
|
qr_code_file: str | None = None
|
||||||
qr_pin: SecretStr | None = None
|
qr_pin: SecretStr | None = None
|
||||||
|
account_pin: SecretStr | None = None
|
||||||
|
|
||||||
@field_serializer("ical_url")
|
@field_serializer("ical_url")
|
||||||
def _serialize_ical_url(self, value: SecretStr | None) -> str | None:
|
def _serialize_ical_url(self, value: SecretStr | None) -> str | None:
|
||||||
@@ -72,6 +73,18 @@ class PronoteSettings(BaseSettings):
|
|||||||
return None
|
return None
|
||||||
return "**********"
|
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):
|
class CalDAVSettings(BaseSettings):
|
||||||
"""Paramètres d'accès au serveur CalDAV de destination.
|
"""Paramètres d'accès au serveur CalDAV de destination.
|
||||||
@@ -328,6 +341,7 @@ class Settings(BaseSettings):
|
|||||||
self.pronote.ical_url,
|
self.pronote.ical_url,
|
||||||
self.pronote.password,
|
self.pronote.password,
|
||||||
self.pronote.qr_pin,
|
self.pronote.qr_pin,
|
||||||
|
self.pronote.account_pin,
|
||||||
self.caldav.url,
|
self.caldav.url,
|
||||||
self.caldav.password,
|
self.caldav.password,
|
||||||
self.xmpp.password,
|
self.xmpp.password,
|
||||||
|
|||||||
@@ -119,6 +119,9 @@ def _collect_auth_secrets(client: PronoteClient) -> list[str]:
|
|||||||
# PIN QR
|
# PIN QR
|
||||||
if settings.qr_pin is not None:
|
if settings.qr_pin is not None:
|
||||||
secrets.append(settings.qr_pin.get_secret_value())
|
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)
|
# Contenu du fichier QR (jeton, login, url)
|
||||||
if settings.qr_code_file is not None:
|
if settings.qr_code_file is not None:
|
||||||
try:
|
try:
|
||||||
@@ -336,8 +339,14 @@ class PronoteClient:
|
|||||||
if self._auth_state is not None:
|
if self._auth_state is not None:
|
||||||
creds = self._auth_state.load()
|
creds = self._auth_state.load()
|
||||||
if creds is not None:
|
if creds is not None:
|
||||||
|
rotation_error: PronoteAuthRotationError | None = None
|
||||||
try:
|
try:
|
||||||
client = client_class.token_login(**creds)
|
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)
|
||||||
if client.logged_in:
|
if client.logged_in:
|
||||||
self._client = client
|
self._client = client
|
||||||
self._persist_credentials()
|
self._persist_credentials()
|
||||||
@@ -356,12 +365,16 @@ class PronoteClient:
|
|||||||
redact_exception(exc, extra_secrets=_collect_auth_secrets(self)),
|
redact_exception(exc, extra_secrets=_collect_auth_secrets(self)),
|
||||||
)
|
)
|
||||||
# Token expiré/invalide — pas de repli vers l'enrôlement QR
|
# Token expiré/invalide — pas de repli vers l'enrôlement QR
|
||||||
raise PronoteAuthRotationError(
|
rotation_error = PronoteAuthRotationError(
|
||||||
"Le token d'authentification Pronote est expiré ou invalide. "
|
"Le token d'authentification Pronote est expiré ou invalide. "
|
||||||
"Action requise : supprimez le fichier .pronote_auth_state.json "
|
"Action requise : supprimez le fichier .pronote_auth_state.json "
|
||||||
"et relancez avec un nouveau QR code (PRONOTE_QR_CODE_FILE + "
|
"et relancez avec un nouveau QR code (PRONOTE_QR_CODE_FILE + "
|
||||||
"PRONOTE_QR_PIN)."
|
"PRONOTE_QR_PIN)."
|
||||||
) from None
|
)
|
||||||
|
if rotation_error is not None:
|
||||||
|
# Lever hors du bloc ``except`` évite de conserver l'erreur
|
||||||
|
# externe dans ``__context__``.
|
||||||
|
raise rotation_error from None
|
||||||
|
|
||||||
# Enrôlement : premier login via QR code (aucun credential persisté)
|
# Enrôlement : premier login via QR code (aucun credential persisté)
|
||||||
client = self._enroll_qr_code(client_class)
|
client = self._enroll_qr_code(client_class)
|
||||||
@@ -396,6 +409,7 @@ class PronoteClient:
|
|||||||
) from None
|
) from None
|
||||||
|
|
||||||
# Read and validate QR code JSON
|
# Read and validate QR code JSON
|
||||||
|
read_error: PronoteAuthRotationError | None = None
|
||||||
try:
|
try:
|
||||||
qr_path = Path(qr_file)
|
qr_path = Path(qr_file)
|
||||||
qr_data: Any = json.loads(qr_path.read_text(encoding="utf-8"))
|
qr_data: Any = json.loads(qr_path.read_text(encoding="utf-8"))
|
||||||
@@ -405,10 +419,14 @@ class PronoteClient:
|
|||||||
redact_secrets(qr_file, extra_secrets=_collect_auth_secrets(self)),
|
redact_secrets(qr_file, extra_secrets=_collect_auth_secrets(self)),
|
||||||
redact_exception(exc, extra_secrets=_collect_auth_secrets(self)),
|
redact_exception(exc, extra_secrets=_collect_auth_secrets(self)),
|
||||||
)
|
)
|
||||||
raise PronoteAuthRotationError(
|
read_error = PronoteAuthRotationError(
|
||||||
"Impossible de lire le fichier QR code : "
|
"Impossible de lire le fichier QR code : "
|
||||||
f"{redact_secrets(qr_file, extra_secrets=_collect_auth_secrets(self))}"
|
f"{redact_secrets(qr_file, extra_secrets=_collect_auth_secrets(self))}"
|
||||||
) from None
|
)
|
||||||
|
if read_error is not None:
|
||||||
|
# Lever hors du bloc ``except`` évite de conserver l'erreur
|
||||||
|
# externe dans ``__context__``.
|
||||||
|
raise read_error from None
|
||||||
|
|
||||||
# Validate required keys
|
# Validate required keys
|
||||||
for key in ("login", "jeton", "url"):
|
for key in ("login", "jeton", "url"):
|
||||||
@@ -420,22 +438,33 @@ class PronoteClient:
|
|||||||
pin_value = qr_pin.get_secret_value()
|
pin_value = qr_pin.get_secret_value()
|
||||||
app_uuid = f"pronote-sync-{uuid4().hex}"
|
app_uuid = f"pronote-sync-{uuid4().hex}"
|
||||||
|
|
||||||
|
enrollment_error: PronoteAuthRotationError | None = None
|
||||||
try:
|
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(
|
client = client_class.qrcode_login(
|
||||||
qr_code=qr_data,
|
qr_code=qr_data,
|
||||||
pin=pin_value,
|
pin=pin_value,
|
||||||
uuid=app_uuid,
|
uuid=app_uuid,
|
||||||
|
account_pin=account_pin,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Échec de l'enrôlement QR : %s",
|
"Échec de l'enrôlement QR : %s",
|
||||||
redact_exception(exc, extra_secrets=_collect_auth_secrets(self)),
|
redact_exception(exc, extra_secrets=_collect_auth_secrets(self)),
|
||||||
)
|
)
|
||||||
raise PronoteAuthRotationError(
|
enrollment_error = PronoteAuthRotationError(
|
||||||
"Échec de l'enrôlement par QR code : PIN invalide ou QR code expiré. "
|
"Échec de l'enrôlement par QR code : PIN invalide ou QR code expiré. "
|
||||||
"Générez un nouveau QR code dans l'application Pronote et mettez à "
|
"Générez un nouveau QR code dans l'application Pronote et mettez à "
|
||||||
"jour PRONOTE_QR_CODE_FILE."
|
"jour PRONOTE_QR_CODE_FILE."
|
||||||
) from None
|
)
|
||||||
|
if enrollment_error is not None:
|
||||||
|
# Lever hors du bloc ``except`` évite de conserver l'erreur
|
||||||
|
# externe dans ``__context__``.
|
||||||
|
raise enrollment_error from None
|
||||||
|
|
||||||
return client
|
return client
|
||||||
|
|
||||||
|
|||||||
@@ -207,6 +207,23 @@ def test_qr_pin_in_redaction_secrets(monkeypatch: MonkeyPatch) -> None:
|
|||||||
assert "**********" in repr(settings.pronote.qr_pin)
|
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()
|
||||||
|
|
||||||
|
|
||||||
def test_sync_past_days_negative_direct_instantiation() -> None:
|
def test_sync_past_days_negative_direct_instantiation() -> None:
|
||||||
"""Vérifie que ``sync_past_days`` négatif lève ``ValidationError`` à l'instanciation.
|
"""Vérifie que ``sync_past_days`` négatif lève ``ValidationError`` à l'instanciation.
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import traceback
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
@@ -887,14 +888,17 @@ def test_get_informations_unchanged_in_password_mode(
|
|||||||
assert message.read is False
|
assert message.read is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("account_pin", [None, "account-pin-42"])
|
||||||
def test_connect_password_mode_unchanged(
|
def test_connect_password_mode_unchanged(
|
||||||
mocker: pytest_mock.MockerFixture,
|
mocker: pytest_mock.MockerFixture,
|
||||||
pronote_settings: PronoteSettings,
|
pronote_settings: PronoteSettings,
|
||||||
|
account_pin: str | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Vérifie que le mode password conserve le comportement historique.
|
"""Vérifie que le mode password conserve le comportement historique.
|
||||||
|
|
||||||
:param mocker: Fixture pytest-mock pour le mocking.
|
:param mocker: Fixture pytest-mock pour le mocking.
|
||||||
:param pronote_settings: Paramètres Pronote valides en mode password.
|
:param pronote_settings: Paramètres Pronote valides en mode password.
|
||||||
|
:param account_pin: PIN de compte éventuellement configuré.
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
from unittest.mock import Mock
|
from unittest.mock import Mock
|
||||||
@@ -904,7 +908,10 @@ def test_connect_password_mode_unchanged(
|
|||||||
mocker.patch("pronotepy.ParentClient", new=mock_client_class)
|
mocker.patch("pronotepy.ParentClient", new=mock_client_class)
|
||||||
mocker.patch("pronotepy.Client")
|
mocker.patch("pronotepy.Client")
|
||||||
|
|
||||||
client = PronoteClient(pronote_settings, auth_state=None)
|
settings = pronote_settings.model_copy(
|
||||||
|
update={"account_pin": SecretStr(account_pin) if account_pin is not None else None}
|
||||||
|
)
|
||||||
|
client = PronoteClient(settings, auth_state=None)
|
||||||
connected = client._connect()
|
connected = client._connect()
|
||||||
|
|
||||||
assert connected is mock_client
|
assert connected is mock_client
|
||||||
@@ -919,8 +926,10 @@ def test_connect_password_mode_unchanged(
|
|||||||
assert mock_client_class.call_count == 1
|
assert mock_client_class.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("account_pin", [None, "account-pin-42"])
|
||||||
def test_connect_qr_token_with_persisted_creds(
|
def test_connect_qr_token_with_persisted_creds(
|
||||||
mocker: pytest_mock.MockerFixture,
|
mocker: pytest_mock.MockerFixture,
|
||||||
|
account_pin: str | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Vérifie le login par token persisté en mode qr_token.
|
"""Vérifie le login par token persisté en mode qr_token.
|
||||||
|
|
||||||
@@ -928,6 +937,7 @@ def test_connect_qr_token_with_persisted_creds(
|
|||||||
``token_login`` et le token rotate est resauvegardé.
|
``token_login`` et le token rotate est resauvegardé.
|
||||||
|
|
||||||
:param mocker: Fixture pytest-mock pour le mocking.
|
:param mocker: Fixture pytest-mock pour le mocking.
|
||||||
|
:param account_pin: PIN de second facteur facultatif.
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
creds = {
|
creds = {
|
||||||
@@ -940,10 +950,26 @@ def test_connect_qr_token_with_persisted_creds(
|
|||||||
auth_state = mocker.MagicMock(spec=PronoteAuthState)
|
auth_state = mocker.MagicMock(spec=PronoteAuthState)
|
||||||
auth_state.load.return_value = creds
|
auth_state.load.return_value = creds
|
||||||
|
|
||||||
mock_client = mocker.MagicMock()
|
mock_client: MagicMock = mocker.MagicMock()
|
||||||
mock_client.logged_in = True
|
mock_client.logged_in = True
|
||||||
mock_client.export_credentials.return_value = rotated_creds
|
mock_client.export_credentials.return_value = rotated_creds
|
||||||
mocker.patch("pronotepy.ParentClient.token_login", return_value=mock_client)
|
|
||||||
|
def token_login_fake(
|
||||||
|
pronote_url: str,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
uuid: str,
|
||||||
|
account_pin: str | None = None,
|
||||||
|
client_identifier: str | None = None,
|
||||||
|
device_name: str | None = None,
|
||||||
|
) -> MagicMock:
|
||||||
|
"""Retourne le faux client avec la signature pronotepy 2.15.7."""
|
||||||
|
del pronote_url, username, password, uuid, account_pin, client_identifier, device_name
|
||||||
|
return mock_client
|
||||||
|
|
||||||
|
token_login = mocker.patch(
|
||||||
|
"pronotepy.ParentClient.token_login", autospec=True, side_effect=token_login_fake
|
||||||
|
)
|
||||||
|
|
||||||
settings = PronoteSettings(
|
settings = PronoteSettings(
|
||||||
url="https://pronote.example.com",
|
url="https://pronote.example.com",
|
||||||
@@ -952,23 +978,27 @@ def test_connect_qr_token_with_persisted_creds(
|
|||||||
ent=None,
|
ent=None,
|
||||||
account_type="parent",
|
account_type="parent",
|
||||||
auth_mode="qr_token",
|
auth_mode="qr_token",
|
||||||
|
account_pin=SecretStr(account_pin) if account_pin is not None else None,
|
||||||
)
|
)
|
||||||
client = PronoteClient(settings, auth_state=auth_state)
|
client = PronoteClient(settings, auth_state=auth_state)
|
||||||
connected = client._connect()
|
connected = client._connect()
|
||||||
|
|
||||||
assert connected is mock_client
|
assert connected is mock_client
|
||||||
pronotepy.ParentClient.token_login.assert_called_once_with(**creds) # type: ignore[attr-defined]
|
token_login.assert_called_once_with(**creds, account_pin=account_pin)
|
||||||
auth_state.save.assert_called_once_with(rotated_creds)
|
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(
|
def test_connect_qr_token_no_creds_with_qr_code(
|
||||||
mocker: pytest_mock.MockerFixture,
|
mocker: pytest_mock.MockerFixture,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
|
account_pin: str | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Vérifie l'enrôlement initial par QR code quand aucun token n'est persisté.
|
"""Vérifie l'enrôlement initial par QR code quand aucun token n'est persisté.
|
||||||
|
|
||||||
:param mocker: Fixture pytest-mock pour le mocking.
|
:param mocker: Fixture pytest-mock pour le mocking.
|
||||||
:param tmp_path: Répertoire temporaire de test.
|
:param tmp_path: Répertoire temporaire de test.
|
||||||
|
:param account_pin: PIN de second facteur facultatif.
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
qr_file = tmp_path / "qr_code.json"
|
qr_file = tmp_path / "qr_code.json"
|
||||||
@@ -991,10 +1021,26 @@ def test_connect_qr_token_no_creds_with_qr_code(
|
|||||||
"password": "new-token", # pragma: allowlist secret
|
"password": "new-token", # pragma: allowlist secret
|
||||||
"uuid": "new-uuid",
|
"uuid": "new-uuid",
|
||||||
}
|
}
|
||||||
mock_client = mocker.MagicMock()
|
mock_client: MagicMock = mocker.MagicMock()
|
||||||
mock_client.logged_in = True
|
mock_client.logged_in = True
|
||||||
mock_client.export_credentials.return_value = creds
|
mock_client.export_credentials.return_value = creds
|
||||||
mocker.patch("pronotepy.ParentClient.qrcode_login", return_value=mock_client)
|
|
||||||
|
def qrcode_login_fake(
|
||||||
|
qr_code: dict[str, str],
|
||||||
|
pin: str,
|
||||||
|
uuid: str,
|
||||||
|
account_pin: str | None = None,
|
||||||
|
client_identifier: str | None = None,
|
||||||
|
device_name: str | None = None,
|
||||||
|
skip_2fa: bool = False,
|
||||||
|
) -> MagicMock:
|
||||||
|
"""Retourne le faux client avec la signature pronotepy 2.15.7."""
|
||||||
|
del qr_code, pin, uuid, account_pin, client_identifier, device_name, skip_2fa
|
||||||
|
return mock_client
|
||||||
|
|
||||||
|
qrcode_login = mocker.patch(
|
||||||
|
"pronotepy.ParentClient.qrcode_login", autospec=True, side_effect=qrcode_login_fake
|
||||||
|
)
|
||||||
|
|
||||||
settings = PronoteSettings(
|
settings = PronoteSettings(
|
||||||
url="https://pronote.example.com",
|
url="https://pronote.example.com",
|
||||||
@@ -1005,15 +1051,16 @@ def test_connect_qr_token_no_creds_with_qr_code(
|
|||||||
auth_mode="qr_token",
|
auth_mode="qr_token",
|
||||||
qr_code_file=str(qr_file),
|
qr_code_file=str(qr_file),
|
||||||
qr_pin=SecretStr("123456"),
|
qr_pin=SecretStr("123456"),
|
||||||
|
account_pin=SecretStr(account_pin) if account_pin is not None else None,
|
||||||
)
|
)
|
||||||
client = PronoteClient(settings, auth_state=auth_state)
|
client = PronoteClient(settings, auth_state=auth_state)
|
||||||
connected = client._connect()
|
connected = client._connect()
|
||||||
|
|
||||||
assert connected is mock_client
|
assert connected is mock_client
|
||||||
qrcode_login = pronotepy.ParentClient.qrcode_login
|
qrcode_login.assert_called_once()
|
||||||
qrcode_login.assert_called_once() # type: ignore[attr-defined]
|
kwargs = qrcode_login.call_args.kwargs
|
||||||
kwargs = qrcode_login.call_args.kwargs # type: ignore[attr-defined]
|
|
||||||
assert kwargs["pin"] == "123456"
|
assert kwargs["pin"] == "123456"
|
||||||
|
assert kwargs["account_pin"] == account_pin
|
||||||
assert kwargs["qr_code"] == {
|
assert kwargs["qr_code"] == {
|
||||||
"login": "testuser",
|
"login": "testuser",
|
||||||
"jeton": "qr-jeton",
|
"jeton": "qr-jeton",
|
||||||
@@ -1390,6 +1437,12 @@ def test_no_secrets_in_rotation_error_messages(
|
|||||||
assert sentinel_pin not in caplog.text
|
assert sentinel_pin not in caplog.text
|
||||||
assert sentinel_token not in caplog.text
|
assert sentinel_token not in caplog.text
|
||||||
assert "sentinel-url" not in caplog.text
|
assert "sentinel-url" not in caplog.text
|
||||||
|
assert exc_info.value.__cause__ is None
|
||||||
|
assert exc_info.value.__context__ is None
|
||||||
|
formatted_traceback = "".join(traceback.format_exception(exc_info.value))
|
||||||
|
assert sentinel_pin not in formatted_traceback
|
||||||
|
assert sentinel_token not in formatted_traceback
|
||||||
|
assert "sentinel-url" not in formatted_traceback
|
||||||
|
|
||||||
|
|
||||||
def test_no_raw_secrets_in_logs(
|
def test_no_raw_secrets_in_logs(
|
||||||
@@ -1412,6 +1465,7 @@ def test_no_raw_secrets_in_logs(
|
|||||||
sentinel_token = "SENTINEL_RAW_TOKEN_ALPHA"
|
sentinel_token = "SENTINEL_RAW_TOKEN_ALPHA"
|
||||||
sentinel_pin = "SENTINEL_RAW_PIN_BRAVO"
|
sentinel_pin = "SENTINEL_RAW_PIN_BRAVO"
|
||||||
sentinel_jeton = "SENTINEL_RAW_JETON_CHARLIE"
|
sentinel_jeton = "SENTINEL_RAW_JETON_CHARLIE"
|
||||||
|
sentinel_account_pin = "SENTINEL_RAW_ACCOUNT_PIN_DELTA"
|
||||||
|
|
||||||
qr_file = tmp_path / "qr_code.json"
|
qr_file = tmp_path / "qr_code.json"
|
||||||
qr_file.write_text(
|
qr_file.write_text(
|
||||||
@@ -1436,6 +1490,7 @@ def test_no_raw_secrets_in_logs(
|
|||||||
"pronotepy.ParentClient.token_login",
|
"pronotepy.ParentClient.token_login",
|
||||||
side_effect=pronotepy.PronoteAPIError(
|
side_effect=pronotepy.PronoteAPIError(
|
||||||
f"login refusé {sentinel_token} puis {sentinel_pin} puis {sentinel_jeton} "
|
f"login refusé {sentinel_token} puis {sentinel_pin} puis {sentinel_jeton} "
|
||||||
|
f"puis {sentinel_account_pin}"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1448,6 +1503,7 @@ def test_no_raw_secrets_in_logs(
|
|||||||
auth_mode="qr_token",
|
auth_mode="qr_token",
|
||||||
qr_code_file=str(qr_file),
|
qr_code_file=str(qr_file),
|
||||||
qr_pin=SecretStr(sentinel_pin),
|
qr_pin=SecretStr(sentinel_pin),
|
||||||
|
account_pin=SecretStr(sentinel_account_pin),
|
||||||
)
|
)
|
||||||
client = PronoteClient(settings, auth_state=auth_state)
|
client = PronoteClient(settings, auth_state=auth_state)
|
||||||
|
|
||||||
@@ -1459,10 +1515,19 @@ def test_no_raw_secrets_in_logs(
|
|||||||
assert sentinel_token not in message
|
assert sentinel_token not in message
|
||||||
assert sentinel_pin not in message
|
assert sentinel_pin not in message
|
||||||
assert sentinel_jeton not in message
|
assert sentinel_jeton not in message
|
||||||
|
assert sentinel_account_pin not in message
|
||||||
assert caplog.text
|
assert caplog.text
|
||||||
assert sentinel_token not in caplog.text
|
assert sentinel_token not in caplog.text
|
||||||
assert sentinel_pin not in caplog.text
|
assert sentinel_pin not in caplog.text
|
||||||
assert sentinel_jeton not in caplog.text
|
assert sentinel_jeton not in caplog.text
|
||||||
|
assert sentinel_account_pin not in caplog.text
|
||||||
|
assert exc_info.value.__cause__ is None
|
||||||
|
assert exc_info.value.__context__ is None
|
||||||
|
formatted_traceback = "".join(traceback.format_exception(exc_info.value))
|
||||||
|
assert sentinel_token not in formatted_traceback
|
||||||
|
assert sentinel_pin not in formatted_traceback
|
||||||
|
assert sentinel_jeton not in formatted_traceback
|
||||||
|
assert sentinel_account_pin not in formatted_traceback
|
||||||
|
|
||||||
|
|
||||||
# --- Persistence of credentials after data operations ---
|
# --- Persistence of credentials after data operations ---
|
||||||
|
|||||||
Reference in New Issue
Block a user