server-up/tests/test_backup_extra.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

287 lines
10 KiB
Python

"""Backups: verifiëren, downloaden, extern wegzetten en de ruimtecontrole.
Een backup die je nooit leest is een aanname, en een backup op dezelfde schijf
als de data overleeft geen defecte schijf. Dit bestand test wat die twee
aannames wegneemt.
"""
import gzip
import sys
import tarfile
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 bk(env, monkeypatch):
from core import backups, docker
env["core"].patch({"BACKUP_DIR": str(env["tmp"] / "backups")})
monkeypatch.setattr(docker, "compose_down", lambda *a, **k: 0)
monkeypatch.setattr(docker, "compose_up", lambda *a, **k: 0)
return backups
def _maak_stack(env, naam="vaultwarden"):
d = env["lib"] / naam
d.mkdir(parents=True, exist_ok=True)
(d / "docker-compose.yml").write_text("services:\n app:\n image: nginx\n",
encoding="utf-8")
(d / ".env").write_text("PORT=8222\n", encoding="utf-8")
return d
# ── Verifiëren ───────────────────────────────────────────────────────────────
def test_nieuw_archief_is_meteen_geverifieerd(bk, env):
_maak_stack(env)
meta = bk.create("vaultwarden")
assert meta.get("verified_ok") is True
assert meta.get("verified_ts")
def test_verify_leest_het_hele_archief(bk, env):
_maak_stack(env)
meta = bk.create("vaultwarden")
ok, msg = bk.verify(meta["file"])
assert ok is True
assert "bestanden" in msg
def test_verify_ziet_een_afgekapt_archief(bk, env):
"""Een archief dat halverwege is afgebroken ziet er in een lijst compleet
uit; de gzip-checksum staat aan het eind en valt dan pas door de mand."""
_maak_stack(env)
meta = bk.create("vaultwarden")
pad = bk.backup_dir() / meta["file"]
ruw = pad.read_bytes()
pad.write_bytes(ruw[: len(ruw) // 2])
ok, msg = bk.verify(meta["file"])
assert ok is False
assert "beschadigd" in msg
assert bk.list_for("vaultwarden")[0]["verified_ok"] is False
def test_verify_ziet_omgewisselde_bytes(bk, env):
_maak_stack(env)
meta = bk.create("vaultwarden")
pad = bk.backup_dir() / meta["file"]
ruw = bytearray(pad.read_bytes())
ruw[len(ruw) // 2] ^= 0xFF
pad.write_bytes(bytes(ruw))
assert bk.verify(meta["file"])[0] is False
def test_verify_weigert_een_leeg_archief(bk, env):
_maak_stack(env)
meta = bk.create("vaultwarden")
pad = bk.backup_dir() / meta["file"]
with tarfile.open(pad, "w:gz"):
pass # geldig, maar zonder inhoud
ok, msg = bk.verify(meta["file"])
assert ok is False
assert "geen bestanden" in msg
def test_verify_van_onbekend_bestand(bk, env):
ok, msg = bk.verify("bestaatniet_20260101_000000.tar.gz")
assert ok is False
assert "niet gevonden" in msg
@pytest.mark.parametrize("naam", ["../../etc/passwd", "/etc/passwd", "geen-backup"])
def test_verify_weigert_padtrucs(bk, naam):
with pytest.raises(bk.BackupError):
bk.verify(naam)
def test_proefherstel_pakt_echt_uit_en_ruimt_op(bk, env):
"""Alleen zo weet je dat terugzetten werkt vóórdat je het nodig hebt."""
_maak_stack(env)
meta = bk.create("vaultwarden")
voor = set(p.name for p in bk.backup_dir().iterdir())
ok, msg = bk.verify(meta["file"], diep=True)
assert ok is True, msg
assert "uitgepakt" in msg
assert bk.list_for("vaultwarden")[0]["verified_deep"] is True
# Geen rommel achtergelaten.
assert set(p.name for p in bk.backup_dir().iterdir()) == voor
def test_create_faalt_zichtbaar_bij_een_kapot_archief(bk, env, monkeypatch):
"""Je wil kunnen zien wát er mis is, dus het archief blijft staan."""
_maak_stack(env)
monkeypatch.setattr(bk, "verify", lambda *a, **k: (False, "stuk"))
with pytest.raises(bk.BackupError) as exc:
bk.create("vaultwarden")
assert "niet te lezen" in str(exc.value)
# Het archief staat er nog, zodat je kunt kijken wat er mis is.
assert list(bk.backup_dir().glob("*.tar.gz"))
def test_verificatie_uitzetten_kan(bk, env):
env["core"].patch({"BACKUP_VERIFY": False})
_maak_stack(env)
meta = bk.create("vaultwarden")
assert "verified_ok" not in meta
# ── Schijfruimte ─────────────────────────────────────────────────────────────
def test_backup_wordt_geweigerd_bij_te_weinig_ruimte(bk, env, monkeypatch):
from core import diskspace
_maak_stack(env)
monkeypatch.setattr(diskspace.shutil, "disk_usage", lambda p: (1000, 999, 1))
with pytest.raises(bk.BackupError) as exc:
bk.create("vaultwarden")
assert "schijfruimte" in str(exc.value).lower()
# En er is geen half archief blijven staan.
assert not list(bk.backup_dir().glob("*.tar.gz"))
# ── Externe kopie ────────────────────────────────────────────────────────────
def test_kopieert_naar_de_externe_bestemming(bk, env):
extern = env["tmp"] / "extern"
env["core"].patch({"BACKUP_OFFSITE_DIR": str(extern)})
_maak_stack(env)
meta = bk.create("vaultwarden")
assert (extern / meta["file"]).is_file()
assert (extern / meta["file"]).stat().st_size == (bk.backup_dir() / meta["file"]).stat().st_size
# De metadata gaat mee, anders weet je bij de kopie niet waar hij bij hoort.
assert list(extern.glob("*.json"))
assert bk.list_for("vaultwarden")[0]["offsite_ok"] is True
def test_geen_half_bestand_bij_een_afgebroken_kopie(bk, env, monkeypatch):
"""Een .part blijft herkenbaar onaf staan in plaats van een archief dat er
compleet uitziet."""
import shutil as sh
extern = env["tmp"] / "extern"
env["core"].patch({"BACKUP_OFFSITE_DIR": str(extern)})
_maak_stack(env)
meta = bk.create("vaultwarden")
monkeypatch.setattr(bk.shutil, "copy2",
lambda *a, **k: (_ for _ in ()).throw(OSError("mount weg")))
ok, msg = bk.kopieer_extern(meta["file"])
assert ok is False
assert "mislukt" in msg
def test_zelfde_map_is_geen_tweede_kopie(bk, env):
env["core"].patch({"BACKUP_OFFSITE_DIR": str(bk.backup_dir())})
assert bk.offsite_dir() is None
def test_offsite_weigert_bij_te_weinig_ruimte_op_de_bestemming(bk, env, monkeypatch):
"""Valt de mount weg, dan bestaat het pad vaak nog als lege map op de
systeemschijf — en vult de kopie ongemerkt de verkeerde schijf."""
from core import diskspace
extern = env["tmp"] / "extern"
_maak_stack(env)
meta = bk.create("vaultwarden")
env["core"].patch({"BACKUP_OFFSITE_DIR": str(extern)})
monkeypatch.setattr(diskspace.shutil, "disk_usage", lambda p: (1000, 999, 1))
ok, msg = bk.kopieer_extern(meta["file"])
assert ok is False
assert "ruimte" in msg
def test_mislukte_externe_kopie_laat_de_backup_zelf_staan(bk, env, monkeypatch):
"""De lokale backup is het belangrijkste; de kopie is een extra."""
extern = env["tmp"] / "extern"
env["core"].patch({"BACKUP_OFFSITE_DIR": str(extern)})
_maak_stack(env)
monkeypatch.setattr(bk, "kopieer_extern", lambda *a, **k: (False, "mount weg"))
meta = bk.create("vaultwarden")
assert (bk.backup_dir() / meta["file"]).is_file()
# ── API ──────────────────────────────────────────────────────────────────────
def test_downloaden(client, env, monkeypatch):
from core import backups, docker
monkeypatch.setattr(docker, "compose_down", lambda *a, **k: 0)
monkeypatch.setattr(docker, "compose_up", lambda *a, **k: 0)
env["core"].patch({"BACKUP_DIR": str(env["tmp"] / "backups")})
login(client)
_maak_stack(env)
meta = backups.create("vaultwarden")
r = client.get(f"/api/backups/{meta['file']}/download")
assert r.status_code == 200
assert r.headers["Content-Disposition"].startswith("attachment")
assert meta["file"] in r.headers["Content-Disposition"]
# En het is een echt, uitpakbaar archief.
assert gzip.decompress(r.data)[:100]
@pytest.mark.parametrize("naam", ["../config.json", "..%2Fconfig.json", "onzin"])
def test_download_weigert_padtrucs(client, env, naam):
login(client)
r = client.get(f"/api/backups/{naam}/download")
assert r.status_code in (400, 404), r.data
def test_download_van_onbekend_bestand(client, env):
login(client)
r = client.get("/api/backups/weg_20260101_000000.tar.gz/download")
assert r.status_code == 404
def test_download_vereist_een_sessie(anon_client):
r = anon_client.get("/api/backups/x_20260101_000000.tar.gz/download")
assert r.status_code == 401
def test_offsite_endpoint(client, env, monkeypatch):
from core import backups, docker
monkeypatch.setattr(docker, "compose_down", lambda *a, **k: 0)
monkeypatch.setattr(docker, "compose_up", lambda *a, **k: 0)
env["core"].patch({"BACKUP_DIR": str(env["tmp"] / "backups")})
csrf = login(client)
_maak_stack(env)
meta = backups.create("vaultwarden")
extern = env["tmp"] / "extern"
client.put("/api/settings", json={"BACKUP_OFFSITE_DIR": str(extern)},
headers={"X-CSRF-Token": csrf})
r = client.post(f"/api/backups/{meta['file']}/offsite",
headers={"X-CSRF-Token": csrf})
assert r.status_code == 200, r.get_json()
assert (extern / meta["file"]).is_file()
def test_offsite_zonder_bestemming_zegt_dat(client, env, monkeypatch):
from core import backups, docker
monkeypatch.setattr(docker, "compose_down", lambda *a, **k: 0)
monkeypatch.setattr(docker, "compose_up", lambda *a, **k: 0)
env["core"].patch({"BACKUP_DIR": str(env["tmp"] / "backups")})
csrf = login(client)
_maak_stack(env)
meta = backups.create("vaultwarden")
r = client.post(f"/api/backups/{meta['file']}/offsite",
headers={"X-CSRF-Token": csrf})
assert r.status_code == 400
assert "bestemming" in r.get_json()["msg"]
def test_backuplijst_toont_de_schijfruimte(client, env):
login(client)
d = client.get("/api/backups").get_json()
assert d["disk"]["volumes"]
assert "BACKUP_OFFSITE_DIR" in d["settings"]
def test_disk_endpoint(client, env):
login(client)
d = client.get("/api/disk").get_json()
assert d["volumes"][0]["free"] > 0
assert "low" in d