Files
backup-tool/tests/fault/test_publication_crashes.py
T

204 lines
6.8 KiB
Python

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