"""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