from __future__ import annotations import importlib import os import stat from pathlib import Path import httpx import pytest from backup_tool.config import Settings from backup_tool.db.engine import create_engine from sqlalchemy import select from sqlalchemy.ext.asyncio import async_sessionmaker config = importlib.import_module("backup_tool.config") adapters = importlib.import_module("backup_tool.adapters") faults = importlib.import_module("backup_tool.faults") models = importlib.import_module("backup_tool.db.models") snapshot = importlib.import_module("backup_tool.snapshot") worker_module = importlib.import_module("backup_tool.worker") PASSWORD = "correct-horse-battery-staple" def settings_for(tmp_path: Path): key = tmp_path / "master.key" key.write_bytes(b"m6-publication-fault-test-master-key-material") key.chmod(0o600) data = tmp_path / "data" repositories = tmp_path / "repositories" sources = tmp_path / "sources" restores = tmp_path / "restores" for directory in (data, repositories, sources, restores): directory.mkdir() return config.Settings( data_dir=data, database_url=f"sqlite+aiosqlite:///{data / 'metadata.db'}", repository_roots=(repositories,), local_source_roots=(sources,), restore_roots=(restores,), master_key_file=key, min_free_bytes=1, ) @pytest.mark.asyncio @pytest.mark.parametrize("point", ["blob.before_write", "blob.after_write", "blob.after_fsync"]) async def test_blob_write_crash_points_leave_no_published_blob(tmp_path: Path, point: str) -> None: settings = settings_for(tmp_path) source_root = settings.local_source_roots[0] / "source" source_root.mkdir() (source_root / "data.txt").write_text("backup data", encoding="utf-8") adapter = adapters.LocalAdapter(source_root, settings) staged_blob = tmp_path / "staged.blob" with pytest.raises(faults.InjectedCrash): await snapshot._copy_file(adapter, "data.txt", staged_blob, faults.CrashAt(point)) assert not (settings.repository_roots[0] / "blobs" / "sha256").exists() @pytest.mark.asyncio async def test_staging_is_owner_only_with_a_permissive_umask(tmp_path: Path) -> None: settings = settings_for(tmp_path) source_root = settings.local_source_roots[0] / "source" source_root.mkdir() (source_root / "data.txt").write_text("plaintext", encoding="utf-8") adapter = adapters.LocalAdapter(source_root, settings) staging = tmp_path / "staging" staged_blobs = staging / "blobs" staged_blob = staged_blobs / "0.blob" old_umask = os.umask(0) try: snapshot._private_directory(staging) snapshot._private_directory(staged_blobs) with pytest.raises(faults.InjectedCrash): await snapshot._copy_file( adapter, "data.txt", staged_blob, faults.CrashAt("blob.after_write"), ) finally: os.umask(old_umask) assert stat.S_IMODE(staging.stat().st_mode) == 0o700 assert stat.S_IMODE(staged_blobs.stat().st_mode) == 0o700 assert stat.S_IMODE(staged_blob.stat().st_mode) == 0o600 assert staged_blob.read_text(encoding="utf-8") == "plaintext" def test_blob_install_crash_point_leaves_staged_blob_unpublished( tmp_path: Path, ) -> None: settings = settings_for(tmp_path) staged_blob = tmp_path / "staged.blob" staged_blob.write_bytes(b"backup data") digest = "a" * 64 target = settings.repository_roots[0] / "blobs" / "sha256" / digest with pytest.raises(faults.InjectedCrash): snapshot._install_blob(staged_blob, target, digest, faults.CrashAt("blob.before_rename")) assert staged_blob.exists() assert not target.exists() async def _login(client: httpx.AsyncClient) -> dict[str, str]: response = await client.post("/api/v2/setup", json={"username": "admin", "password": PASSWORD}) assert response.status_code == 201 return {"X-CSRF-Token": client.cookies["backup_tool_csrf"]} @pytest.mark.asyncio async def test_metadata_crash_is_reconciled_without_republishing( app_client: tuple[httpx.AsyncClient, Settings], ) -> None: client, settings = app_client source_root = settings.local_source_roots[0] / "source" source_root.mkdir() (source_root / "data.txt").write_text("backup data", encoding="utf-8") headers = await _login(client) repository = await client.post( "/api/v2/repositories", json={ "name": "repo", "relative_path": "repo", "compression": "none", "encryption": "none", }, headers=headers, ) source = await client.post( "/api/v2/sources", json={ "name": "source", "kind": "local", "public_config": {"root": str(source_root)}, }, headers=headers, ) job = await client.post( "/api/v2/jobs", json={ "name": "job", "source_id": source.json()["id"], "repository_id": repository.json()["id"], "requested_mode": "full", "exclusions": [], "retention": {}, "enabled": True, "allow_empty": False, }, headers=headers, ) execution = await client.post(f"/api/v2/jobs/{job.json()['id']}/executions", headers=headers) execution_id = execution.json()["id"] crashing_worker = worker_module.Worker( settings, owner="crashing-worker", fault_injector=faults.CrashAt("metadata.before_commit"), ) try: with pytest.raises(faults.InjectedCrash): await crashing_worker.run_once() finally: await crashing_worker.engine.dispose() engine = create_engine(settings) try: sessions = async_sessionmaker(engine, expire_on_commit=False) async with sessions() as db: assert ( await db.scalar( select(models.Backup).where(models.Backup.execution_id == execution_id) ) is None ) finally: await engine.dispose() recovery_worker = worker_module.Worker(settings, owner="recovery-worker") try: assert await recovery_worker.startup() == 1 finally: await recovery_worker.engine.dispose() engine = create_engine(settings) try: sessions = async_sessionmaker(engine, expire_on_commit=False) async with sessions() as db: recovered = await db.get(models.Execution, execution_id) backups = list( await db.scalars( select(models.Backup).where(models.Backup.execution_id == execution_id) ) ) finally: await engine.dispose() assert recovered is not None and recovered.state == "committed" assert len(backups) == 1