Files
pointeuse-optimisator/app/tests/test_logs.py
Jacquin Antoine c1a2f38044 Convertit les endpoints de présence en POST (anti CSRF trivial)
GET /presence/{token}/arrivee et /depart modifiaient l'état (save_presence,
append_log) : c'est anti-pattern REST, CSRF-trivial via <img src="..."> si
le token fuite, et le token se retrouvait dans les logs reverse-proxy,
l'historique navigateur et les referrers.

Les deux routes passent en POST. C'est un breaking change pour les clients
Tasker actuels, mais Tasker supporte HTTP POST nativement et la doc
docs/notifications.md est mise à jour (Tasker : HTTP Request méthode POST ;
script NetworkManager : curl -X POST).

Les tests existants qui utilisaient client.get(...) sur ces routes passent
en client.post(...). Un nouveau test vérifie que le GET renvoie désormais
405 Method Not Allowed et que le POST continue de fonctionner.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
2026-07-20 00:32:50 +02:00

343 lines
14 KiB
Python

"""Smoke tests pour les nouvelles routes /logs et /aide + journalisation présence."""
import sys
# DATA_DIR temporaire est positionné par conftest.py avant l'import de models.
import models # noqa: F401 (réexport pratique pour les tests ci-dessous)
sys.path.insert(0, ".")
import main
from fastapi.testclient import TestClient
client = TestClient(main.app)
def _login_as(uid: str):
"""Crée un user avec mdp puis se connecte (renvoie les cookies signés)."""
import bcrypt
models.save_auth(uid, email=f"{uid}@x.fr",
password_hash=bcrypt.hashpw(b"x" * 12, bcrypt.gensalt()).decode())
r = client.post("/login/password", data={"email": f"{uid}@x.fr", "password": "x" * 12},
follow_redirects=False)
assert r.status_code == 302, r.text
# Récupère le cookie user_id réellement signé par le serveur
return {"user_id": r.cookies.get("user_id")}
COOKIES = _login_as("alice")
# ── Route /logs ──────────────────────────────────────────────────────────────
def test_logs_page_requires_auth():
# Client frais, sans cookie, doit être redirigé vers /login
unauth = TestClient(main.app)
r = unauth.get("/logs", follow_redirects=False)
assert r.status_code == 302
assert "/login" in r.headers["location"]
def test_logs_page_empty_for_new_user():
r = client.get("/logs", cookies=COOKIES)
assert r.status_code == 200
assert "Journaux" in r.text
assert "Aucun événement" in r.text # état initial
def test_logs_page_shows_entries_after_presence():
token = models.load_notif_config("alice")["token"]
# Simule une arrivée Tasker
client.post(f"/presence/{token}/arrivee")
# Simule un départ
client.post(f"/presence/{token}/depart")
r = client.get("/logs", cookies=COOKIES)
assert r.status_code == 200
assert "arrivée" in r.text
assert "départ" in r.text
assert "Présence" in r.text
assert "Aucun événement" not in r.text
def test_logs_filter_query_param():
# Toutes les entrées sont de type "presence" à ce stade
r_all = client.get("/logs", cookies=COOKIES)
r_notif = client.get("/logs?filter=notif", cookies=COOKIES)
r_pres = client.get("/logs?filter=presence", cookies=COOKIES)
# Le filtre "notif" ne doit PAS montrer les entrées presence
assert "arrivée" not in r_notif.text
# Le filtre "presence" DOIT les montrer
assert "arrivée" in r_pres.text
assert "départ" in r_pres.text
# Le total Tout inclut tout
assert "arrivée" in r_all.text
# ── Route /aide ──────────────────────────────────────────────────────────────
def test_aide_page_requires_auth():
unauth = TestClient(main.app)
r = unauth.get("/aide", follow_redirects=False)
assert r.status_code == 302
assert "/login" in r.headers["location"]
def test_aide_page_content():
r = client.get("/aide", cookies=COOKIES)
assert r.status_code == 200
# Vérifie la présence des sections clés
for needle in ["ntfy", "Tasker", "Aide", "topic", "géofence", "/logs"]:
assert needle in r.text, f"missing {needle!r}"
# ── Logging via notifications.send_ntfy (échec) ─────────────────────────────
def test_send_ntfy_failure_logs_entry():
# Serveur injoignable (host public sans résolution) → échec → entrée de log "échec"
import notifications
notifications.send_ntfy(
"https://ntfy-injoignable.invalid", "topic-injoignable",
"msg test", "titre test", user_id="alice",
)
logs = models.load_logs("alice")
assert any(l["event"] == "échec" and l["type"] == "notif" for l in logs)
# ── Navigation ──────────────────────────────────────────────────────────────
def test_nav_links_present():
r = client.get("/aide", cookies=COOKIES)
assert 'href="/logs"' in r.text
assert 'href="/aide"' in r.text
assert 'href="/settings"' in r.text
# ── Isolation par user ──────────────────────────────────────────────────────
def test_logs_isolated_per_user():
# Bob ne doit pas voir les logs d'alice
bob_cookies = _login_as("bob")
r = client.get("/logs", cookies=bob_cookies)
assert "arrivée" not in r.text
assert "Aucun événement" in r.text
# ── Rotation des logs ───────────────────────────────────────────────────────
def test_log_rotation_caps_at_max():
# Alice a déjà quelques entrées ; on en ajoute largement > MAX_LOGS
for i in range(models.MAX_LOGS + 50):
models.append_log("alice", "notif", "envoyée", f"msg {i}")
logs = models.load_logs("alice")
assert len(logs) == models.MAX_LOGS
# Le plus récent est bien le dernier inséré
assert logs[0]["message"] == f"msg {models.MAX_LOGS + 49}"
def test_invalid_log_type_rejected():
try:
models.append_log("alice", "invalid_type", "x", "y")
assert False, "doit lever une AssertionError"
except AssertionError:
pass
# ── Bouton de test ntfy (/settings/test-ntfy) ────────────────────────────────
def test_test_ntfy_requires_auth():
unauth = TestClient(main.app)
r = unauth.post("/settings/test-ntfy", follow_redirects=False)
assert r.status_code == 302
assert "/login" in r.headers["location"]
def test_test_ntfy_without_topic_returns_error():
# Alice n'a pas de topic configuré → message d'erreur
models.save_notif_config("alice", ntfy_topic="", ntfy_server="https://ntfy.sh")
r = client.post("/settings/test-ntfy", cookies=COOKIES)
assert r.status_code == 200
assert "settings-error" in r.text
assert "topic" in r.text.lower()
def test_test_ntfy_with_bad_server_returns_failure():
# Topic renseigné mais serveur injoignable → échec
models.save_notif_config("alice", ntfy_topic="topic-test", ntfy_server="https://ntfy-injoignable.invalid")
r = client.post("/settings/test-ntfy", cookies=COOKIES)
assert r.status_code == 200
assert "settings-error" in r.text
assert "Échec" in r.text
# L'entrée de log d'échec doit être présente
logs = models.load_logs("alice")
assert any(l["event"] == "échec" and l["type"] == "notif" for l in logs)
def test_test_ntfy_logs_to_journal():
"""Le bouton de test doit écrire dans le journal (même en échec)."""
models.save_notif_config("alice", ntfy_topic="topic-test-2", ntfy_server="https://ntfy-injoignable.invalid")
client.post("/settings/test-ntfy", cookies=COOKIES)
logs = models.load_logs("alice")
# L'entrée la plus récente doit être l'échec du test (message contient "Test pointeuse")
assert any(l["event"] == "échec" and "Test pointeuse" in l["message"] for l in logs)
# ── Jeton d'accès ntfy (serveur protégé) ─────────────────────────────────────
def test_send_ntfy_passes_authorization_header(monkeypatch):
"""Quand un token est fourni, send_ntfy doit envoyer un header Authorization: Bearer."""
import notifications
captured = {}
class _DummyReq:
def __init__(self, url, data, headers, method):
captured["url"] = url
captured["headers"] = headers
captured["method"] = method
class _DummyResp:
def close(self): pass
def _fake_urlopen(req, timeout):
captured["timeout"] = timeout
return _DummyResp()
monkeypatch.setattr(notifications.urllib.request, "Request", _DummyReq)
monkeypatch.setattr(notifications.urllib.request, "urlopen", _fake_urlopen)
ok = notifications.send_ntfy(
"https://ntfy.example", "topic-x",
"msg", "titre", user_id="alice", token="tk_abc123",
)
assert ok is True
assert captured["headers"]["Authorization"] == "Bearer tk_abc123"
def test_send_ntfy_omits_authorization_when_no_token(monkeypatch):
"""Sans token, pas de header Authorization."""
import notifications
captured = {}
class _DummyReq:
def __init__(self, url, data, headers, method):
captured["headers"] = headers
class _DummyResp:
def close(self): pass
monkeypatch.setattr(notifications.urllib.request, "Request", _DummyReq)
monkeypatch.setattr(notifications.urllib.request, "urlopen", lambda req, timeout: _DummyResp())
notifications.send_ntfy("https://x", "t", "m", "ti", user_id=None, token="")
assert "Authorization" not in captured["headers"]
def test_settings_save_persists_ntfy_token():
"""Le token saisi dans /settings doit être persisté."""
models.save_notif_config("alice", ntfy_topic="t", ntfy_server="https://ntfy.sh")
client.post("/settings", cookies=COOKIES, data={
"ntfy_topic": "topic-persisted",
"ntfy_server": "https://ntfy.arkel.fr",
"ntfy_token": "tk_persisted_123",
"rappel_debut_h": "8",
"rappel_fin_h": "19",
})
cfg = models.load_notif_config("alice")
assert cfg["ntfy_token"] == "tk_persisted_123"
assert cfg["ntfy_topic"] == "topic-persisted"
# ── Régénération du token de présence (Tasker) ───────────────────────────────
def test_regenerate_presence_token_requires_auth():
unauth = TestClient(main.app)
r = unauth.post("/settings/regenerate-presence-token", follow_redirects=False)
assert r.status_code == 302
assert "/login" in r.headers["location"]
def test_regenerate_presence_token_invalidates_old_urls():
"""Le nouveau token remplace l'ancien : les anciennes URLs ne marchent plus."""
old_token = models.load_notif_config("alice")["token"]
r = client.post("/settings/regenerate-presence-token", cookies=COOKIES)
assert r.status_code == 200
# Le HTML retourné contient de nouvelles URLs avec un nouveau token
assert old_token not in r.text
assert "/presence/" in r.text
assert "Token régénéré" in r.text
# L'ancienne URL doit maintenant renvoyer 404 (token inconnu)
r_old = client.post(f"/presence/{old_token}/arrivee")
assert r_old.status_code == 404
# La nouvelle URL doit fonctionner
new_cfg = models.load_notif_config("alice")
r_new = client.post(f"/presence/{new_cfg['token']}/arrivee")
assert r_new.status_code == 200
assert r_new.json() == {"ok": True}
def test_regenerate_preserves_other_notif_fields():
"""La régénération ne doit pas effacer topic/server/token ntfy."""
models.save_notif_config(
"alice", ntfy_topic="topic-x", ntfy_server="https://ntfy.arkel.fr",
ntfy_token="tk_xyz",
)
before = models.load_notif_config("alice")
new_token = models.regenerate_presence_token("alice")
after = models.load_notif_config("alice")
assert after["token"] == new_token
assert before["token"] != new_token
# Les autres champs sont préservés
assert after["ntfy_topic"] == "topic-x"
assert after["ntfy_server"] == "https://ntfy.arkel.fr"
assert after["ntfy_token"] == "tk_xyz"
# ── Changements de paramètres tracés dans le journal ─────────────────────────
def test_settings_save_logged():
"""Sauvegarder les réglages ntfy doit écrire une entrée de journal."""
models.save_notif_config("alice", ntfy_topic="t", ntfy_server="https://ntfy.sh")
before = len([l for l in models.load_logs("alice") if l["type"] == "settings"])
client.post("/settings", cookies=COOKIES, data={
"ntfy_topic": "topic-new",
"ntfy_server": "https://ntfy.arkel.fr",
"ntfy_token": "tk_abc",
"rappel_debut_h": "8",
"rappel_fin_h": "19",
})
after = [l for l in models.load_logs("alice") if l["type"] == "settings"]
assert len(after) == before + 1
latest = after[0]
assert latest["event"] == "Réglages ntfy"
assert "topic-new" in latest["message"]
assert "ntfy.arkel.fr" in latest["message"]
def test_settings_plages_save_logged():
"""Sauvegarder les plages horaires doit écrire une entrée de journal."""
before = len([l for l in models.load_logs("alice") if l["type"] == "settings"])
client.post("/settings/plages", cookies=COOKIES, data={
"matin_debut": "09:00", "matin_fin": "12:00",
"aprem_debut": "14:00", "aprem_fin": "17:00",
"pause_dejeuner_fin": "13:30",
"arrivee_visee": "", "depart_vise": "",
})
after = [l for l in models.load_logs("alice") if l["type"] == "settings"]
assert len(after) == before + 1
latest = after[0]
assert latest["event"] == "Plages horaires"
assert "09:00" in latest["message"]
def test_regenerate_presence_token_logged():
"""Régénérer le token de présence doit écrire une entrée de journal."""
before = len([l for l in models.load_logs("alice") if l["type"] == "settings"])
client.post("/settings/regenerate-presence-token", cookies=COOKIES)
after = [l for l in models.load_logs("alice") if l["type"] == "settings"]
assert len(after) == before + 1
latest = after[0]
assert "régénéré" in latest["event"].lower()