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,6 +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.
|
||||||
|
|
||||||
|
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
|
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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ l'outil et de ne jamais toucher aux événements étrangers du calendrier.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, time
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
from icalendar import Calendar, Component, Event, vDate, vDatetime
|
from icalendar import Calendar, Component, Event, vDate, vDatetime
|
||||||
@@ -33,15 +33,7 @@ MANAGED_VALUE = "v1"
|
|||||||
PRODID = "-//pronote-sync//NONSGML v1.0//EN"
|
PRODID = "-//pronote-sync//NONSGML v1.0//EN"
|
||||||
|
|
||||||
#: Propriétés prises en compte dans la signature sémantique d'un composant.
|
#: Propriétés prises en compte dans la signature sémantique d'un composant.
|
||||||
_SIGNATURE_KEYS: tuple[str, ...] = (
|
_SIGNATURE_KEYS: tuple[str, ...] = ("UID", "SUMMARY", "DTSTART", "DTEND", "STATUS", "DESCRIPTION")
|
||||||
"UID",
|
|
||||||
"SUMMARY",
|
|
||||||
"DTSTART",
|
|
||||||
"DTEND",
|
|
||||||
"STATUS",
|
|
||||||
"TRANSP",
|
|
||||||
"DESCRIPTION",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def lesson_to_vevent(lesson: Lesson) -> Event:
|
def lesson_to_vevent(lesson: Lesson) -> Event:
|
||||||
@@ -96,9 +88,8 @@ def lesson_to_vevent(lesson: Lesson) -> Event:
|
|||||||
def homework_to_vevent(homework: Homework) -> Event:
|
def homework_to_vevent(homework: Homework) -> Event:
|
||||||
"""Convertit un devoir Pronote en composant VEVENT iCalendar.
|
"""Convertit un devoir Pronote en composant VEVENT iCalendar.
|
||||||
|
|
||||||
Le devoir est représenté comme un événement toute la journée à la date
|
Le devoir est représenté comme une tâche (``STATUS:NEEDS-ACTION``) sur la
|
||||||
d'échéance. Il est transparent pour ne pas bloquer les disponibilités ;
|
journée d'échéance, entre 08:00 et 18:00.
|
||||||
aucun statut de tâche ``VTODO`` n'est ajouté à ce ``VEVENT``.
|
|
||||||
|
|
||||||
:param homework: Devoir Pronote à sérialiser.
|
:param homework: Devoir Pronote à sérialiser.
|
||||||
:return: Composant :class:`icalendar.Event` marqué comme géré par l'outil.
|
:return: Composant :class:`icalendar.Event` marqué comme géré par l'outil.
|
||||||
@@ -107,10 +98,10 @@ def homework_to_vevent(homework: Homework) -> Event:
|
|||||||
event = Event()
|
event = Event()
|
||||||
event.add("uid", f"homework-{homework.id}")
|
event.add("uid", f"homework-{homework.id}")
|
||||||
event.add("summary", f"Devoir: {homework.subject}")
|
event.add("summary", f"Devoir: {homework.subject}")
|
||||||
event.add("dtstart", vDate(homework.due_on))
|
event.add("dtstart", vDatetime(datetime.combine(homework.due_on, time(8, 0))))
|
||||||
event.add("dtend", vDate(homework.due_on + timedelta(days=1)))
|
event.add("dtend", vDatetime(datetime.combine(homework.due_on, time(18, 0))))
|
||||||
event.add("description", homework.text)
|
event.add("description", homework.text)
|
||||||
event.add("transp", "TRANSPARENT")
|
event.add("status", "NEEDS-ACTION")
|
||||||
event.add("categories", ["Pronote", "Devoir"])
|
event.add("categories", ["Pronote", "Devoir"])
|
||||||
event.add(MANAGED_PROPERTY, MANAGED_VALUE)
|
event.add(MANAGED_PROPERTY, MANAGED_VALUE)
|
||||||
return event
|
return event
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ ajouts/mises à jour/suppressions, et la préservation des événements non gér
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date, datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -122,16 +122,10 @@ class FakeCalendar:
|
|||||||
event_start = raw_start.dt
|
event_start = raw_start.dt
|
||||||
event_end = raw_end.dt if raw_end is not None else event_start
|
event_end = raw_end.dt if raw_end is not None else event_start
|
||||||
overlaps = True
|
overlaps = True
|
||||||
if isinstance(event_start, datetime):
|
if start is not None:
|
||||||
if start is not None:
|
overlaps = overlaps and event_end > start
|
||||||
overlaps = overlaps and event_end > start
|
if end is not None:
|
||||||
if end is not None:
|
overlaps = overlaps and event_start < end
|
||||||
overlaps = overlaps and event_start < end
|
|
||||||
else:
|
|
||||||
if start is not None:
|
|
||||||
overlaps = overlaps and event_end > start.date()
|
|
||||||
if end is not None:
|
|
||||||
overlaps = overlaps and event_start < end.date()
|
|
||||||
if overlaps:
|
if overlaps:
|
||||||
results.append(FakeCalendarEvent(ical_text, uid=uid, server=self._server))
|
results.append(FakeCalendarEvent(ical_text, uid=uid, server=self._server))
|
||||||
break
|
break
|
||||||
@@ -692,80 +686,6 @@ class TestCalDAVSynchronize:
|
|||||||
assert result.added == 1
|
assert result.added == 1
|
||||||
assert len(fake_caldav_server.get_events()) == 1
|
assert len(fake_caldav_server.get_events()) == 1
|
||||||
|
|
||||||
def test_legacy_homework_vevent_is_migrated_idempotently_and_removed(
|
|
||||||
self,
|
|
||||||
fake_caldav_server: FakeCalDAVServer,
|
|
||||||
full_settings: Settings,
|
|
||||||
) -> None:
|
|
||||||
"""Migre un ancien devoir puis vérifie l'idempotence et la suppression."""
|
|
||||||
fake_caldav_server._events["homework-hw-legacy"] = _create_vevent_text(
|
|
||||||
uid="homework-hw-legacy",
|
|
||||||
summary="Devoir: Histoire",
|
|
||||||
start=datetime(2026, 1, 20, 8, 0),
|
|
||||||
end=datetime(2026, 1, 20, 18, 0),
|
|
||||||
status="NEEDS-ACTION",
|
|
||||||
managed=True,
|
|
||||||
)
|
|
||||||
homework = Homework(
|
|
||||||
id="hw-legacy",
|
|
||||||
subject="Histoire",
|
|
||||||
assigned_on=None,
|
|
||||||
due_on=date(2026, 1, 20),
|
|
||||||
text="Lire le chapitre 5",
|
|
||||||
)
|
|
||||||
|
|
||||||
def make_data(homeworks: list[Homework]) -> PronoteData:
|
|
||||||
"""Construit les données de synchronisation du scénario."""
|
|
||||||
return PronoteData(
|
|
||||||
lessons=[],
|
|
||||||
homeworks=homeworks,
|
|
||||||
school_events=[],
|
|
||||||
messages=[],
|
|
||||||
target_date=date(2026, 1, 20),
|
|
||||||
generated_at=datetime(2026, 1, 14, 0, 0),
|
|
||||||
)
|
|
||||||
|
|
||||||
first = synchronize(
|
|
||||||
pronote_data=make_data([homework]),
|
|
||||||
settings=full_settings,
|
|
||||||
client_factory=fake_caldav_server.client_factory,
|
|
||||||
now=datetime(2026, 1, 14, 12, 0),
|
|
||||||
)
|
|
||||||
assert first.status == CalDAVSyncStatus.SUCCESS
|
|
||||||
assert first.added == 0
|
|
||||||
assert first.updated == 1
|
|
||||||
assert first.removed == 0
|
|
||||||
|
|
||||||
migrated = Calendar.from_ical(fake_caldav_server.get_events()["homework-hw-legacy"])
|
|
||||||
event = migrated.walk("VEVENT")[0]
|
|
||||||
assert event.get("DTSTART").dt == date(2026, 1, 20)
|
|
||||||
assert event.get("DTEND").dt == date(2026, 1, 21)
|
|
||||||
assert event.get("STATUS") is None
|
|
||||||
assert str(event.get("TRANSP")) == "TRANSPARENT"
|
|
||||||
|
|
||||||
second = synchronize(
|
|
||||||
pronote_data=make_data([homework]),
|
|
||||||
settings=full_settings,
|
|
||||||
client_factory=fake_caldav_server.client_factory,
|
|
||||||
now=datetime(2026, 1, 14, 12, 0),
|
|
||||||
)
|
|
||||||
assert second.status == CalDAVSyncStatus.SKIPPED
|
|
||||||
assert second.added == 0
|
|
||||||
assert second.updated == 0
|
|
||||||
assert second.removed == 0
|
|
||||||
|
|
||||||
removed = synchronize(
|
|
||||||
pronote_data=make_data([]),
|
|
||||||
settings=full_settings,
|
|
||||||
client_factory=fake_caldav_server.client_factory,
|
|
||||||
now=datetime(2026, 1, 14, 12, 0),
|
|
||||||
)
|
|
||||||
assert removed.status == CalDAVSyncStatus.SUCCESS
|
|
||||||
assert removed.added == 0
|
|
||||||
assert removed.updated == 0
|
|
||||||
assert removed.removed == 1
|
|
||||||
assert fake_caldav_server.get_events() == {}
|
|
||||||
|
|
||||||
def test_school_event_sync(
|
def test_school_event_sync(
|
||||||
self,
|
self,
|
||||||
fake_caldav_server: FakeCalDAVServer,
|
fake_caldav_server: FakeCalDAVServer,
|
||||||
|
|||||||
@@ -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 ---
|
||||||
|
|||||||
@@ -239,16 +239,15 @@ def test_homework_to_vevent_uid_prefix() -> None:
|
|||||||
assert str(event.get("UID")) == "homework-HW-5678"
|
assert str(event.get("UID")) == "homework-HW-5678"
|
||||||
|
|
||||||
|
|
||||||
def test_homework_to_vevent_is_transparent_without_task_status() -> None:
|
def test_homework_to_vevent_status() -> None:
|
||||||
"""Vérifie qu'un devoir VEVENT est transparent et sans statut VTODO.
|
"""Vérifie qu'un devoir a STATUS=NEEDS-ACTION.
|
||||||
|
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
homework = _make_homework()
|
homework = _make_homework()
|
||||||
event = homework_to_vevent(homework)
|
event = homework_to_vevent(homework)
|
||||||
|
|
||||||
assert event.get("STATUS") is None
|
assert str(event.get("STATUS")) == "NEEDS-ACTION"
|
||||||
assert str(event.get("TRANSP")) == "TRANSPARENT"
|
|
||||||
|
|
||||||
|
|
||||||
def test_homework_to_vevent_categories() -> None:
|
def test_homework_to_vevent_categories() -> None:
|
||||||
@@ -266,15 +265,15 @@ def test_homework_to_vevent_categories() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_homework_to_vevent_dtstart_dtend() -> None:
|
def test_homework_to_vevent_dtstart_dtend() -> None:
|
||||||
"""Vérifie que DTSTART et DTEND encadrent la journée d'échéance.
|
"""Vérifie que DTSTART et DTEND couvrent la journée d'échéance (08:00-18:00).
|
||||||
|
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
homework = _make_homework(due_on=date(2026, 1, 20))
|
homework = _make_homework(due_on=date(2026, 1, 20))
|
||||||
event = homework_to_vevent(homework)
|
event = homework_to_vevent(homework)
|
||||||
|
|
||||||
assert event.get("DTSTART").dt == date(2026, 1, 20)
|
assert event.get("DTSTART").dt == datetime(2026, 1, 20, 8, 0)
|
||||||
assert event.get("DTEND").dt == date(2026, 1, 21)
|
assert event.get("DTEND").dt == datetime(2026, 1, 20, 18, 0)
|
||||||
|
|
||||||
|
|
||||||
def test_homework_to_vevent_summary() -> None:
|
def test_homework_to_vevent_summary() -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user