Toute modification des paramètres (topic/serveur/jeton ntfy, plages
horaires, régénération du token de présence Tasker) est désormais
enregistrée dans /logs sous un nouveau type "Réglages", avec un onglet
dédié. Chaque entrée résume ce qui a changé (valeurs des plages, plage
des rappels, topic, jeton défini/vide, URLs invalidées), pour aider à
diagnostiquer a posteriori un problème de configuration.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
349 lines
14 KiB
Python
349 lines
14 KiB
Python
"""Smoke tests pour les nouvelles routes /logs et /aide + journalisation présence."""
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
# Prépare un DATA_DIR temporaire AVANT l'import de models
|
|
_TMP = Path(tempfile.mkdtemp())
|
|
import models
|
|
models.DATA_DIR = _TMP
|
|
models.CONFIG_FILE = models.DATA_DIR / "config.json"
|
|
models.CONFIG_FILE.write_text('{"heures_jour": 7.8, "allowed_email_domain": "x.fr"}')
|
|
|
|
# Recharge main dans un état propre
|
|
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)."""
|
|
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
|
|
return {"user_id": uid}
|
|
|
|
|
|
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.get(f"/presence/{token}/arrivee")
|
|
# Simule un départ
|
|
client.get(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 → échec → entrée de log "échec"
|
|
import notifications
|
|
notifications.send_ntfy(
|
|
"http://127.0.0.1:1", "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="http://127.0.0.1:1")
|
|
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="http://127.0.0.1:1")
|
|
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.get(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.get(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()
|