Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ff6e687b6 |
@@ -17,24 +17,6 @@ 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>
|
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 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
|
les installer, vérifiez que la version installée contient bien ce point
|
||||||
d'entrée :
|
d'entrée :
|
||||||
@@ -66,14 +48,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 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`, 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
|
## Installation systemd
|
||||||
|
|
||||||
Les fichiers versionnés sont :
|
Les fichiers versionnés sont :
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ _URL_SECRET_RE = re.compile(
|
|||||||
r"(?ix)[?&](?:api[_-]?key|access[_-]?token|auth(?:orization)?|icalsecurise|password|secret|token)"
|
r"(?ix)[?&](?:api[_-]?key|access[_-]?token|auth(?:orization)?|icalsecurise|password|secret|token)"
|
||||||
r"=([^&#\s]{3,})"
|
r"=([^&#\s]{3,})"
|
||||||
)
|
)
|
||||||
|
_URL_PLACEHOLDER_RE = re.compile(r"(?x)^(?:\{[^{}\r\n]+\}|<[^<>\r\n]+>|\.+|[•…*]+)$")
|
||||||
_EXTRA_NAMES = frozenset({"pronote_sync"})
|
_EXTRA_NAMES = frozenset({"pronote_sync"})
|
||||||
|
|
||||||
|
|
||||||
@@ -185,7 +186,8 @@ def find_secrets(
|
|||||||
)
|
)
|
||||||
if is_literal_secret:
|
if is_literal_secret:
|
||||||
findings.append(SecretFinding(relative_path, number, "affectation-litterale"))
|
findings.append(SecretFinding(relative_path, number, "affectation-litterale"))
|
||||||
if _URL_SECRET_RE.search(line):
|
url_match = _URL_SECRET_RE.search(line)
|
||||||
|
if url_match and not _URL_PLACEHOLDER_RE.fullmatch(url_match.group(1)):
|
||||||
findings.append(SecretFinding(relative_path, number, "parametre-url"))
|
findings.append(SecretFinding(relative_path, number, "parametre-url"))
|
||||||
return sorted(findings, key=lambda finding: (str(finding.path), finding.line, finding.rule))
|
return sorted(findings, key=lambda finding: (str(finding.path), finding.line, finding.rule))
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -118,6 +118,33 @@ def test_main_detects_sensitive_url_parameter(
|
|||||||
assert sentinel not in output
|
assert sentinel not in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_ignores_documentation_url_placeholders(
|
||||||
|
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""Ignore les marqueurs de remplacement utilisés dans une documentation.
|
||||||
|
|
||||||
|
:param secret_checker: Module du script sous test.
|
||||||
|
:param tmp_path: Répertoire temporaire représentant un dépôt.
|
||||||
|
:param capsys: Fixture de capture de sortie.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
(tmp_path / "guide.md").write_text(
|
||||||
|
"\n".join(
|
||||||
|
(
|
||||||
|
"https://example.invalid/?icalsecurise={jeton}",
|
||||||
|
"https://example.invalid/?icalsecurise=••••••••",
|
||||||
|
"https://example.invalid/?icalsecurise=<token>",
|
||||||
|
"https://example.invalid/?icalsecurise=...",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
+ "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert secret_checker.main([], root=tmp_path) == 0
|
||||||
|
assert "OK:" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
def test_staged_mode_inspects_only_paths_provided_by_git(
|
def test_staged_mode_inspects_only_paths_provided_by_git(
|
||||||
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
|
secret_checker: ModuleType, tmp_path: Path, capsys: CaptureFixture[str]
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -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