Files
netmover/tests/test_backup.py
T
Florian WildeandClaude Opus 5.5 47ccf90b56
CI / pruefen (3.12) (push) Waiting to run
CI / pruefen (3.13) (push) Waiting to run
feat: tägliche verschlüsselte Sicherung, Kuma-Kennzahlen, CI in Gitea
- Sicherung von Datenbank und Schlüsseln auf eine Anbindung (scrypt+Fernet),
  Seite „Sicherung“, /healthz/backup, netmover backup/restore
- metrics in /healthz und /healthz/rule/<id> für Kuma-JSON-Abfragen
- .gitea/workflows/ci.yml; jede Migration auf befüllter DB getestet
- HTTP im internen Netz als bewusste Entscheidung dokumentiert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:05:18 +02:00

308 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""0.10.0: Datensicherung – verschlüsselt auf eine Anbindung, Wiederherstellen per CLI."""
from __future__ import annotations
import io
import sqlite3
import tarfile
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from netmover import __version__, backup
from netmover.app_state import NetMover
from netmover.backup import (
BackupError,
is_due,
load_state,
make_backup,
open_archive,
prune_names,
)
from netmover.cli import cmd_backup, cmd_restore
from netmover.config import Settings
from netmover.models import Mount, MountType
from netmover.mounting.fake import FakeMountBackend
from netmover.settings_store import AppSettings, load_settings, save_settings
from tests.webutil import post
PASS = "korrekt-pferd-batterie"
NOW = datetime(2026, 10, 23, 12, 0, tzinfo=UTC)
def _nas(state: NetMover, backend: FakeMountBackend) -> tuple[int, Path]:
"""CIFS-Anbindung „Backup“ einhängen; liefert (id, Freigabe auf dem „NAS“)."""
share = backend.export("nas", "backup")
with state.session_factory() as s:
m = Mount(
name="Backup",
slug="backup",
type=MountType.CIFS,
server="nas",
share="backup",
mountpoint=str(state.settings.mount_base.resolve() / "backup"),
)
s.add(m)
s.commit()
mid = m.id
state.mounts.mount(mid)
return mid, share
def _configure(state: NetMover, mount_id: int, **kw: Any) -> AppSettings:
cfg = load_settings(state.session_factory)
cfg.backup_enabled = True
cfg.backup_mount_id = mount_id
cfg.backup_path = "netmover"
cfg.backup_passphrase_enc = state.secrets.encrypt(PASS)
for k, v in kw.items():
setattr(cfg, k, v)
save_settings(state.session_factory, cfg)
return cfg
# -- Format ----------------------------------------------------------------------------------
def test_encrypted_roundtrip_and_wrong_passphrase(state: NetMover) -> None:
blob, manifest = make_backup(state.settings, PASS)
assert backup.is_encrypted(blob)
# Weder Schlüssel noch Datenbank im Klartext
key = state.settings.secret_key_path.read_bytes().strip()
assert key not in blob and b"SQLite format 3" not in blob
archive = open_archive(blob, PASS)
assert archive.manifest["version"] == __version__
assert archive.manifest["revision"]
assert archive.files["secret.key"].strip() == key
assert archive.files["netmover.db"].startswith(b"SQLite format 3")
assert manifest["files"] == sorted(archive.files)
with pytest.raises(BackupError, match="Passphrase falsch"):
open_archive(blob, "falsche-passphrase")
with pytest.raises(BackupError, match="verschlüsselt"):
open_archive(blob, None)
def test_tampered_backup_is_rejected(state: NetMover) -> None:
blob = bytearray(make_backup(state.settings, PASS)[0])
blob[-10] ^= 0x01
with pytest.raises(BackupError):
open_archive(bytes(blob), PASS)
def test_env_file_is_included(state: NetMover, tmp_path: Path) -> None:
env = tmp_path / "netmover.env"
env.write_text("NETMOVER_PORT=8080\n")
settings = state.settings.model_copy(update={"config_file": env})
archive = open_archive(make_backup(settings, None)[0], None)
assert archive.files["netmover.env"] == b"NETMOVER_PORT=8080\n"
def test_archive_without_secret_key_is_rejected() -> None:
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
data = b'{"format": 1}'
info = tarfile.TarInfo("manifest.json")
info.size = len(data)
tar.addfile(info, io.BytesIO(data))
with pytest.raises(BackupError, match="unvollständig"):
open_archive(buf.getvalue(), None)
def test_prune_keeps_newest_and_ignores_foreign_files() -> None:
ours = [f"netmover-h-202610{d:02d}-033000.nmbak" for d in range(1, 8)]
names = [*ours, "urlaub.jpg", "netmover-notizen.txt"]
assert sorted(prune_names(names, 3)) == ours[:4]
assert prune_names(names, 10) == []
# -- Termin ----------------------------------------------------------------------------------
def _cfg(time: str = "03:30") -> AppSettings:
return AppSettings(backup_enabled=True, backup_mount_id=1, backup_time=time)
def test_due_after_slot_once_per_day_and_hourly_retry() -> None:
local = datetime(2026, 10, 23, 2, 0).astimezone() # 02:00 Ortszeit
st = backup.BackupState()
yesterday_ok = backup.BackupState(last_success=local - timedelta(hours=3))
assert is_due(_cfg(), st, local) # noch nie gesichert → nachholen
assert not is_due(_cfg(), yesterday_ok, local) # 23:00 gestern nach 03:30 gestern
later = local + timedelta(hours=2) # 04:00 → heutiger Termin ist fällig
assert is_due(_cfg(), yesterday_ok, later)
failed = backup.BackupState(
last_success=yesterday_ok.last_success, last_attempt=later - timedelta(minutes=10)
)
assert not is_due(_cfg(), failed, later) # gerade gescheitert → nicht jeden Takt
assert is_due(_cfg(), failed, later + timedelta(hours=1))
assert not is_due(AppSettings(backup_enabled=False, backup_mount_id=1), st, later)
with pytest.raises(ValueError):
backup.parse_time("25:00")
# -- Dienst ----------------------------------------------------------------------------------
def test_service_writes_encrypted_backup_to_mount_and_prunes(
state: NetMover, backend: FakeMountBackend
) -> None:
mid, share = _nas(state, backend)
_configure(state, mid, backup_keep=3)
(share / "netmover").mkdir()
(share / "netmover" / "fremd.txt").write_text("bleibt")
for i in range(5):
result = state.backups.run(NOW + timedelta(days=i))
assert result.ok, result.message
files = sorted(p.name for p in (share / "netmover").iterdir())
assert "fremd.txt" in files
backups = [f for f in files if f.endswith(".nmbak")]
assert len(backups) == 3
assert not [f for f in files if f.endswith(".part")]
newest = share / "netmover" / backups[-1]
assert open_archive(newest.read_bytes(), PASS).manifest["version"] == __version__
st = load_state(state.session_factory)
assert st.last_success == NOW + timedelta(days=4)
assert st.last_file == backups[-1] and st.last_size == newest.stat().st_size
check = state.health().find("backup")
assert check is not None and check.ok
def test_failure_is_recorded_notified_once_and_becomes_problem(
settings: Settings, backend: FakeMountBackend
) -> None:
sent: list[tuple[str, str, str]] = []
state = NetMover(settings, backend=backend)
mid, _share = _nas(state, backend)
_configure(state, mid)
state.backups._notify = lambda *a: sent.append(a)
backend.vanish(state.mounts.mountpoint_for("backup"))
backend.fail_next_mount(times=10)
for i in range(3):
result = state.backups.run(NOW + timedelta(hours=i))
assert not result.ok
st = load_state(state.session_factory)
assert st.failures == 3 and "eingehängt" in st.last_error
assert [k for k, _t, _m in sent] == ["backup.failed"]
# Überfällig erst nach STALE_AFTER – dann rot in /healthz und Kuma
state.started_at = datetime.now(UTC) - backup.STALE_AFTER * 2
check = state.health().find("backup")
assert check is not None and not check.ok and "überfällig" in check.message
def test_tick_runs_only_when_due(state: NetMover, backend: FakeMountBackend) -> None:
mid, _ = _nas(state, backend)
_configure(state, mid)
assert state.backups.tick(NOW) is not None # noch nie gesichert
assert state.backups.tick(NOW + timedelta(minutes=5)) is None
def test_missing_passphrase_refuses(state: NetMover, backend: FakeMountBackend) -> None:
mid, _ = _nas(state, backend)
_configure(state, mid, backup_passphrase_enc="")
result = state.backups.run(NOW)
assert not result.ok and "Passphrase" in result.message
# -- Wiederherstellen ------------------------------------------------------------------------
def _mount_names(db: Path) -> list[str]:
con = sqlite3.connect(db)
try:
return [r[0] for r in con.execute("select name from mounts order by name")]
finally:
con.close()
def test_cli_backup_and_restore_roundtrip(
state: NetMover, backend: FakeMountBackend, tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
settings = state.settings
mid, _ = _nas(state, backend)
_configure(state, mid)
out = tmp_path / "out"
out.mkdir()
assert cmd_backup(settings, out, encrypted=True) == 0
(file,) = out.iterdir()
assert file.suffix == ".nmbak" and file.stat().st_mode & 0o077 == 0
old_key = settings.secret_key_path.read_bytes()
state.stop()
# Nach der Sicherung geht etwas schief: Anbindung gelöscht, Schlüssel weg
con = sqlite3.connect(settings.db_path)
con.execute("delete from mounts")
con.commit()
con.close()
settings.secret_key_path.write_bytes(b"kaputt")
phrase = tmp_path / "phrase"
phrase.write_text(PASS + "\n")
assert cmd_restore(settings, file, phrase, assume_yes=True, active=lambda: True) == 1
assert "Dienst läuft" in capsys.readouterr().out
assert cmd_restore(settings, file, phrase, assume_yes=True, active=lambda: False) == 0
assert _mount_names(settings.db_path) == ["Backup"]
assert settings.secret_key_path.read_bytes() == old_key
assert settings.secret_key_path.stat().st_mode & 0o077 == 0
(kept,) = settings.data_dir.glob("vor-wiederherstellung-*")
assert (kept / "secret.key").read_bytes() == b"kaputt"
assert _mount_names(kept / "netmover.db") == []
# Gespeicherte Zugangsdaten sind mit dem zurückgeholten Schlüssel wieder lesbar
restarted = NetMover(settings, backend=backend)
cfg = load_settings(restarted.session_factory)
assert restarted.backups.passphrase(cfg) == PASS
restarted.stop()
def test_restore_refuses_newer_version(state: NetMover, monkeypatch: pytest.MonkeyPatch) -> None:
archive = open_archive(make_backup(state.settings, None)[0], None)
archive.manifest["version"] = "99.0.0"
with pytest.raises(BackupError, match="erst aktualisieren"):
backup.restore(state.settings, archive)
# -- Oberfläche ------------------------------------------------------------------------------
def test_backup_page_save_run_and_health(
client: TestClient, state: NetMover, backend: FakeMountBackend
) -> None:
mid, share = _nas(state, backend)
page = client.get("/backup")
assert page.status_code == 200 and 'href="/backup"' in page.text
form = {
"backup_enabled": "on",
"backup_mount_id": str(mid),
"backup_path": "netmover",
"backup_time": "03:30",
"backup_keep": "14",
}
r = post(client, "/backup", form)
assert r.status_code == 400 and "Passphrase festlegen" in r.text
r = post(client, "/backup", {**form, "passphrase": PASS, "passphrase_confirm": "anders"})
assert r.status_code == 400 and "stimmen nicht" in r.text
r = post(client, "/backup", {**form, "backup_path": "../raus", "passphrase": PASS})
assert r.status_code == 400
r = post(client, "/backup", {**form, "passphrase": PASS, "passphrase_confirm": PASS})
assert r.status_code == 303
with state.session_factory() as s:
from netmover.models import Setting
raw = " ".join(v for (v,) in s.execute(select(Setting.value)))
assert PASS not in raw # Passphrase nur verschlüsselt gespeichert
# Passphrase leer lassen = unverändert
assert post(client, "/backup", {**form, "backup_keep": "7"}).status_code == 303
assert state.backups.passphrase(load_settings(state.session_factory)) == PASS
assert post(client, "/backup/run").status_code == 303
assert "geschrieben" in client.get("/backup").text
assert len(list((share / "netmover").glob("*.nmbak"))) == 1
r = client.get("/healthz/backup")
assert r.status_code == 200 and r.json()["status"] == "ok"
assert "/healthz/backup" in client.get("/settings").text
assert any(c["kind"] == "backup" for c in client.get("/healthz").json()["checks"])