All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 1m22s
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
263 lines
10 KiB
Python
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
|