Le bouton "Régénérer le token" de la carte Tasker crée un nouveau token
secret et invalide immédiatement les anciennes URLs, au cas où elles
auraient fuité. La fonction regenerate_presence_token préserve les
autres réglages notif (topic, serveur, jeton d'accès, plage). Au passage
le message de retour du bouton de test ntfy occupe désormais toute la
largeur de la carte au lieu d'être limité à 420px.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
302 lines
12 KiB
Python
302 lines
12 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"
|