server-up/tests/test_notify.py
Ramon 87fb9d19d1
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 1m22s
v0.7.50-beta - meldingen, schijfruimte, backups controleren en extern wegzetten
Het onbewaakte deel was de zwakke plek: geplande taken draaiden 's nachts en
een storing kwam alleen in het auditlog terecht.

Meldingen (core/notify.py):
- ntfy, webhook (Discord/Slack/Gotify) en e-mail, alle drie met de
  standaardbibliotheek.
- Gebeurtenissen: mislukte backup, onleesbaar archief, vastgelopen taak,
  weinig schijfruimte, beschikbare update, gestopte container.
- Eén bericht per ronde; bij schijfruimte en gestopte containers alleen bij
  de overgang, zodat je niet elk uur hetzelfde krijgt.
- Testknop die eerst opslaat, zodat je test wat je net hebt ingevuld.
- send() gooit nooit: het kanaal mag de taak die de melding veroorzaakte niet
  alsnog laten omvallen.

Schijfruimte (core/diskspace.py):
- Controle vóór elke backup; past het niet, dan weigeren in plaats van
  halverwege afbreken.
- DISK_MIN_FREE_GB blijft gereserveerd, DISK_WARN_PCT kleurt de balk rood.
- Per filesystem één regel in de backuplijst.

Backups controleren:
- Elk nieuw archief wordt helemaal uitgelezen (tar-structuur plus
  gzip-checksum, die aan het eind staat).
- Diepe variant pakt echt uit naar een tijdelijke map, met dezelfde
  beperkingen als een echt herstel.
- Resultaat staat in de metadata en als schildje in de lijst.

Backups de deur uit:
- Downloadknop.
- BACKUP_OFFSITE_DIR kopieert elke nieuwe backup naar een gemounte schijf,
  NFS- of SMB-share, via .part zodat een afgebroken kopie herkenbaar onaf is.
- Ruimtecontrole op de bestemming, want een weggevallen mount laat vaak een
  lege map op de systeemschijf achter.

NOTIFY_TOKEN en NOTIFY_EMAIL_PASSWORD zijn write-only.

Nieuw: docs/meldingen.md; docs/backups.md uitgebreid. Getest tegen een
draaiende server met een echte ontvanger. 1064 tests groen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb
2026-07-28 19:41:35 +02:00

263 lines
10 KiB
Python

