Author SHA1 Message Date
Codex 6575a5f60b fix(caldav): rendre les devoirs transparents 2026-09-12 15:43:12 +02:00
Codex 6bb8ad1ed1 docs(deploiement) : imposer les permissions de la configuration
Aligne la documentation d'exploitation et le wiki sur les permissions protegees des secrets et de l'etat d'authentification.

Co-authored-by: Codex <codex@antoineve.me>
2026-09-12 15:26:57 +02:00
4 changed files with 134 additions and 18 deletions
+26
View File
@@ -17,6 +17,24 @@ sudo install -d -m 0700 -o <utilisateur-service> -g <groupe-service> <repertoire
sudo install -m 0600 -o <utilisateur-service> -g <groupe-service> .env <fichier-environnement>
```
Pour créer directement la configuration à partir du modèle versionné, utilisez
la même installation protégée puis éditez le fichier avec un compte autorisé :
```bash
sudo install -m 0600 -o <utilisateur-service> -g <groupe-service> \
/opt/pronote-sync/.env.example <fichier-environnement>
```
Contrôlez ensuite uniquement les métadonnées du fichier, sans afficher son
contenu :
```bash
stat -c 'mode=%a proprietaire=%U groupe=%G chemin=%n' <fichier-environnement>
```
Le résultat attendu est le mode `600` et le propriétaire/groupe du compte de
service. Le répertoire de configuration doit rester en `0700`.
Les unités fournies nécessitent l'interface CLI livrée au jalon M12. Avant de
les installer, vérifiez que la version installée contient bien ce point
d'entrée :
@@ -48,6 +66,14 @@ 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`, 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
`0600`. Ils contiennent des credentials vivantes et ne doivent être ni copiés,
ni versionnés, ni affichés. Le PIN du QR code et, si nécessaire, le PIN de
second facteur du compte sont également des secrets ; configurez-les uniquement
dans le fichier d'environnement protégé.
## Installation systemd
Les fichiers versionnés sont :
+16 -7
View File
@@ -14,7 +14,7 @@ l'outil et de ne jamais toucher aux événements étrangers du calendrier.
from __future__ import annotations
from datetime import datetime, time
from datetime import datetime, timedelta
from typing import cast
from icalendar import Calendar, Component, Event, vDate, vDatetime
@@ -33,7 +33,15 @@ MANAGED_VALUE = "v1"
PRODID = "-//pronote-sync//NONSGML v1.0//EN"
#: Propriétés prises en compte dans la signature sémantique d'un composant.
_SIGNATURE_KEYS: tuple[str, ...] = ("UID", "SUMMARY", "DTSTART", "DTEND", "STATUS", "DESCRIPTION")
_SIGNATURE_KEYS: tuple[str, ...] = (
"UID",
"SUMMARY",
"DTSTART",
"DTEND",
"STATUS",
"TRANSP",
"DESCRIPTION",
)
def lesson_to_vevent(lesson: Lesson) -> Event:
@@ -88,8 +96,9 @@ def lesson_to_vevent(lesson: Lesson) -> Event:
def homework_to_vevent(homework: Homework) -> Event:
"""Convertit un devoir Pronote en composant VEVENT iCalendar.
Le devoir est représenté comme une tâche (``STATUS:NEEDS-ACTION``) sur la
journée d'échéance, entre 08:00 et 18:00.
Le devoir est représenté comme un événement toute la journée à la date
d'échéance. Il est transparent pour ne pas bloquer les disponibilités ;
aucun statut de tâche ``VTODO`` n'est ajouté à ce ``VEVENT``.
:param homework: Devoir Pronote à sérialiser.
:return: Composant :class:`icalendar.Event` marqué comme géré par l'outil.
@@ -98,10 +107,10 @@ def homework_to_vevent(homework: Homework) -> Event:
event = Event()
event.add("uid", f"homework-{homework.id}")
event.add("summary", f"Devoir: {homework.subject}")
event.add("dtstart", vDatetime(datetime.combine(homework.due_on, time(8, 0))))
event.add("dtend", vDatetime(datetime.combine(homework.due_on, time(18, 0))))
event.add("dtstart", vDate(homework.due_on))
event.add("dtend", vDate(homework.due_on + timedelta(days=1)))
event.add("description", homework.text)
event.add("status", "NEEDS-ACTION")
event.add("transp", "TRANSPARENT")
event.add("categories", ["Pronote", "Devoir"])
event.add(MANAGED_PROPERTY, MANAGED_VALUE)
return event
+85 -5
View File
@@ -7,7 +7,7 @@ ajouts/mises à jour/suppressions, et la préservation des événements non gér
from __future__ import annotations
from datetime import datetime
from datetime import date, datetime
from typing import TYPE_CHECKING, Any
import pytest
@@ -122,10 +122,16 @@ class FakeCalendar:
event_start = raw_start.dt
event_end = raw_end.dt if raw_end is not None else event_start
overlaps = True
if start is not None:
overlaps = overlaps and event_end > start
if end is not None:
overlaps = overlaps and event_start < end
if isinstance(event_start, datetime):
if start is not None:
overlaps = overlaps and event_end > start
if end is not None:
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:
results.append(FakeCalendarEvent(ical_text, uid=uid, server=self._server))
break
@@ -686,6 +692,80 @@ class TestCalDAVSynchronize:
assert result.added == 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(
self,
fake_caldav_server: FakeCalDAVServer,
+7 -6
View File
@@ -239,15 +239,16 @@ def test_homework_to_vevent_uid_prefix() -> None:
assert str(event.get("UID")) == "homework-HW-5678"
def test_homework_to_vevent_status() -> None:
"""Vérifie qu'un devoir a STATUS=NEEDS-ACTION.
def test_homework_to_vevent_is_transparent_without_task_status() -> None:
"""Vérifie qu'un devoir VEVENT est transparent et sans statut VTODO.
:return: None
"""
homework = _make_homework()
event = homework_to_vevent(homework)
assert str(event.get("STATUS")) == "NEEDS-ACTION"
assert event.get("STATUS") is None
assert str(event.get("TRANSP")) == "TRANSPARENT"
def test_homework_to_vevent_categories() -> None:
@@ -265,15 +266,15 @@ def test_homework_to_vevent_categories() -> None:
def test_homework_to_vevent_dtstart_dtend() -> None:
"""Vérifie que DTSTART et DTEND couvrent la journée d'échéance (08:00-18:00).
"""Vérifie que DTSTART et DTEND encadrent la journée d'échéance.
:return: None
"""
homework = _make_homework(due_on=date(2026, 1, 20))
event = homework_to_vevent(homework)
assert event.get("DTSTART").dt == datetime(2026, 1, 20, 8, 0)
assert event.get("DTEND").dt == datetime(2026, 1, 20, 18, 0)
assert event.get("DTSTART").dt == date(2026, 1, 20)
assert event.get("DTEND").dt == date(2026, 1, 21)
def test_homework_to_vevent_summary() -> None: