Files
FabledGrimoire/tests/test_services_backup_job.py
T
bvandeusenandClaude Opus 5.5 34f8d7060b
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 1m40s
CI & Build / integration (push) Successful in 1m49s
CI & Build / Build & push image (push) Successful in 24s
feat(6847): a rollback first backs up the state it replaces
backup_job.rollback_to_backup:
1. Checks the backup can apply here (backup.check_replace, which shares
   _replace_mode with replace_from_backup), refusing before anything is written.
2. Takes the backup lock, so no scheduled backup runs during the rollback.
3. Saves the current store with save_state_before_restore.
4. Replaces the store.

The safety file goes to the backup destination when backups are on and it
takes it. Otherwise it goes to DATA_DIR/pre-restore, with a note when the
destination refused. If neither takes it the rollback does not run. The
result names where the file went.

Milestone 582, step 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:02:26 -05:00

345 lines
12 KiB
Python

"""The scheduled backup job (services/backup_job.py) and the admin surface
over it. No database: admin settings are an in-memory dict, and the export
and delivery are stand-ins, so what is under test is the job's own contract —
it records every outcome, keeps a bounded history, runs only at its hour,
and never runs twice at once.
"""
import asyncio
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
import pytest
from grimoire.services import backup_destination, backup_job
from grimoire.services.backup_destination import (
BackupConfig, Delivered, DestinationError, apply_changes,
)
NOW = datetime(2026, 10, 15, 2, 0, 0, tzinfo=timezone.utc)
class _Settings(dict):
async def get(self, key, default=""):
return self.get_value(key, default)
def get_value(self, key, default):
return dict.get(self, key, default)
async def set(self, key, value):
self[key] = value
def _patched(settings, deliver=None, config=BackupConfig(enabled=True)):
async def load_config():
return config
async def load_secret():
return (None, False)
async def export():
return {"version": 25, "users": []}
async def delivered(payload, config, secret, at, **kw):
return Delivered("grimoire-20261015T020000Z.tar", 1234, "/backups")
return [
patch.object(backup_job, "get_admin_setting", settings.get),
patch.object(backup_job, "set_admin_setting", settings.set),
patch.object(backup_job, "load_config", load_config),
patch.object(backup_job, "load_secret", load_secret),
patch.object(backup_job, "deliver", deliver or delivered),
patch("grimoire.services.backup.export_full_backup", export),
]
def _run(coro, patches):
for p in patches:
p.start()
try:
return asyncio.run(coro)
finally:
for p in reversed(patches):
p.stop()
def test_a_good_run_is_kept_with_its_file_and_size():
settings = _Settings()
run = _run(backup_job.run_backup("manual"), _patched(settings))
assert run["status"] == "ok"
assert (run["file"], run["size_bytes"], run["trigger"]) == (
"grimoire-20261015T020000Z.tar", 1234, "manual",
)
runs = _run(backup_job.list_runs(), _patched(settings))
assert runs == [run]
def test_a_failed_run_is_kept_with_its_reason_in_words():
async def refuse(*a, **kw):
raise DestinationError("/backups isn't there.")
settings = _Settings()
run = _run(backup_job.run_backup("scheduled"), _patched(settings, deliver=refuse))
assert run["status"] == "failed"
assert run["error"] == "/backups isn't there."
def test_an_unexpected_error_is_a_failed_run_not_a_crash():
async def boom(*a, **kw):
raise RuntimeError("disk on fire")
settings = _Settings()
run = _run(backup_job.run_backup("scheduled"), _patched(settings, deliver=boom))
assert run["status"] == "failed"
assert "disk on fire" in run["error"]
def test_a_run_that_hangs_is_abandoned_at_the_deadline():
async def hang(*a, **kw):
await asyncio.sleep(10)
settings = _Settings()
with patch.object(backup_job, "RUN_DEADLINE", timedelta(seconds=0.05)):
run = _run(backup_job.run_backup("scheduled"), _patched(settings, deliver=hang))
assert run["status"] == "failed"
assert "didn't finish" in run["error"]
assert not backup_job.is_running()
def test_the_history_keeps_only_the_newest_runs():
settings = _Settings()
async def many():
for _ in range(backup_job.KEEP_RUNS + 5):
await backup_job.run_backup("scheduled")
return await backup_job.list_runs()
runs = _run(many(), _patched(settings))
assert len(runs) == backup_job.KEEP_RUNS
assert runs[0]["started_at"] >= runs[-1]["started_at"]
def test_a_second_run_while_one_is_going_is_refused():
started = asyncio.Event()
release = asyncio.Event()
async def slow(*a, **kw):
started.set()
await release.wait()
return Delivered("x.tar", 1, "/backups")
async def both():
first = asyncio.create_task(backup_job.run_backup("manual"))
await started.wait()
with pytest.raises(backup_job.BackupBusy):
await backup_job.run_backup("manual")
release.set()
return await first
run = _run(both(), _patched(_Settings(), deliver=slow))
assert run["status"] == "ok"
@pytest.mark.parametrize("enabled,hour,ran", [
(True, NOW.hour, True),
(True, (NOW.hour + 1) % 24, False),
(False, NOW.hour, False),
])
def test_the_hourly_tick_runs_only_when_on_and_at_its_hour(enabled, hour, ran):
calls = []
async def fake_run(trigger):
calls.append(trigger)
class _Now(datetime):
@classmethod
def now(cls, tz=None):
return NOW
async def config():
return BackupConfig(enabled=enabled, hour=hour)
with patch.object(backup_job, "load_config", config), \
patch.object(backup_job, "run_backup", fake_run), \
patch.object(backup_job, "datetime", _Now):
asyncio.run(backup_job._hourly_check())
assert calls == (["scheduled"] if ran else [])
# ── The page's verdict ───────────────────────────────────────────────────────
def _ok(at):
return {"status": "ok", "finished_at": at.isoformat()}
def test_a_recent_success_is_not_overdue():
s = backup_job.summarise([_ok(NOW - timedelta(hours=3))], enabled=True, now=NOW)
assert s["overdue"] is False
assert s["last_success"]["status"] == "ok"
def test_failures_after_an_old_success_are_overdue():
runs = [{"status": "failed", "finished_at": NOW.isoformat()},
_ok(NOW - timedelta(hours=30))]
s = backup_job.summarise(runs, enabled=True, now=NOW)
assert s["overdue"] is True
assert s["last_run"]["status"] == "failed"
def test_off_is_never_overdue_and_nothing_run_yet_is_not_late():
assert backup_job.summarise([], enabled=False, now=NOW)["overdue"] is False
assert backup_job.summarise([], enabled=True, now=NOW)["overdue"] is False
failed_only = [{"status": "failed", "finished_at": NOW.isoformat()}]
assert backup_job.summarise(failed_only, enabled=True, now=NOW)["overdue"] is True
# ── What an admin sends ──────────────────────────────────────────────────────
def test_changes_apply_on_top_of_the_current_config():
config = apply_changes(BackupConfig(), {"enabled": True, "hour": "5", "s3_secret": "x"})
assert (config.enabled, config.hour, config.destination) == (True, 5, "folder")
@pytest.mark.parametrize("data,said", [
({"hour": 24}, "from 0 to 23"),
({"hour": "two"}, "whole number"),
({"destination": "ftp"}, "folder or an S3 bucket"),
({"enabled": "yes"}, "true or false"),
])
def test_a_typed_mistake_is_refused_with_the_reason(data, said):
with pytest.raises(ValueError, match=said):
apply_changes(BackupConfig(), data)
def test_routes_and_scheduler_are_wired():
from grimoire.app import create_app
from grimoire.routes import admin as admin_routes
for name in ("get_backups", "update_backups", "check_backups_destination", "run_backup_now"):
assert callable(getattr(admin_routes, name))
rules = {r.rule for r in create_app().url_map.iter_rules()}
assert {"/api/admin/backups", "/api/admin/backups/check", "/api/admin/backups/run"} <= rules
assert callable(backup_job.start_backup_scheduler)
assert callable(backup_job.stop_backup_scheduler)
assert backup_destination.SECRET_SETTING == "backup_s3_secret"
# ── The backup a rollback takes of what it replaces ────────────────────────
def _safety_patches(tmp_path, *, config, deliver=None):
from grimoire.config import Config
async def load_config():
return config
async def load_secret():
return (None, False)
async def export():
return {"version": 28, "scope": "full", "users": []}
async def delivered(payload, config, secret, at, **kw):
return Delivered("grimoire-20261015T020000Z.tar", 1234, "/backups")
return [
patch.object(backup_job, "load_config", load_config),
patch.object(backup_job, "load_secret", load_secret),
patch.object(backup_job, "deliver", deliver or delivered),
patch("grimoire.services.backup.export_full_backup", export),
patch.object(Config, "DATA_DIR", str(tmp_path)),
]
def test_the_state_before_a_rollback_goes_to_the_backup_destination(tmp_path):
saved = _run(backup_job.save_state_before_restore(),
_safety_patches(tmp_path, config=BackupConfig(enabled=True)))
assert saved == {"file": "grimoire-20261015T020000Z.tar", "size_bytes": 1234,
"where": "/backups"}
assert not (tmp_path / backup_job.PRE_RESTORE_DIR).exists()
def test_with_backups_off_it_is_kept_on_the_instance(tmp_path):
saved = _run(backup_job.save_state_before_restore(),
_safety_patches(tmp_path, config=BackupConfig(enabled=False)))
folder = tmp_path / backup_job.PRE_RESTORE_DIR
assert saved["where"] == str(folder)
assert (folder / saved["file"]).stat().st_size == saved["size_bytes"] > 0
assert "note" not in saved
def test_a_destination_that_refuses_it_falls_back_to_the_instance_and_says_so(tmp_path):
async def refuse(*a, **kw):
raise DestinationError("/backups isn't there.")
saved = _run(backup_job.save_state_before_restore(),
_safety_patches(tmp_path, config=BackupConfig(enabled=True), deliver=refuse))
assert saved["where"] == str(tmp_path / backup_job.PRE_RESTORE_DIR)
assert "/backups isn't there." in saved["note"]
def test_a_rollback_that_cannot_save_the_current_state_does_not_run(tmp_path):
from grimoire.services import backup
calls = []
async def check(data):
return "rollback"
async def replace(data):
calls.append("replace")
return {"mode": "rollback"}
async def fail():
raise OSError("disk full")
patches = [
patch.object(backup, "check_replace", check),
patch.object(backup, "replace_from_backup", replace),
patch.object(backup_job, "save_state_before_restore", fail),
]
with pytest.raises(OSError):
_run(backup_job.rollback_to_backup({}), patches)
assert calls == []
def test_a_refused_rollback_writes_no_safety_backup():
from grimoire.services import backup
calls = []
async def check(data):
raise backup.RestoreRefused("from another instance")
async def save():
calls.append("save")
patches = [
patch.object(backup, "check_replace", check),
patch.object(backup_job, "save_state_before_restore", save),
]
with pytest.raises(backup.RestoreRefused):
_run(backup_job.rollback_to_backup({}), patches)
assert calls == []
def test_a_rollback_reports_where_the_state_it_replaced_went():
from grimoire.services import backup
async def check(data):
return "rollback"
async def replace(data):
return {"mode": "rollback", "restored": {"notes": 3}}
async def save():
return {"file": "grimoire-x.tar", "size_bytes": 9, "where": "/data/pre-restore"}
patches = [
patch.object(backup, "check_replace", check),
patch.object(backup, "replace_from_backup", replace),
patch.object(backup_job, "save_state_before_restore", save),
]
result = _run(backup_job.rollback_to_backup({}), patches)
assert result["safety_backup"]["where"] == "/data/pre-restore"
assert result["restored"] == {"notes": 3}