"""Meldingen: kanalen, gebeurtenisfilter en de belofte dat send() nooit gooit.
Het punt van dit onderdeel is dat je hoort dat er iets misging. Een melding die
zelf een uitzondering veroorzaakt zou de taak die hem afvuurde alsnog laten
omvallen — dan is de kuur erger dan de kwaal.
"""
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "server-up"))
from conftest import login
@pytest.fixture
def nt(env):
from core import notify
return notify
@pytest.fixture
def verzonden(nt, monkeypatch):
"""Vangt uitgaande HTTP-verzoeken op in plaats van ze te versturen."""
opgevangen = []
def _nep(url, data, headers):
opgevangen.append({"url": url, "data": data, "headers": headers})
return True, "HTTP 200"
monkeypatch.setattr(nt, "_post", _nep)
return opgevangen
# ── Kanaalkeuze ──────────────────────────────────────────────────────────────
def test_zonder_kanaal_gebeurt_er_niets(nt, env, verzonden):
assert nt.ingeschakeld("backup_failed") is False
assert nt.send("backup_failed", "x", "y") is False
assert verzonden == []
def test_ntfy_stuurt_titel_als_header(nt, env, verzonden):
env["core"].patch({"NOTIFY_CHANNEL": "ntfy",
"NOTIFY_URL": "https://ntfy.sh/mijn-topic"})
ok, _ = nt.verstuur("Backup mislukt", "de schijf is vol")
assert ok is True
v = verzonden[0]
assert v["url"] == "https://ntfy.sh/mijn-topic"
assert v["headers"]["Title"] == "Backup mislukt"
assert v["data"] == "de schijf is vol".encode()
def test_ntfy_zet_hoge_prioriteit(nt, env, verzonden):
env["core"].patch({"NOTIFY_CHANNEL": "ntfy", "NOTIFY_URL": "https://ntfy.sh/t"})
nt.verstuur("x", "y", prioriteit="hoog")
assert verzonden[0]["headers"]["Priority"] == "high"
def test_token_gaat_mee_als_bearer(nt, env, verzonden):
env["core"].patch({"NOTIFY_CHANNEL": "ntfy", "NOTIFY_URL": "https://ntfy.sh/t",
"NOTIFY_TOKEN": "tk_geheim"})
nt.verstuur("x", "y")
assert verzonden[0]["headers"]["Authorization"] == "Bearer tk_geheim"
def test_webhook_bedient_discord_en_slack_tegelijk(nt, env, verzonden):
"""Dezelfde instelling moet voor allebei werken zonder extra keuzemenu."""
import json
env["core"].patch({"NOTIFY_CHANNEL": "webhook",
"NOTIFY_URL": "https://voorbeeld.test/hook"})
nt.verstuur("Titel", "Tekst")
body = json.loads(verzonden[0]["data"])
assert "Titel" in body["content"] # Discord
assert "Titel" in body["text"] # Slack
assert body["message"] == "Tekst"
def test_kanaal_zonder_url_meldt_dat(nt, env, verzonden):
env["core"].patch({"NOTIFY_CHANNEL": "ntfy", "NOTIFY_URL": ""})
ok, msg = nt.verstuur("x", "y")
assert ok is False
assert "URL" in msg
assert verzonden == []
def test_email_zonder_server_meldt_dat(nt, env):
env["core"].patch({"NOTIFY_CHANNEL": "email"})
ok, msg = nt.verstuur("x", "y")
assert ok is False
assert "SMTP" in msg
def test_email_gaat_via_smtp(nt, env, monkeypatch):
verstuurd = {}
class NepSMTP:
def __init__(self, host, port, timeout=None):
verstuurd["host"], verstuurd["port"] = host, port
def __enter__(self):
return self
def __exit__(self, *a):
return False
def starttls(self, context=None):
verstuurd["tls"] = True
def login(self, u, p):
verstuurd["login"] = (u, p)
def send_message(self, m):
verstuurd["subject"] = m["Subject"]
verstuurd["to"] = m["To"]
verstuurd["body"] = m.get_content()
monkeypatch.setattr(nt.smtplib, "SMTP", NepSMTP)
env["core"].patch({"NOTIFY_CHANNEL": "email", "NOTIFY_EMAIL_HOST": "smtp.test",
"NOTIFY_EMAIL_PORT": 587, "NOTIFY_EMAIL_USER": "ik",
"NOTIFY_EMAIL_PASSWORD": "geheim",
"NOTIFY_EMAIL_TO": "ik@voorbeeld.test"})
ok, _ = nt.verstuur("Backup mislukt", "uitleg")
assert ok is True
assert verstuurd["host"] == "smtp.test"
assert verstuurd["tls"] is True
assert verstuurd["login"] == ("ik", "geheim")
assert "Backup mislukt" in verstuurd["subject"]
assert "uitleg" in verstuurd["body"]
# ── Gebeurtenisfilter ────────────────────────────────────────────────────────
def test_alleen_gekozen_gebeurtenissen(nt, env, verzonden):
env["core"].patch({"NOTIFY_CHANNEL": "ntfy", "NOTIFY_URL": "https://ntfy.sh/t",
"NOTIFY_EVENTS": ["disk_low"]})
assert nt.send("backup_failed", "x", "y") is False
assert nt.send("disk_low", "x", "y") is True
assert len(verzonden) == 1
def test_testbericht_negeert_het_filter(nt, env, verzonden):
"""De testknop moet werken ook als je nog niets hebt aangevinkt."""
env["core"].patch({"NOTIFY_CHANNEL": "ntfy", "NOTIFY_URL": "https://ntfy.sh/t",
"NOTIFY_EVENTS": []})
assert nt.verstuur("test", "test")[0] is True
def test_onbekende_gebeurtenis_wordt_genegeerd(nt, env, verzonden):
env["core"].patch({"NOTIFY_CHANNEL": "ntfy", "NOTIFY_URL": "https://ntfy.sh/t",
"NOTIFY_EVENTS": list(nt.EVENTS)})
assert nt.send("bestaat_niet", "x", "y") is False
# ── Nooit doorgeven ──────────────────────────────────────────────────────────
def test_send_gooit_nooit(nt, env, monkeypatch):
"""Een kapot meldingskanaal mag de taak niet alsnog laten omvallen."""
env["core"].patch({"NOTIFY_CHANNEL": "ntfy", "NOTIFY_URL": "https://ntfy.sh/t"})
def _ontploft(*a, **kw):
raise RuntimeError("netwerk stuk")
monkeypatch.setattr(nt, "_post", _ontploft)
assert nt.send("backup_failed", "x", "y") is False # geen exception
def test_mislukte_melding_komt_in_het_auditlog(nt, env, monkeypatch):
from core import audit
audit.init()
env["core"].patch({"NOTIFY_CHANNEL": "ntfy", "NOTIFY_URL": "https://ntfy.sh/t"})
monkeypatch.setattr(nt, "_post", lambda *a, **kw: (False, "HTTP 500"))
nt.send("backup_failed", "x", "y")
regels = audit.query()
assert any(r["src"] == "notify" and r["status"] == "fout" for r in regels), regels
# ── API ──────────────────────────────────────────────────────────────────────
def test_token_lekt_niet_via_de_api(client, env):
csrf = login(client)
client.put("/api/settings",
json={"NOTIFY_CHANNEL": "ntfy", "NOTIFY_URL": "https://ntfy.sh/t",
"NOTIFY_TOKEN": "tk_geheim",
"NOTIFY_EMAIL_PASSWORD": "smtp-geheim"},
headers={"X-CSRF-Token": csrf})
body = client.get("/api/notify").get_data(as_text=True)
assert "tk_geheim" not in body
assert "smtp-geheim" not in body
assert '"has_token":true' in body.replace(" ", "")
instellingen = client.get("/api/settings").get_data(as_text=True)
assert "tk_geheim" not in instellingen
assert "smtp-geheim" not in instellingen
assert env["core"].load()["NOTIFY_TOKEN"] == "tk_geheim"
def test_leeg_token_wist_het_bestaande_niet(client, env):
csrf = login(client)
client.put("/api/settings", json={"NOTIFY_TOKEN": "blijf-staan"},
headers={"X-CSRF-Token": csrf})
client.put("/api/settings", json={"NOTIFY_TOKEN": ""},
headers={"X-CSRF-Token": csrf})
assert env["core"].load()["NOTIFY_TOKEN"] == "blijf-staan"
@pytest.mark.parametrize("waarden,melding", [
({"NOTIFY_CHANNEL": "duiven"}, "meldingskanaal"),
({"NOTIFY_EVENTS": ["bestaat_niet"]}, "gebeurtenis"),
({"NOTIFY_EVENTS": "geen lijst"}, "gebeurtenis"),
({"NOTIFY_EMAIL_SECURITY": "rot13"}, "SMTP"),
({"NOTIFY_EMAIL_PORT": 70000}, "NOTIFY_EMAIL_PORT"),
({"DISK_WARN_PCT": 150}, "DISK_WARN_PCT"),
({"DISK_MIN_FREE_GB": "veel"}, "DISK_MIN_FREE_GB"),
({"BACKUP_OFFSITE_DIR": "relatief/pad"}, "absoluut"),
])
def test_ongeldige_instellingen_worden_geweigerd(client, waarden, melding):
csrf = login(client)
r = client.put("/api/settings", json=waarden, headers={"X-CSRF-Token": csrf})
assert r.status_code == 400, r.get_json()
assert melding in r.get_json()["msg"]
def test_geldige_instellingen_worden_bewaard(client, env):
csrf = login(client)
r = client.put("/api/settings",
json={"NOTIFY_CHANNEL": "webhook",
"NOTIFY_EVENTS": ["disk_low", "job_failed"],
"DISK_WARN_PCT": 15, "BACKUP_OFFSITE_DIR": "/mnt/backup"},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 200, r.get_json()
c = env["core"].load()
assert c["NOTIFY_CHANNEL"] == "webhook"
assert c["DISK_WARN_PCT"] == 15
assert c["BACKUP_OFFSITE_DIR"] == "/mnt/backup"
def test_testknop_meldt_dat_er_geen_kanaal_is(client, env):
csrf = login(client)
r = client.post("/api/notify/test", headers={"X-CSRF-Token": csrf})
assert r.status_code == 400
assert "kanaal" in r.get_json()["msg"]
def test_testknop_verstuurt(client, env, monkeypatch):
from core import notify
monkeypatch.setattr(notify, "_post", lambda *a, **kw: (True, "HTTP 200"))
csrf = login(client)
client.put("/api/settings",
json={"NOTIFY_CHANNEL": "ntfy", "NOTIFY_URL": "https://ntfy.sh/t"},
headers={"X-CSRF-Token": csrf})
r = client.post("/api/notify/test", headers={"X-CSRF-Token": csrf})
assert r.status_code == 200, r.get_json()
def test_meldingen_zijn_alleen_voor_beheerders(client):
csrf = login(client)
client.post("/api/auth/users",
json={"username": "op", "password": "eenlangwachtwoord",
"role": "operator"},
headers={"X-CSRF-Token": csrf})
op = client.post("/api/auth/login",
json={"username": "op", "password": "eenlangwachtwoord"}
).get_json()["csrf_token"]
r = client.post("/api/notify/test", headers={"X-CSRF-Token": op})
assert r.status_code == 403