"""Backups: maken, tonen, terugzetten, retentie — en de veiligheid daarvan.""" import io import json import tarfile import time from pathlib import Path import pytest from conftest import login @pytest.fixture def bk(env, monkeypatch): from core import backups, docker env["core"].patch({"BACKUP_DIR": str(env["tmp"] / "backups")}) # Geen echte docker in de tests: stack stoppen/starten is een no-op. 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", inhoud="services:\n app:\n image: nginx\n"): d = env["lib"] / naam d.mkdir(parents=True, exist_ok=True) (d / "docker-compose.yml").write_text(inhoud, encoding="utf-8") (d / ".env").write_text("PORT=8222\n", encoding="utf-8") return d def test_maken_en_tonen(bk, env): _maak_stack(env) meta = bk.create("vaultwarden") assert meta["stack"] == "vaultwarden" assert meta["size"] > 0 lijst = bk.list_for("vaultwarden") assert len(lijst) == 1 assert lijst[0]["file"] == meta["file"] assert lijst[0]["size_human"] def test_maken_van_onbekende_stack(bk): with pytest.raises(bk.BackupError): bk.create("bestaatniet") def test_terugzetten_herstelt_gewijzigd_bestand(bk, env): d = _maak_stack(env) meta = bk.create("vaultwarden") (d / "docker-compose.yml").write_text("KAPOT", encoding="utf-8") ok, msg = bk.restore("vaultwarden", meta["file"]) assert ok, msg assert "image: nginx" in (d / "docker-compose.yml").read_text() assert (d / ".env").read_text() == "PORT=8222\n" def test_terugzetten_van_backup_van_andere_stack(bk, env): _maak_stack(env, "app-a") _maak_stack(env, "app-b") meta = bk.create("app-a") ok, msg = bk.restore("app-b", meta["file"]) assert ok is False assert "hoort bij 'app-a'" in msg @pytest.mark.parametrize("naam", [ "../../etc/passwd.tar.gz", "/etc/shadow.tar.gz", "geen-tijdstempel.tar.gz", "vaultwarden_20260101_120000.txt", "", "..tar.gz", ]) def test_ongeldige_backupnaam(bk, naam): with pytest.raises(bk.BackupError): bk._archive_path(naam) def test_terugzetten_weigert_archief_dat_buiten_de_map_schrijft(bk, env): """Een geprepareerd archief met '..' mag niets buiten de stackmap raken. Met een kaal `tar xzf` zou dit bestand op de host belanden; tarfile met filter='data' weigert de ingang. """ _maak_stack(env) bd = Path(bk.backup_dir()) bd.mkdir(parents=True, exist_ok=True) boos = bd / "vaultwarden_20260101_120000.tar.gz" with tarfile.open(boos, "w:gz") as tar: data = b"ontsnapt" info = tarfile.TarInfo("../../ontsnapt.txt") info.size = len(data) tar.addfile(info, io.BytesIO(data)) ok, msg = bk.restore("vaultwarden", boos.name) assert ok is False assert not (env["tmp"] / "ontsnapt.txt").exists() assert not (env["lib"].parent / "ontsnapt.txt").exists() def test_mislukte_restore_zet_de_oude_map_terug(bk, env): """Een kapot archief mag je niet zonder stack achterlaten.""" d = _maak_stack(env) bd = Path(bk.backup_dir()) bd.mkdir(parents=True, exist_ok=True) stuk = bd / "vaultwarden_20260101_120000.tar.gz" stuk.write_bytes(b"dit is geen gzip") ok, _ = bk.restore("vaultwarden", stuk.name) assert ok is False assert d.is_dir() assert "image: nginx" in (d / "docker-compose.yml").read_text() def test_verwijderen(bk, env): _maak_stack(env) meta = bk.create("vaultwarden") ok, _ = bk.delete(meta["file"]) assert ok assert bk.list_for("vaultwarden") == [] def test_retentie_houdt_er_hoogstens_keep(bk, env): _maak_stack(env) env["core"].patch({"BACKUP_KEEP": 3}) metas = [] for i in range(5): m = bk.create("vaultwarden") # Tijdstempels uit elkaar trekken; de naam heeft secondeprecisie. m["ts"] = time.time() - (5 - i) _herschrijf_ts(bk, m) metas.append(m) bk.prune("vaultwarden") assert len(bk.list_for("vaultwarden")) == 3 def test_retentie_bewaart_altijd_de_nieuwste(bk, env): """Ook als alles te oud is, blijft er één over.""" _maak_stack(env) env["core"].patch({"BACKUP_KEEP": 0, "BACKUP_MAX_AGE_DAYS": 1}) for i in range(3): m = bk.create("vaultwarden") m["ts"] = time.time() - 30 * 86400 _herschrijf_ts(bk, m) bk.prune("vaultwarden") assert len(bk.list_for("vaultwarden")) == 1 def test_retentie_uit_doet_niets(bk, env): _maak_stack(env) env["core"].patch({"BACKUP_KEEP": 0, "BACKUP_MAX_AGE_DAYS": 0}) for _ in range(3): bk.create("vaultwarden") assert bk.prune("vaultwarden") == [] def _herschrijf_ts(bk, meta): """Zet de tijdstempel in het metadata-bestand terug in de tijd.""" p = Path(bk.backup_dir()) / meta["file"] mf = p.with_suffix("").with_suffix(".json") d = json.loads(mf.read_text()) d["ts"] = meta["ts"] mf.write_text(json.dumps(d)) # ── API ────────────────────────────────────────────────────────────────────── def test_endpoint_lijst(client, env, bk): login(client) _maak_stack(env) bk.create("vaultwarden") d = client.get("/api/backups").get_json() assert len(d["backups"]) == 1 assert "BACKUP_KEEP" in d["settings"] def test_endpoint_restore_weigert_ongeldige_naam(client, bk): csrf = login(client) r = client.post("/api/backups/vaultwarden/restore", json={"file": "../../etc/passwd.tar.gz"}, headers={"X-CSRF-Token": csrf}) assert r.status_code == 400 def test_endpoint_restore_zonder_bestand(client, bk): csrf = login(client) r = client.post("/api/backups/vaultwarden/restore", json={}, headers={"X-CSRF-Token": csrf}) assert r.status_code == 400 # ── De appdata en de databases ─────────────────────────────────────────────── # Een backup bevatte alleen de stackmap. LIBRARY_DIR en de appdata zijn buren, # geen genestelde mappen, dus je zette een backup terug en hield een lege app # over — met een groen vinkje erbij. def _stack_met_appdata(env, naam="umami", inhoud=b"gegevens"): """Een geïnstalleerde stack met een appdata-map ernaast.""" import json as _json d = env["lib"] / naam d.mkdir(parents=True, exist_ok=True) (d / "docker-compose.yml").write_text( "services:\n" f" {naam}:\n" " image: nginx\n" f" {naam}-db:\n" " image: postgres:16-alpine\n" f" container_name: {naam}-db\n" " environment:\n" " - POSTGRES_USER=umami\n" " - POSTGRES_DB=umami\n" " - POSTGRES_PASSWORD=${DB_PASSWORD}\n") (d / ".env").write_text("DB_PASSWORD=geheim123\n") appdata = Path(str(env["tmp"])) / "appdata" (appdata / naam).mkdir(parents=True, exist_ok=True) (appdata / f"{naam}-db").mkdir(parents=True, exist_ok=True) (appdata / naam / "bestand.txt").write_bytes(inhoud) (d / ".serverup.json").write_text(_json.dumps({ "source": naam, "values": {"appdata_dir": str(appdata), "service_name": naam}, })) return d, appdata def test_appdata_gaat_mee_in_het_archief(env, monkeypatch): import tarfile from core import backups, docker monkeypatch.setattr(docker, "_run", lambda *a, **k: _mislukt()) _stack_met_appdata(env) meta = backups.create("umami") with tarfile.open(backups.backup_dir() / meta["file"]) as tar: namen = tar.getnames() assert "umami/docker-compose.yml" in namen assert "appdata/umami/bestand.txt" in namen assert "appdata/umami-db" in namen assert meta["appdata"] == ["umami", "umami-db"] def test_appdata_kan_uit(env, monkeypatch): """Bij een mediabibliotheek loopt dit in de honderden gigabytes.""" import tarfile from core import backups, docker monkeypatch.setattr(docker, "_run", lambda *a, **k: _mislukt()) _stack_met_appdata(env) meta = backups.create("umami", appdata=False) with tarfile.open(backups.backup_dir() / meta["file"]) as tar: namen = tar.getnames() assert not [n for n in namen if n.startswith("appdata/")] assert meta["appdata"] == [] def test_terugzetten_herstelt_de_appdata(env, monkeypatch): from core import backups, docker monkeypatch.setattr(docker, "_run", lambda *a, **k: _mislukt()) monkeypatch.setattr(docker, "compose_up", lambda *a, **k: 0) monkeypatch.setattr(docker, "compose_down", lambda *a, **k: 0) _d, appdata = _stack_met_appdata(env) meta = backups.create("umami") (appdata / "umami" / "bestand.txt").write_bytes(b"kapot") ok, msg = backups.restore("umami", meta["file"]) assert ok, msg assert (appdata / "umami" / "bestand.txt").read_bytes() == b"gegevens" def test_databasedienst_wordt_herkend_met_wachtwoord_uit_env(env, monkeypatch): """Het wachtwoord staat als ${DB_PASSWORD} in compose en met de waarde in .env — zonder die stap draait pg_dump met een lege sleutel.""" from core import backups, docker monkeypatch.setattr(docker, "_run", lambda *a, **k: _mislukt()) _stack_met_appdata(env) diensten = backups.databasediensten("umami") assert len(diensten) == 1 d = diensten[0] assert d["container"] == "umami-db" assert d["soort"] == "postgres" assert d["gebruiker"] == "umami" assert d["wachtwoord"] == "geheim123" def test_dump_die_mislukt_wordt_gemeld_en_niet_stil_overgeslagen(env, monkeypatch): """Een archief dat compleet lijkt terwijl de database ontbreekt is het ergste dat een backup kan doen.""" from core import backups, docker monkeypatch.setattr(docker, "_run", lambda *a, **k: _mislukt(b"container niet gestart")) _stack_met_appdata(env) regels = [] meta = backups.create("umami", log_fn=regels.append) assert meta["dumps"] == [] assert any("Geen dump van umami-db" in r for r in regels), regels def test_dump_belandt_in_het_archief(env, monkeypatch): import tarfile from core import backups, docker monkeypatch.setattr(docker, "_run", lambda *a, **k: _gelukt(b"-- pg_dump\nCREATE TABLE x;\n")) _stack_met_appdata(env) meta = backups.create("umami") with tarfile.open(backups.backup_dir() / meta["file"]) as tar: namen = tar.getnames() inhoud = tar.extractfile("dumps/umami-db.sql").read() assert "dumps/umami-db.sql" in namen assert b"CREATE TABLE x;" in inhoud assert meta["dumps"] == ["umami-db.sql"] def _mislukt(stderr=b"fout"): import subprocess return subprocess.CompletedProcess([], 1, b"", stderr) def _gelukt(stdout=b""): import subprocess return subprocess.CompletedProcess([], 0, stdout, b"") # ── Rechten: wie is de eigenaar van wat er teruggezet wordt? ───────────────── # Server Up draait vanaf v0.10 niet meer als root. Twee gevolgen die eerder # geen rol speelden: appdata van een app met een eigen uid kan buiten bereik # liggen, en `tarfile` met filter="data" geeft alles aan wie uitpakt. def test_een_onleesbaar_bestand_laat_de_rest_van_de_backup_staan(env, monkeypatch): """Eén bestand waar we niet bij kunnen mocht niet de héle backup slopen — juist dan heb je hem nodig.""" from core import backups, docker monkeypatch.setattr(docker, "_run", lambda *a, **k: _mislukt()) _d, appdata = _stack_met_appdata(env) (appdata / "umami" / "tweede.txt").write_bytes(b"deze wel") # Rechten intrekken werkt niet als toets: root leest ook een bestand met # modus 000, en de CI draait als root. De weigering komt daarom van het # inpakken zelf — dat is precies waar hij vandaan zou komen. echt = backups.tarfile.TarFile.add def weiger_een(self, name, *a, **kw): if str(name).endswith("bestand.txt"): raise PermissionError("Permission denied") return echt(self, name, *a, **kw) monkeypatch.setattr(backups.tarfile.TarFile, "add", weiger_een) meta = backups.create("umami") assert meta["skipped"] == ["bestand.txt"], meta with backups.tarfile.open(backups.backup_dir() / meta["file"]) as tar: namen = tar.getnames() assert "appdata/umami/tweede.txt" in namen, "de rest is ook weggevallen" assert "appdata/umami/bestand.txt" not in namen def test_wat_ontbreekt_staat_in_de_metadata(env, monkeypatch): """Een archief dat compleet lijkt maar het niet is, is erger dan een archief dat zegt wat het mist.""" from core import backups, docker monkeypatch.setattr(docker, "_run", lambda *a, **k: _mislukt()) _stack_met_appdata(env) meta = backups.create("umami") assert meta["skipped"] == [], "een geslaagde backup meldt niets ontbrekends" assert "skipped" in backups.list_for("umami")[0] def test_teruggezette_appdata_krijgt_de_puid_van_de_stack(env, monkeypatch): """filter="data" laat uid/gid vallen, dus zonder chown wordt alles van wie uitpakt — en start de app niet meer.""" import json as _json from core import backups, docker monkeypatch.setattr(docker, "_run", lambda *a, **k: _mislukt()) monkeypatch.setattr(docker, "compose_up", lambda *a, **k: 0) monkeypatch.setattr(docker, "compose_down", lambda *a, **k: 0) d, appdata = _stack_met_appdata(env) meta_pad = d / ".serverup.json" inhoud = _json.loads(meta_pad.read_text()) inhoud["values"].update({"puid": "1005", "pgid": "1006"}) meta_pad.write_text(_json.dumps(inhoud)) meta = backups.create("umami") gevraagd = [] # `backups.os` ís de os-module, dus dit vangt ook wat tarfile zelf chownt — # als root doet die dat namelijk wél. Daarom toetsen we op het exacte # doelpad in plaats van op "alles wat langskwam". monkeypatch.setattr(backups.os, "chown", lambda p, u, g, **kw: gevraagd.append((str(p), u, g))) ok, msg = backups.restore("umami", meta["file"]) assert ok, msg doel = str(appdata / "umami") assert (doel, 1005, 1006) in gevraagd, \ f"de teruggezette appdata kreeg niet de PUID van de stack: {gevraagd}" def test_zonder_puid_valt_hij_terug_op_ons_eigen_account(env): from core import backups import os as _os assert backups._verwachte_eigenaar({}) == (_os.getuid(), _os.getgid()) assert backups._verwachte_eigenaar({"puid": "1005", "pgid": "1006"}) == (1005, 1006) # Een sjabloon met onzin erin mag geen TypeError geven. assert backups._verwachte_eigenaar({"puid": "abc"}) == (_os.getuid(), _os.getgid()) def test_chown_die_niet_mag_stopt_het_terugzetten_niet(env, monkeypatch, tmp_path): """Draai je zonder de rechten om te chownen, dan is een waarschuwing beter dan een mislukt herstel.""" from core import backups monkeypatch.setattr(backups.os, "chown", lambda *a, **k: (_ for _ in ()).throw(PermissionError("mag niet"))) map_ = tmp_path / "appdata-umami" map_.mkdir() (map_ / "a.txt").write_text("x") regels = [] assert backups._zet_eigenaar(map_, 1005, 1006, log_fn=regels.append) is False assert any("eigenaar" in r for r in regels), regels