server-up/tests/test_backups.py
Ramon 3bee664484
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 17m1s
Versienummering terug in de 0.7-reeks, stappen van 0.01
Ik sprong met tientallen (0.7.90 → 0.8.00 → … → 0.9.00 → 0.10.00) en liep
daarmee de reeks uit: dit schema rolt over bij .90, dus na 0.9.90 hoort 1.0.00
te komen. "0.10.00" bestond niet, en schond ook het formaat v0.0.00 uit de
projectafspraken.

De vijftien uitgaven van deze sessie zijn hernummerd naar 0.7.81 t/m 0.7.95,
aaneengesloten. In één doorloop met een tabel, want 0.8.81 wordt 0.7.90 en
0.7.90 wordt 0.7.81 — achtereenvolgende vervangingen zouden elkaar overschrijven.

Meegenomen: verwijzingen in de documentatie, .env.example, install.sh en de
tests. Verkorte vormen als "vanaf v0.10" zijn vervangen door het volledige
nummer, want die waren niet automatisch te herleiden.

De commit-onderwerpen in de geschiedenis dragen nog de oude nummers; VERSION en
CHANGELOG zijn leidend.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9eqpADJSRs49SoGGr4NAy
2026-08-03 07:51:05 +02:00

414 lines
15 KiB
Python

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