Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ad62ad50f | ||
|
|
2d55ac8d2b |
@@ -23,6 +23,8 @@ PRONOTE_AUTH_MODE=password
|
|||||||
# PRONOTE_AUTH_MODE=qr_token
|
# PRONOTE_AUTH_MODE=qr_token
|
||||||
# PRONOTE_QR_CODE_FILE=/path/to/qr_code.json
|
# PRONOTE_QR_CODE_FILE=/path/to/qr_code.json
|
||||||
# PRONOTE_QR_PIN=1234
|
# 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 ---
|
||||||
CALDAV_URL=https://caldav.example.com/calendars/user/pronote/
|
CALDAV_URL=https://caldav.example.com/calendars/user/pronote/
|
||||||
|
|||||||
@@ -66,12 +66,11 @@ 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.
|
||||||
|
|
||||||
Si le blog RSS est activé, ses GUID ne sont acquittés qu'après confirmation de
|
En mode `PRONOTE_AUTH_MODE=qr_token`, `PRONOTE_QR_PIN` déchiffre le QR code
|
||||||
l'envoi XMPP. Un refus, une exception, l'absence de canal ou un `--dry-run`
|
exporté depuis le site web Pronote. Si le compte exige un second facteur,
|
||||||
laisse donc les articles récupérables à l'exécution suivante ; les en-têtes
|
configurez aussi `PRONOTE_ACCOUNT_PIN` avec le PIN du compte. Ce PIN est
|
||||||
HTTP associés à ces articles suivent la même règle pour éviter un `304` qui
|
transmis uniquement à `pronotepy` lors de l'enrôlement QR et des connexions par
|
||||||
masquerait une livraison non confirmée.
|
token ; il n'est jamais écrit dans `.pronote_auth_state.json` ni dans les logs.
|
||||||
|
|
||||||
En mode `PRONOTE_AUTH_MODE=qr_token`, le fichier
|
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
|
`.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
|
de travail du service (par exemple `/var/lib/pronote-sync`) avec le mode
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -230,13 +230,11 @@ class PipelineRunner:
|
|||||||
data = normalize_step(fetched, generated_at=now)
|
data = normalize_step(fetched, generated_at=now)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
blog_result = fetch_blog_step(self._blog_client, self._blog_state)
|
blog_articles = fetch_blog_step(self._blog_client, self._blog_state)
|
||||||
blog_articles = list(blog_result.articles)
|
|
||||||
except PipelineCriticalError:
|
except PipelineCriticalError:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._warn("fetch_blog", self._redact(exc))
|
self._warn("fetch_blog", self._redact(exc))
|
||||||
blog_result = None
|
|
||||||
blog_articles = []
|
blog_articles = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -292,11 +290,8 @@ class PipelineRunner:
|
|||||||
)
|
)
|
||||||
if self._channel is not None and not self._dry_run:
|
if self._channel is not None and not self._dry_run:
|
||||||
try:
|
try:
|
||||||
delivered = send_step(self._channel, message)
|
if not send_step(self._channel, message):
|
||||||
if not delivered:
|
|
||||||
self._warn("send", "Le canal XMPP a refusé l'envoi")
|
self._warn("send", "Le canal XMPP a refusé l'envoi")
|
||||||
elif blog_result is not None and self._blog_state is not None:
|
|
||||||
self._blog_state.acknowledge(blog_result)
|
|
||||||
except PipelineCriticalError:
|
except PipelineCriticalError:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -2,28 +2,23 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pronote_sync.sources.blog.result import BlogRSSFetchResult
|
from pronote_sync.models.blog import BlogArticle
|
||||||
from pronote_sync.sources.blog.rss import BlogRSSClient
|
from pronote_sync.sources.blog.rss import BlogRSSClient
|
||||||
from pronote_sync.sources.blog.state import BlogRSSState
|
from pronote_sync.sources.blog.state import BlogRSSState
|
||||||
from pronote_sync.utils.redaction import redact_exception
|
from pronote_sync.utils.redaction import redact_exception
|
||||||
|
|
||||||
|
|
||||||
def fetch_blog_step(client: BlogRSSClient | None, state: BlogRSSState | None) -> BlogRSSFetchResult:
|
def fetch_blog_step(client: BlogRSSClient | None, state: BlogRSSState | None) -> list[BlogArticle]:
|
||||||
"""Récupère les articles RSS nouveaux sans les acquitter.
|
"""Récupère les articles RSS nouveaux en conservant l'état du client.
|
||||||
|
|
||||||
L'état des GUID est acquitté séparément par le pipeline après confirmation
|
|
||||||
de la livraison XMPP. Les en-têtes de cache d'une réponse sans article
|
|
||||||
peuvent être conservés immédiatement, car aucune livraison n'est alors en
|
|
||||||
attente.
|
|
||||||
|
|
||||||
:param client: Client RSS configuré, ou ``None`` lorsque le blog est désactivé.
|
:param client: Client RSS configuré, ou ``None`` lorsque le blog est désactivé.
|
||||||
:param state: État de déduplication et de cache HTTP associé au run.
|
:param state: État de déduplication et de cache HTTP associé au run.
|
||||||
:return: Résultat de récupération, incluant les métadonnées de cache.
|
:return: Nouveaux articles du blog.
|
||||||
:rtype: BlogRSSFetchResult
|
:rtype: list[BlogArticle]
|
||||||
:raises RuntimeError: Si la récupération RSS injectée échoue.
|
:raises RuntimeError: Si la récupération RSS injectée échoue.
|
||||||
"""
|
"""
|
||||||
if client is None or state is None:
|
if client is None or state is None:
|
||||||
return BlogRSSFetchResult()
|
return []
|
||||||
try:
|
try:
|
||||||
etag, last_modified = state.get_cache_headers()
|
etag, last_modified = state.get_cache_headers()
|
||||||
result = client.fetch_and_parse(
|
result = client.fetch_and_parse(
|
||||||
@@ -31,8 +26,9 @@ def fetch_blog_step(client: BlogRSSClient | None, state: BlogRSSState | None) ->
|
|||||||
)
|
)
|
||||||
if result.error is not None:
|
if result.error is not None:
|
||||||
raise RuntimeError(result.error) from None
|
raise RuntimeError(result.error) from None
|
||||||
if not result.not_modified and not result.articles:
|
if not result.not_modified:
|
||||||
|
state.add_guids(article.id for article in result.articles)
|
||||||
state.update_cache_headers(result.etag, result.last_modified)
|
state.update_cache_headers(result.etag, result.last_modified)
|
||||||
return result
|
return list(result.articles)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise RuntimeError(f"Récupération du blog échouée : {redact_exception(exc)}") from None
|
raise RuntimeError(f"Récupération du blog échouée : {redact_exception(exc)}") from None
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import logging
|
|||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from pronote_sync.sources.blog.result import BlogRSSFetchResult
|
|
||||||
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__)
|
||||||
@@ -157,22 +156,6 @@ class BlogRSSState:
|
|||||||
self._known_guids.update(new_guids)
|
self._known_guids.update(new_guids)
|
||||||
self._save()
|
self._save()
|
||||||
|
|
||||||
def acknowledge(self, result: BlogRSSFetchResult) -> None:
|
|
||||||
"""Acquitte une récupération RSS après sa livraison confirmée.
|
|
||||||
|
|
||||||
Les GUID et les en-têtes de cache sont enregistrés ensemble afin qu'un
|
|
||||||
article dont la livraison a échoué reste récupérable à l'exécution
|
|
||||||
suivante. Une réponse ``304 Not Modified`` n'a rien à acquitter.
|
|
||||||
|
|
||||||
:param result: Résultat RSS livré avec succès.
|
|
||||||
"""
|
|
||||||
if result.not_modified:
|
|
||||||
return
|
|
||||||
self._known_guids.update(article.id for article in result.articles)
|
|
||||||
self._etag = result.etag
|
|
||||||
self._last_modified = result.last_modified
|
|
||||||
self._save()
|
|
||||||
|
|
||||||
def get_cache_headers(self) -> tuple[str | None, str | None]:
|
def get_cache_headers(self) -> tuple[str | None, str | None]:
|
||||||
"""Renvoie les en-têtes de cache HTTP mémorisés.
|
"""Renvoie les en-têtes de cache HTTP mémorisés.
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -337,7 +340,12 @@ class PronoteClient:
|
|||||||
creds = self._auth_state.load()
|
creds = self._auth_state.load()
|
||||||
if creds is not None:
|
if creds is not 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()
|
||||||
@@ -421,10 +429,16 @@ class PronoteClient:
|
|||||||
app_uuid = f"pronote-sync-{uuid4().hex}"
|
app_uuid = f"pronote-sync-{uuid4().hex}"
|
||||||
|
|
||||||
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(
|
||||||
|
|||||||
@@ -1040,110 +1040,6 @@ def test_runner_blog_success_delivers_articles_into_xmpp_message_external_info(
|
|||||||
assert xmpp_message.external_info.blog_articles[0].title == "Test Article"
|
assert xmpp_message.external_info.blog_articles[0].title == "Test Article"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("channel_kind", "dry_run", "should_acknowledge"),
|
|
||||||
[
|
|
||||||
("success", False, True),
|
|
||||||
("false", False, False),
|
|
||||||
("exception", False, False),
|
|
||||||
("none", False, False),
|
|
||||||
("success", True, False),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_runner_acknowledges_blog_only_after_confirmed_xmpp_delivery(
|
|
||||||
pipeline_inputs: tuple[Lesson, Homework],
|
|
||||||
tmp_path: Any,
|
|
||||||
channel_kind: str,
|
|
||||||
dry_run: bool,
|
|
||||||
should_acknowledge: bool,
|
|
||||||
) -> None:
|
|
||||||
"""Les GUID RSS restent rejouables tant que XMPP n'a pas confirmé l'envoi.
|
|
||||||
|
|
||||||
:param pipeline_inputs: Données Pronote de test.
|
|
||||||
:param tmp_path: Répertoire temporaire pour l'état RSS.
|
|
||||||
:param channel_kind: Comportement du canal XMPP simulé.
|
|
||||||
:param dry_run: Active ou non le mode simulation.
|
|
||||||
:param should_acknowledge: Indique si l'état RSS doit être acquitté.
|
|
||||||
"""
|
|
||||||
lesson, homework = pipeline_inputs
|
|
||||||
calls: list[str] = []
|
|
||||||
state_file = tmp_path / "blog-state.json"
|
|
||||||
|
|
||||||
class SuccessfulBlogClient:
|
|
||||||
"""Client RSS renvoyant un article non encore livré."""
|
|
||||||
|
|
||||||
def fetch_and_parse(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
known_guids: frozenset[str] | None = None,
|
|
||||||
etag: str | None = None,
|
|
||||||
last_modified: str | None = None,
|
|
||||||
) -> BlogRSSFetchResult:
|
|
||||||
"""Retourne un article et des en-têtes de cache déterministes.
|
|
||||||
|
|
||||||
:param known_guids: GUID déjà connus, ignorés dans ce faux client.
|
|
||||||
:param etag: ETag mémorisé, ignoré dans ce faux client.
|
|
||||||
:param last_modified: Date HTTP mémorisée, ignorée dans ce faux client.
|
|
||||||
:return: Résultat RSS avec un article à livrer.
|
|
||||||
:rtype: BlogRSSFetchResult
|
|
||||||
"""
|
|
||||||
del known_guids, etag, last_modified
|
|
||||||
return BlogRSSFetchResult(
|
|
||||||
articles=(
|
|
||||||
BlogArticle(
|
|
||||||
id="article-to-deliver",
|
|
||||||
title="Article à livrer",
|
|
||||||
url="https://example.com/article-to-deliver",
|
|
||||||
published_at=datetime(2026, 9, 8, 12, 0),
|
|
||||||
updated_at=None,
|
|
||||||
category=None,
|
|
||||||
author=None,
|
|
||||||
content_html="<p>Contenu</p>",
|
|
||||||
content_text="Contenu",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
etag="etag-after-delivery",
|
|
||||||
last_modified="Tue, 08 Sep 2026 12:00:00 GMT",
|
|
||||||
)
|
|
||||||
|
|
||||||
channel: Any
|
|
||||||
if channel_kind == "success":
|
|
||||||
channel = StubChannel(calls)
|
|
||||||
elif channel_kind == "false":
|
|
||||||
channel = FailingChannel()
|
|
||||||
elif channel_kind == "exception":
|
|
||||||
channel = ExceptionalChannel()
|
|
||||||
else:
|
|
||||||
channel = None
|
|
||||||
|
|
||||||
runner = PipelineRunner(
|
|
||||||
settings=Settings(blog=Settings().blog.model_copy(update={"enabled": True})),
|
|
||||||
pronote_fetcher=StubFetcher(calls, lesson, homework),
|
|
||||||
caldav_synchronizer=lambda data, settings: successful_sync_result(),
|
|
||||||
agenda_comparator=cast("AgendaComparator | None", StubComparator(calls)),
|
|
||||||
blog_client=cast("BlogRSSClient | None", SuccessfulBlogClient()),
|
|
||||||
blog_state=BlogRSSState(state_file),
|
|
||||||
channel=channel,
|
|
||||||
dry_run=dry_run,
|
|
||||||
now_provider=lambda: datetime(2026, 9, 8, 7, 0),
|
|
||||||
)
|
|
||||||
|
|
||||||
data, errors = runner.run()
|
|
||||||
|
|
||||||
assert data is not None
|
|
||||||
if should_acknowledge:
|
|
||||||
acknowledged_state = BlogRSSState(state_file)
|
|
||||||
assert acknowledged_state.get_known_guids() == frozenset({"article-to-deliver"})
|
|
||||||
assert acknowledged_state.get_cache_headers() == (
|
|
||||||
"etag-after-delivery",
|
|
||||||
"Tue, 08 Sep 2026 12:00:00 GMT",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
assert not state_file.exists()
|
|
||||||
if channel_kind in {"false", "exception"}:
|
|
||||||
assert any(error.step == "send" for error in errors)
|
|
||||||
|
|
||||||
|
|
||||||
def test_runner_secret_redaction_in_pipeline_errors(
|
def test_runner_secret_redaction_in_pipeline_errors(
|
||||||
pipeline_inputs: tuple[Lesson, Homework],
|
pipeline_inputs: tuple[Lesson, Homework],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -14,14 +14,11 @@ Tous les tests utilisent des fichiers temporaires via la fixture ``tmp_path``.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from datetime import UTC, datetime
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from pronote_sync.models.blog import BlogArticle
|
|
||||||
from pronote_sync.sources.blog.result import BlogRSSFetchResult
|
|
||||||
from pronote_sync.sources.blog.state import BlogRSSState
|
from pronote_sync.sources.blog.state import BlogRSSState
|
||||||
|
|
||||||
|
|
||||||
@@ -119,38 +116,6 @@ def test_add_guids_empty_noop(tmp_path: Path) -> None:
|
|||||||
assert state_file.read_text(encoding="utf-8") == original_content
|
assert state_file.read_text(encoding="utf-8") == original_content
|
||||||
|
|
||||||
|
|
||||||
def test_acknowledge_persists_guids_and_cache_headers_together(tmp_path: Path) -> None:
|
|
||||||
"""Vérifie l'acquittement atomique après une livraison confirmée.
|
|
||||||
|
|
||||||
:param tmp_path: Fixture pytest pour un répertoire temporaire.
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
state_file = tmp_path / "state.json"
|
|
||||||
state = BlogRSSState(state_file)
|
|
||||||
article = BlogArticle(
|
|
||||||
id="guid-1",
|
|
||||||
title="Article",
|
|
||||||
url="https://example.com/article",
|
|
||||||
published_at=datetime(2026, 9, 12, 8, 0, tzinfo=UTC),
|
|
||||||
updated_at=None,
|
|
||||||
category=None,
|
|
||||||
author=None,
|
|
||||||
content_html="<p>Contenu</p>",
|
|
||||||
content_text="Contenu",
|
|
||||||
)
|
|
||||||
|
|
||||||
state.acknowledge(
|
|
||||||
BlogRSSFetchResult(
|
|
||||||
articles=(article,),
|
|
||||||
etag="etag-1",
|
|
||||||
last_modified="Sat, 12 Sep 2026 08:00:00 GMT",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert state.get_known_guids() == frozenset({"guid-1"})
|
|
||||||
assert state.get_cache_headers() == ("etag-1", "Sat, 12 Sep 2026 08:00:00 GMT")
|
|
||||||
|
|
||||||
|
|
||||||
def test_state_load_persisted_guids(tmp_path: Path) -> None:
|
def test_state_load_persisted_guids(tmp_path: Path) -> None:
|
||||||
"""Vérifie que les GUID persistés sont rechargés dans une nouvelle instance.
|
"""Vérifie que les GUID persistés sont rechargés dans une nouvelle instance.
|
||||||
|
|
||||||
|
|||||||
@@ -207,4 +207,21 @@ 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()
|
||||||
|
|
||||||
|
|
||||||
# Ensure trailing newline
|
# Ensure trailing newline
|
||||||
|
|||||||
@@ -674,8 +674,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.
|
||||||
|
|
||||||
@@ -683,6 +685,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 = {
|
||||||
@@ -707,23 +710,29 @@ 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]
|
pronotepy.ParentClient.token_login.assert_called_once_with( # type: ignore[attr-defined]
|
||||||
|
**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"
|
||||||
@@ -760,6 +769,7 @@ 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()
|
||||||
@@ -769,6 +779,7 @@ def test_connect_qr_token_no_creds_with_qr_code(
|
|||||||
qrcode_login.assert_called_once() # type: ignore[attr-defined]
|
qrcode_login.assert_called_once() # type: ignore[attr-defined]
|
||||||
kwargs = qrcode_login.call_args.kwargs # type: ignore[attr-defined]
|
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",
|
||||||
@@ -1167,6 +1178,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(
|
||||||
@@ -1190,7 +1202,8 @@ def test_no_raw_secrets_in_logs(
|
|||||||
mocker.patch(
|
mocker.patch(
|
||||||
"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}"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1203,6 +1216,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)
|
||||||
|
|
||||||
@@ -1214,10 +1228,12 @@ 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
|
||||||
|
|
||||||
|
|
||||||
# --- Persistence of credentials after data operations ---
|
# --- Persistence of credentials after data operations ---
|
||||||
|
|||||||
Reference in New Issue
Block a user