feat(v2): complete v2 reimplementation

This commit is contained in:
2026-07-31 13:33:39 +02:00
parent 396219e776
commit bd107d6a30
137 changed files with 20737 additions and 155 deletions
@@ -0,0 +1,389 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import pytest
from backup_tool.db.models import (
Backup,
Execution,
Job,
NotificationDelivery,
NotificationEvent,
NotificationSubscription,
Repository,
Schedule,
Source,
)
from backup_tool.execution import record_event
from backup_tool.notifications.events import EVENT_CATALOG
from backup_tool.scheduler import deliver_due
from backup_tool.worker import Worker
from sqlalchemy import select
@pytest.mark.asyncio
async def test_execution_catalog_events_are_produced_and_delivered(app_client) -> None:
"""Exercise the execution producer, not emit_event(), for each live execution type."""
client, _ = app_client
setup = await client.post(
"/api/v2/setup",
json={"username": "admin", "password": "correct horse battery staple"},
)
assert setup.status_code == 201
app = client._transport.app
expected = {
"execution.queued",
"execution.started",
"execution.committed",
"execution.failed",
"execution.cancelled",
"execution.retry_queued",
"execution.worker_recovered",
}
assert expected <= set(EVENT_CATALOG)
deferred_prefixes = ["source.", "gc.", "reconciliation."]
assert not any(item.startswith(tuple(deferred_prefixes)) for item in EVENT_CATALOG)
async with app.state.sessions() as db:
repository = Repository(
name="events-repository",
root="/events-repository",
format_version=1,
compression="none",
encryption="none",
)
source = Source(
name="events-source",
kind="local",
public_config={"root": "/events-source"},
secret_refs=[],
)
db.add_all([repository, source])
await db.flush()
job = Job(
name="events-job",
source_id=source.id,
repository_id=repository.id,
exclusions=[],
retention={},
requested_mode="full",
)
subscription = NotificationSubscription(
channel="email",
event_filters=["execution.*"],
destination_config={"recipients": ["operator@example.test"]},
rate_limit_per_minute=60,
rate_tokens=60.0,
)
db.add_all([job, subscription])
await db.flush()
cases = (
("queued", 1, None, "execution.queued"),
("preparing", 1, None, "execution.started"),
("committed", 1, None, "execution.committed"),
("failed", 1, "transient_io", "execution.failed"),
("cancelled", 1, "cancellation_requested", "execution.cancelled"),
("queued", 2, None, "execution.retry_queued"),
("queued", 1, "worker_lost", "execution.worker_recovered"),
)
for state, attempt, reason, _event_type in cases:
execution = Execution(
job_id=job.id,
trigger="manual",
state=state,
attempt=attempt,
reason_code=reason,
progress={},
)
db.add(execution)
await db.flush()
await record_event(db, execution)
if state in {"queued", "preparing"}:
execution.state = "failed"
execution.reason_code = "test_cleanup"
await db.flush()
await db.commit()
event_statement = select(NotificationEvent.type).where(NotificationEvent.type.in_(expected))
event_types = set((await db.scalars(event_statement)).all())
deliveries = await db.scalar(
select(NotificationDelivery.id)
.join(NotificationEvent, NotificationDelivery.event_id == NotificationEvent.id)
.where(NotificationEvent.type.in_(expected))
.limit(1)
)
assert event_types == expected
assert deliveries is not None
@pytest.mark.asyncio
async def test_schedule_catalog_events_are_produced_and_delivered(app_client) -> None:
client, _ = app_client
setup = await client.post(
"/api/v2/setup",
json={"username": "admin", "password": "correct horse battery staple"},
)
assert setup.status_code == 201
csrf = client.cookies["backup_tool_csrf"]
app = client._transport.app
expected = {
"schedule.created",
"schedule.updated",
"schedule.deleted",
"schedule.enabled",
"schedule.disabled",
"schedule.occurrence_enqueued",
"schedule.occurrence_misfired",
"schedule.occurrence_blocked",
}
assert expected <= set(EVENT_CATALOG)
async with app.state.sessions() as db:
repository = Repository(
name="schedule-repository",
root="/schedule-repository",
format_version=1,
compression="none",
encryption="none",
)
source = Source(
name="schedule-source",
kind="local",
public_config={"root": "/schedule-source"},
secret_refs=[],
)
db.add_all([repository, source])
await db.flush()
jobs = [
Job(
name=f"schedule-job-{number}",
source_id=source.id,
repository_id=repository.id,
exclusions=[],
retention={},
requested_mode="full",
enabled=number not in {2, 3},
)
for number in range(1, 5)
]
subscription = NotificationSubscription(
channel="email",
event_filters=["schedule.*"],
destination_config={"recipients": ["operator@example.test"]},
rate_limit_per_minute=60,
rate_tokens=60.0,
)
db.add_all([*jobs, subscription])
await db.commit()
job_ids = [job.id for job in jobs]
created = await client.post(
f"/api/v2/jobs/{job_ids[0]}/schedule",
json={"cron": "0 0 * * *", "timezone": "UTC", "enabled": True},
headers={"X-CSRF-Token": csrf},
)
assert created.status_code == 201
disabled = await client.patch(
f"/api/v2/jobs/{job_ids[0]}/schedule",
json={"cron": "0 0 * * *", "timezone": "UTC", "enabled": False},
headers={"X-CSRF-Token": csrf},
)
assert disabled.status_code == 200
enabled = await client.patch(
f"/api/v2/jobs/{job_ids[0]}/schedule",
json={"cron": "1 0 * * *", "timezone": "UTC", "enabled": True},
headers={"X-CSRF-Token": csrf},
)
assert enabled.status_code == 200
updated = await client.patch(
f"/api/v2/jobs/{job_ids[0]}/schedule",
json={"cron": "2 0 * * *", "timezone": "UTC", "enabled": True},
headers={"X-CSRF-Token": csrf},
)
assert updated.status_code == 200
assert (
await client.delete(f"/api/v2/jobs/{job_ids[0]}/schedule", headers={"X-CSRF-Token": csrf})
).status_code == 204
async with app.state.sessions() as db:
now = datetime.now(UTC)
db.add_all(
[
Schedule(
job_id=job_ids[1],
cron="* * * * *",
timezone="UTC",
misfire_grace_seconds=0,
enabled=True,
next_nominal_at=now - timedelta(hours=1),
),
Schedule(
job_id=job_ids[2],
cron="* * * * *",
timezone="UTC",
misfire_grace_seconds=60,
enabled=True,
next_nominal_at=now,
),
Schedule(
job_id=job_ids[3],
cron="* * * * *",
timezone="UTC",
misfire_grace_seconds=60,
enabled=True,
next_nominal_at=now,
),
]
)
await db.commit()
assert await deliver_due(db, now=now) == 1
statement = select(NotificationEvent.type).where(NotificationEvent.type.in_(expected))
event_types = set((await db.scalars(statement)).all())
delivery = await db.scalar(
select(NotificationDelivery.id)
.join(NotificationEvent, NotificationDelivery.event_id == NotificationEvent.id)
.where(NotificationEvent.type.in_(expected))
.limit(1)
)
assert event_types == expected
assert delivery is not None
@pytest.mark.asyncio
async def test_backup_restore_and_retention_events_are_produced_and_delivered(
app_client,
) -> None:
client, settings = app_client
source_root = settings.local_source_roots[0] / "notification-project"
source_root.mkdir()
(source_root / "data.txt").write_text("notification data\n", encoding="utf-8")
setup = await client.post(
"/api/v2/setup",
json={"username": "admin", "password": "correct horse battery staple"},
)
assert setup.status_code == 201
headers = {"X-CSRF-Token": client.cookies["backup_tool_csrf"]}
subscription = await client.post(
"/api/v2/notifications/subscriptions",
json={
"channel": "email",
"event_filters": ["backup.*", "restore.*", "retention.*"],
"destination": {"recipients": ["operator@example.test"]},
},
headers=headers,
)
assert subscription.status_code == 201
repository = await client.post(
"/api/v2/repositories",
json={"name": "notification-repo", "relative_path": "notification-repo"},
headers=headers,
)
source = await client.post(
"/api/v2/sources",
json={
"name": "notification-source",
"kind": "local",
"public_config": {"root": str(source_root)},
},
headers=headers,
)
assert repository.status_code == source.status_code == 201
job = await client.post(
"/api/v2/jobs",
json={
"name": "notification-job",
"source_id": source.json()["id"],
"repository_id": repository.json()["id"],
"requested_mode": "full",
"exclusions": [],
"retention": {"keep_last": 1},
"allow_empty": False,
},
headers=headers,
)
assert job.status_code == 201
execution = await client.post(f"/api/v2/jobs/{job.json()['id']}/executions", headers=headers)
assert execution.status_code == 202
worker = Worker(settings, owner="notification-backup-worker")
try:
assert await worker.run_once()
finally:
await worker.engine.dispose()
app = client._transport.app
async with app.state.sessions() as db:
backup = await db.scalar(
select(Backup).where(Backup.execution_id == execution.json()["id"])
)
assert backup is not None
# A synthetic older catalog entry is valid business state; tombstoning is
# performed only through the real retention producer below.
older_execution = Execution(
job_id=job.json()["id"],
trigger="manual",
state="committed",
progress={},
)
db.add(older_execution)
await db.flush()
older = Backup(
execution_id=older_execution.id,
manifest_id="00000000-0000-7000-8000-000000000001",
manifest_digest="0" * 64,
logical_bytes=0,
stored_bytes=0,
integrity="verified",
created_at=datetime.now(UTC) - timedelta(days=1),
)
db.add(older)
await db.commit()
retention_worker = Worker(settings, owner="notification-retention-worker")
try:
# Retention/GC is executed by worker maintenance, not a direct helper call.
assert await retention_worker.run_once()
finally:
await retention_worker.engine.dispose()
restore = await client.post(
f"/api/v2/backups/{backup.id}/restores",
json={
"destination": str(settings.restore_roots[0] / "notification-restore"),
"selection": [],
"dry_run": True,
"overwrite_policy": "fail",
},
headers=headers,
)
assert restore.status_code == 202
restore_worker = Worker(settings, owner="notification-restore-worker")
try:
assert await restore_worker.run_once()
finally:
await restore_worker.engine.dispose()
expected = {
"backup.committed",
"backup.verification_succeeded",
"restore.queued",
"restore.committed",
"retention.tombstoned",
}
async with app.state.sessions() as db:
types = set(
(
await db.scalars(
select(NotificationEvent.type).where(NotificationEvent.type.in_(expected))
)
).all()
)
deliveries = list(
(
await db.scalars(
select(NotificationDelivery.id)
.join(
NotificationEvent,
NotificationDelivery.event_id == NotificationEvent.id,
)
.where(NotificationEvent.type.in_(expected))
)
).all()
)
assert types == expected
assert len(deliveries) >= len(expected)
+13
View File
@@ -0,0 +1,13 @@
from __future__ import annotations
import importlib
import pytest
snapshot = importlib.import_module("backup_tool.snapshot")
def test_empty_source_requires_explicit_opt_in() -> None:
with pytest.raises(snapshot.SnapshotError, match="source_empty"):
snapshot.require_nonempty([], False)
snapshot.require_nonempty([], True)
@@ -0,0 +1,496 @@
from __future__ import annotations
import asyncio
import json
import stat
from pathlib import Path
import httpx
import pytest
from alembic import command
from backup_tool.cli import (
build_alembic_config,
recovery_export_payload,
rotate_repository_key,
)
from backup_tool.cli import main as cli_main
from backup_tool.config import Settings
from backup_tool.db.engine import create_engine
from backup_tool.db.models import Backup, Execution, Repository, RepositoryDataKeyEpoch
from backup_tool.repository import (
begin_key_rotation,
initialize,
inspect_repository,
replace_active_data_key,
)
from backup_tool.security.repository_crypto import create_data_key
from backup_tool.worker import Worker
from sqlalchemy import select
from .test_repository_safety import settings_for
PASSWORD = "correct-horse-battery-staple"
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"]}
def test_encrypted_initialization_creates_private_data_key(tmp_path: Path) -> None:
settings = settings_for(tmp_path)
initialized = initialize(settings, "encrypted", "none", "aes-256-gcm")
assert initialized.data_key_id is not None
assert initialized.data_key_path is not None
assert stat.S_IMODE(initialized.data_key_path.stat().st_mode) == 0o600
try:
payload = json.loads((initialized.root / "repository.json").read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise AssertionError("encrypted repository metadata is unreadable") from error
assert payload["encryption"] == {
"mode": "aes-256-gcm",
"key_id": initialized.data_key_id,
}
inspected = inspect_repository(settings, initialized.root)
assert inspected.encryption == "aes-256-gcm"
assert inspected.data_key_id == initialized.data_key_id
def test_rotation_rejects_metadata_database_epoch_mismatch(tmp_path: Path) -> None:
settings = settings_for(tmp_path)
command.upgrade(build_alembic_config(settings), "head")
initialized = initialize(settings, "encrypted", "none", "aes-256-gcm")
assert initialized.data_key_id is not None
async def create_repository() -> str:
engine = create_engine(settings)
try:
from sqlalchemy.ext.asyncio import async_sessionmaker
sessions = async_sessionmaker(engine, expire_on_commit=False)
async with sessions() as db:
repository = Repository(
name="encrypted",
root=str(initialized.root),
format_version=initialized.format_version,
compression=initialized.compression,
encryption=initialized.encryption,
signing_key_id=initialized.signing_key_id,
signing_public_key=initialized.signing_public_key,
active_data_key_id=initialized.data_key_id,
)
db.add(repository)
await db.flush()
db.add(
RepositoryDataKeyEpoch(
repository_id=repository.id,
key_id=initialized.data_key_id,
state="active",
)
)
await db.commit()
return repository.id
finally:
await engine.dispose()
repository_id = asyncio.run(create_repository())
replacement_id, replacement_path = create_data_key(settings, initialized.repository_id)
try:
replace_active_data_key(initialized.root, initialized.data_key_id, replacement_id)
with pytest.raises(ValueError, match="repository encryption metadata is invalid"):
asyncio.run(rotate_repository_key(settings, repository_id))
finally:
replacement_path.unlink(missing_ok=True)
def test_rotation_reconciliation_clears_stale_rollback_journal_after_key_removal(
tmp_path: Path,
) -> None:
settings = settings_for(tmp_path)
command.upgrade(build_alembic_config(settings), "head")
initialized = initialize(settings, "encrypted", "none", "aes-256-gcm")
assert initialized.data_key_id is not None
async def create_repository() -> str:
engine = create_engine(settings)
try:
from sqlalchemy.ext.asyncio import async_sessionmaker
sessions = async_sessionmaker(engine, expire_on_commit=False)
async with sessions() as db:
repository = Repository(
name="encrypted",
root=str(initialized.root),
format_version=initialized.format_version,
compression=initialized.compression,
encryption=initialized.encryption,
signing_key_id=initialized.signing_key_id,
signing_public_key=initialized.signing_public_key,
active_data_key_id=initialized.data_key_id,
)
db.add(repository)
await db.flush()
db.add(
RepositoryDataKeyEpoch(
repository_id=repository.id,
key_id=initialized.data_key_id,
state="active",
)
)
await db.commit()
return repository.id
finally:
await engine.dispose()
repository_id = asyncio.run(create_repository())
new_key_id, new_key_path = create_data_key(settings, initialized.repository_id)
begin_key_rotation(
initialized.root,
repository_id,
initialized.repository_id,
initialized.data_key_id,
new_key_id,
)
new_key_path.unlink()
assert (initialized.root / ".key-rotation.json").is_file()
async def reconcile_and_assert() -> None:
worker = Worker(settings, owner="stale-rollback-journal-worker")
try:
assert await worker.startup() == 1
finally:
await worker.engine.dispose()
engine = create_engine(settings)
try:
from sqlalchemy.ext.asyncio import async_sessionmaker
sessions = async_sessionmaker(engine, expire_on_commit=False)
async with sessions() as db:
repository = await db.get(Repository, repository_id)
assert repository is not None
assert repository.active_data_key_id == initialized.data_key_id
inspected = inspect_repository(settings, initialized.root)
assert inspected.data_key_id == initialized.data_key_id
finally:
await engine.dispose()
asyncio.run(reconcile_and_assert())
assert not (initialized.root / ".key-rotation.json").exists()
def test_rotation_crash_after_db_commit_recovers_on_worker_startup(
tmp_path: Path,
) -> None:
settings = settings_for(tmp_path)
command.upgrade(build_alembic_config(settings), "head")
initialized = initialize(settings, "encrypted", "none", "aes-256-gcm")
assert initialized.data_key_id is not None
async def create_repository() -> str:
engine = create_engine(settings)
try:
from sqlalchemy.ext.asyncio import async_sessionmaker
sessions = async_sessionmaker(engine, expire_on_commit=False)
async with sessions() as db:
repository = Repository(
name="encrypted",
root=str(initialized.root),
format_version=initialized.format_version,
compression=initialized.compression,
encryption=initialized.encryption,
signing_key_id=initialized.signing_key_id,
signing_public_key=initialized.signing_public_key,
active_data_key_id=initialized.data_key_id,
)
db.add(repository)
await db.flush()
db.add(
RepositoryDataKeyEpoch(
repository_id=repository.id,
key_id=initialized.data_key_id,
state="active",
)
)
await db.commit()
return repository.id
finally:
await engine.dispose()
repository_id = asyncio.run(create_repository())
def interrupted_after_database_commit() -> None:
raise OSError("simulated process loss after database commit")
with pytest.raises(OSError, match="simulated process loss"):
asyncio.run(
rotate_repository_key(
settings,
repository_id,
after_database_commit=interrupted_after_database_commit,
)
)
assert inspect_repository(settings, initialized.root).data_key_id == initialized.data_key_id
assert (initialized.root / ".key-rotation.json").is_file()
async def reconcile_and_assert() -> None:
worker = Worker(settings, owner="rotation-recovery-worker")
try:
assert await worker.startup() == 1
finally:
await worker.engine.dispose()
engine = create_engine(settings)
try:
from sqlalchemy.ext.asyncio import async_sessionmaker
sessions = async_sessionmaker(engine, expire_on_commit=False)
async with sessions() as db:
repository = await db.get(Repository, repository_id)
assert repository is not None
assert repository.active_data_key_id is not None
inspected = inspect_repository(settings, initialized.root)
assert inspected.data_key_id == repository.active_data_key_id
active_data_key_id = repository.active_data_key_id
exported = await recovery_export_payload(settings)
exported_repository = exported["catalog"]["repositories"][0]
assert exported_repository["active_data_key_id"] == active_data_key_id
finally:
await engine.dispose()
asyncio.run(reconcile_and_assert())
assert not (initialized.root / ".key-rotation.json").exists()
@pytest.mark.asyncio
async def test_encrypted_repository_worker_backup_and_restore(
app_client: tuple[httpx.AsyncClient, Settings],
) -> None:
client, settings = app_client
source_root = settings.local_source_roots[0] / "project"
source_root.mkdir()
plaintext = b"encrypted backup content\n"
(source_root / "hello.txt").write_bytes(plaintext)
initialized = initialize(settings, "encrypted", "none", "aes-256-gcm")
assert initialized.data_key_id is not None
engine = create_engine(settings)
try:
from sqlalchemy.ext.asyncio import async_sessionmaker
sessions = async_sessionmaker(engine, expire_on_commit=False)
async with sessions() as db:
repository = Repository(
name="encrypted",
root=str(initialized.root),
format_version=initialized.format_version,
compression=initialized.compression,
encryption=initialized.encryption,
signing_key_id=initialized.signing_key_id,
signing_public_key=initialized.signing_public_key,
active_data_key_id=initialized.data_key_id,
)
db.add(repository)
await db.flush()
db.add(
RepositoryDataKeyEpoch(
repository_id=repository.id,
key_id=initialized.data_key_id,
state="active",
)
)
await db.commit()
repository_id = repository.id
finally:
await engine.dispose()
headers = await login(client)
source_response = await client.post(
"/api/v2/sources",
json={
"name": "local",
"kind": "local",
"public_config": {"root": str(source_root)},
},
headers=headers,
)
assert source_response.status_code == 201
job_response = await client.post(
"/api/v2/jobs",
json={
"name": "encrypted-backup",
"source_id": source_response.json()["id"],
"repository_id": repository_id,
"requested_mode": "full",
"exclusions": [],
"retention": {},
"enabled": True,
"allow_empty": False,
},
headers=headers,
)
assert job_response.status_code == 201
execution_response = await client.post(
f"/api/v2/jobs/{job_response.json()['id']}/executions", headers=headers
)
assert execution_response.status_code == 202
execution_id = execution_response.json()["id"]
worker = Worker(settings, owner="encrypted-backup-worker")
try:
assert await worker.run_once()
finally:
await worker.engine.dispose()
engine = create_engine(settings)
try:
from sqlalchemy.ext.asyncio import async_sessionmaker
sessions = async_sessionmaker(engine, expire_on_commit=False)
async with sessions() as db:
execution = await db.get(Execution, execution_id)
backup = await db.scalar(select(Backup).where(Backup.execution_id == execution_id))
finally:
await engine.dispose()
assert execution is not None
assert execution.state == "committed"
assert backup is not None
blob = next((initialized.root / "blobs" / "sha256").iterdir())
assert blob.read_bytes().startswith(b"BTENC\x01")
assert plaintext not in blob.read_bytes()
manifest_path = initialized.root / "manifests" / f"{backup.manifest_id}.json"
stored_manifest = manifest_path.read_bytes()
assert stored_manifest.startswith(b"BTENC\x01")
assert b'"entries"' not in stored_manifest
assert b"hello.txt" not in stored_manifest
assert (
await asyncio.to_thread(
cli_main,
["admin", "repository-key", "rotate", "--repository-id", repository_id],
settings=settings,
)
== 0
)
engine = create_engine(settings)
try:
from sqlalchemy.ext.asyncio import async_sessionmaker
sessions = async_sessionmaker(engine, expire_on_commit=False)
async with sessions() as db:
repository = await db.get(Repository, repository_id)
epochs = list(
(
await db.scalars(
select(RepositoryDataKeyEpoch).where(
RepositoryDataKeyEpoch.repository_id == repository_id
)
)
).all()
)
finally:
await engine.dispose()
assert repository is not None
assert repository.active_data_key_id != initialized.data_key_id
epoch_states = {f"{epoch.key_id}:{epoch.state}" for epoch in epochs}
expected_epoch_states = {
f"{initialized.data_key_id}:retired",
f"{repository.active_data_key_id}:active",
}
assert epoch_states == expected_epoch_states
assert (
inspect_repository(settings, initialized.root).data_key_id == repository.active_data_key_id
)
plaintext_after_rotation = b"encrypted content after rotation\n"
(source_root / "hello.txt").write_bytes(plaintext_after_rotation)
second_execution_response = await client.post(
f"/api/v2/jobs/{job_response.json()['id']}/executions", headers=headers
)
assert second_execution_response.status_code == 202
second_execution_id = second_execution_response.json()["id"]
second_worker = Worker(settings, owner="encrypted-rotated-backup-worker")
try:
assert await second_worker.run_once()
finally:
await second_worker.engine.dispose()
engine = create_engine(settings)
try:
from sqlalchemy.ext.asyncio import async_sessionmaker
sessions = async_sessionmaker(engine, expire_on_commit=False)
async with sessions() as db:
second_backup = await db.scalar(
select(Backup).where(Backup.execution_id == second_execution_id)
)
finally:
await engine.dispose()
assert second_backup is not None
assert second_backup.data_key_id == repository.active_data_key_id
destination = settings.restore_roots[0] / "restored"
restore_response = await client.post(
f"/api/v2/backups/{backup.id}/restores",
json={
"destination": str(destination),
"selection": [],
"overwrite_policy": "fail",
},
headers=headers,
)
assert restore_response.status_code == 202
source_root.rename(settings.data_dir / "removed-source")
restore_worker = Worker(settings, owner="encrypted-restore-worker")
try:
assert await restore_worker.run_once()
finally:
await restore_worker.engine.dispose()
restored = await client.get(
f"/api/v2/restores/{restore_response.json()['id']}", headers=headers
)
assert restored.json()["state"] == "committed"
assert (destination / "hello.txt").read_bytes() == plaintext
rotated_destination = settings.restore_roots[0] / "rotated-restored"
rotated_restore_response = await client.post(
f"/api/v2/backups/{second_backup.id}/restores",
json={
"destination": str(rotated_destination),
"selection": [],
"overwrite_policy": "fail",
},
headers=headers,
)
assert rotated_restore_response.status_code == 202
rotated_restore_worker = Worker(settings, owner="encrypted-rotated-restore-worker")
try:
assert await rotated_restore_worker.run_once()
finally:
await rotated_restore_worker.engine.dispose()
assert (rotated_destination / "hello.txt").read_bytes() == plaintext_after_rotation
manifest_path.write_bytes(stored_manifest[:-1] + bytes([stored_manifest[-1] ^ 1]))
corrupt_destination = settings.restore_roots[0] / "corrupt-manifest"
corrupt_restore = await client.post(
f"/api/v2/backups/{backup.id}/restores",
json={
"destination": str(corrupt_destination),
"selection": [],
"overwrite_policy": "fail",
},
headers=headers,
)
assert corrupt_restore.status_code == 202
corrupt_worker = Worker(settings, owner="encrypted-corrupt-manifest-worker")
try:
assert await corrupt_worker.run_once()
finally:
await corrupt_worker.engine.dispose()
corrupt_status = await client.get(
f"/api/v2/restores/{corrupt_restore.json()['id']}", headers=headers
)
assert corrupt_status.json()["state"] == "failed"
assert not corrupt_destination.exists()
@@ -0,0 +1,246 @@
from __future__ import annotations
from pathlib import Path
import httpx
import pytest
from backup_tool.config import Settings
from backup_tool.db.engine import create_engine
from backup_tool.db.models import Backup, Execution, Repository, Restore
from backup_tool.snapshot import verify_published_snapshot
from backup_tool.worker import Worker
from sqlalchemy import select
PASSWORD = "correct-horse-battery-staple"
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_worker_publishes_a_verified_signed_full_backup_and_atomic_restore(
app_client: tuple[httpx.AsyncClient, Settings],
) -> None:
client, settings = app_client
source_root = settings.local_source_roots[0] / "project"
source_root.mkdir()
(source_root / "nested").mkdir()
(source_root / "nested" / "hello.txt").write_text("hello backup\n", encoding="utf-8")
headers = await login(client)
repository_response = await client.post(
"/api/v2/repositories",
json={
"name": "primary",
"relative_path": "primary",
"compression": "none",
"encryption": "none",
},
headers=headers,
)
assert repository_response.status_code == 201
source_response = await client.post(
"/api/v2/sources",
json={
"name": "local",
"kind": "local",
"public_config": {"root": str(source_root)},
},
headers=headers,
)
assert source_response.status_code == 201
job_response = await client.post(
"/api/v2/jobs",
json={
"name": "full-backup",
"source_id": source_response.json()["id"],
"repository_id": repository_response.json()["id"],
"requested_mode": "full",
"exclusions": [],
"retention": {},
"enabled": True,
"allow_empty": False,
},
headers=headers,
)
assert job_response.status_code == 201
execution_response = await client.post(
f"/api/v2/jobs/{job_response.json()['id']}/executions", headers=headers
)
assert execution_response.status_code == 202
execution_id = execution_response.json()["id"]
worker = Worker(settings, owner="snapshot-worker")
try:
assert await worker.run_once()
finally:
await worker.engine.dispose()
engine = create_engine(settings)
try:
from sqlalchemy.ext.asyncio import async_sessionmaker
sessions = async_sessionmaker(engine, expire_on_commit=False)
async with sessions() as db:
execution = await db.get(Execution, execution_id)
backup = await db.scalar(select(Backup).where(Backup.execution_id == execution_id))
repository = await db.get(Repository, repository_response.json()["id"])
finally:
await engine.dispose()
assert execution is not None
assert execution.state == "committed"
assert backup is not None
assert backup.integrity == "verified"
assert repository is not None
root = Path(repository.root)
manifest_path = root / "manifests" / f"{backup.manifest_id}.json"
manifest = verify_published_snapshot(root, manifest_path, repository.signing_public_key)
file_entry = next(entry for entry in manifest["entries"] if entry["type"] == "file")
assert file_entry["path"] == "nested/hello.txt"
assert (root / "blobs" / "sha256" / file_entry["blob_digest"]).read_text() == "hello backup\n"
dry_run_destination = settings.restore_roots[0] / "dry-run-backup"
dry_run_response = await client.post(
f"/api/v2/backups/{backup.id}/restores",
json={
"destination": str(dry_run_destination),
"selection": ["nested"],
"dry_run": True,
"overwrite_policy": "fail",
},
headers=headers,
)
assert dry_run_response.status_code == 202
dry_run_worker = Worker(settings, owner="dry-run-worker")
try:
assert await dry_run_worker.run_once()
finally:
await dry_run_worker.engine.dispose()
dry_run = await client.get(f"/api/v2/restores/{dry_run_response.json()['id']}", headers=headers)
assert dry_run.json()["state"] == "committed"
assert dry_run.json()["result"]["dry_run"]
assert dry_run.json()["result"]["entry_count"] == 2
assert not dry_run_destination.exists()
destination = settings.restore_roots[0] / "restored-backup"
restore_response = await client.post(
f"/api/v2/backups/{backup.id}/restores",
json={
"destination": str(destination),
"selection": [],
"overwrite_policy": "fail",
},
headers=headers,
)
assert restore_response.status_code == 202
restore_id = restore_response.json()["id"]
assert restore_response.json()["state"] == "queued"
source_root.rename(settings.data_dir / "removed-source")
restore_worker = Worker(settings, owner="restore-worker")
try:
assert await restore_worker.run_once()
finally:
await restore_worker.engine.dispose()
restored = await client.get(f"/api/v2/restores/{restore_id}", headers=headers)
assert restored.status_code == 200
assert restored.json()["state"] == "committed"
assert restored.json()["result"]["manifest_digest"] == backup.manifest_digest
assert (destination / "nested" / "hello.txt").read_text(encoding="utf-8") == "hello backup\n"
engine = create_engine(settings)
try:
from sqlalchemy.ext.asyncio import async_sessionmaker
sessions = async_sessionmaker(engine, expire_on_commit=False)
async with sessions() as db:
interrupted_restore = await db.get(Restore, restore_id)
assert interrupted_restore is not None
interrupted_restore.state = "running"
interrupted_restore.result = None
await db.commit()
finally:
await engine.dispose()
recovery_worker = Worker(settings, owner="recovery-worker")
try:
assert await recovery_worker.startup() == 1
finally:
await recovery_worker.engine.dispose()
recovered = await client.get(f"/api/v2/restores/{restore_id}", headers=headers)
assert recovered.json()["state"] == "committed"
skipped_restore = await client.post(
f"/api/v2/backups/{backup.id}/restores",
json={
"destination": str(destination),
"selection": [],
"overwrite_policy": "skip",
},
headers=headers,
)
assert skipped_restore.status_code == 202
skip_worker = Worker(settings, owner="skip-worker")
try:
assert await skip_worker.run_once()
finally:
await skip_worker.engine.dispose()
skipped = await client.get(f"/api/v2/restores/{skipped_restore.json()['id']}", headers=headers)
assert skipped.json()["result"]["skipped"]
(destination / "nested" / "hello.txt").write_text("replaced", encoding="utf-8")
replaced_restore = await client.post(
f"/api/v2/backups/{backup.id}/restores",
json={
"destination": str(destination),
"selection": [],
"overwrite_policy": "replace",
},
headers=headers,
)
assert replaced_restore.status_code == 202
replace_worker = Worker(settings, owner="replace-worker")
try:
assert await replace_worker.run_once()
finally:
await replace_worker.engine.dispose()
assert (destination / "nested" / "hello.txt").read_text(encoding="utf-8") == "hello backup\n"
(root / "blobs" / "sha256" / file_entry["blob_digest"]).write_text("tampered")
corrupt_destination = settings.restore_roots[0] / "corrupt-restore"
corrupt_restore = await client.post(
f"/api/v2/backups/{backup.id}/restores",
json={
"destination": str(corrupt_destination),
"selection": [],
"overwrite_policy": "fail",
},
headers=headers,
)
assert corrupt_restore.status_code == 202
corrupt_worker = Worker(settings, owner="corrupt-restore-worker")
try:
assert await corrupt_worker.run_once()
finally:
await corrupt_worker.engine.dispose()
corrupt_status = await client.get(
f"/api/v2/restores/{corrupt_restore.json()['id']}", headers=headers
)
assert corrupt_status.json()["state"] == "failed"
assert not corrupt_destination.exists()
engine = create_engine(settings)
try:
from sqlalchemy.ext.asyncio import async_sessionmaker
sessions = async_sessionmaker(engine, expire_on_commit=False)
async with sessions() as db:
corrupted_backup = await db.get(Backup, backup.id)
finally:
await engine.dispose()
assert corrupted_backup is not None
assert corrupted_backup.integrity == "corrupt"
+136
View File
@@ -0,0 +1,136 @@
from __future__ import annotations
import json
import os
from datetime import UTC, datetime, timedelta
from pathlib import Path
import pytest
from backup_tool.gc import purge_repository
from backup_tool.security.repository_crypto import encrypt_object, object_aad
def write_manifest(path: Path, digests: list[str]) -> None:
path.write_text(
json.dumps({"entries": [{"blob_digest": digest} for digest in digests]}),
encoding="utf-8",
)
def age(path: Path, days: int = 8) -> None:
stamp = (datetime.now(UTC) - timedelta(days=days)).timestamp()
os.utime(path, (stamp, stamp))
def test_gc_purges_tombstoned_manifest_and_only_unreferenced_old_blob(
tmp_path: Path,
) -> None:
root = tmp_path / "repository"
manifests = root / "manifests"
blobs = root / "blobs" / "sha256"
manifests.mkdir(parents=True)
blobs.mkdir(parents=True)
kept = "a" * 64
removed = "b" * 64
write_manifest(manifests / "kept.json", [kept])
write_manifest(manifests / "deleted.json", [removed])
(blobs / kept).write_bytes(b"kept")
(blobs / removed).write_bytes(b"removed")
age(manifests / "deleted.json")
age(blobs / removed)
report = purge_repository(root, {"deleted"})
assert report.purged_manifests == 1
assert report.purged_blobs == 1
assert (manifests / "kept.json").exists()
assert (blobs / kept).exists()
assert not (blobs / removed).exists()
@pytest.mark.parametrize("encrypted", [False, True], ids=["corrupt", "encrypted"])
def test_gc_fails_closed_for_unreadable_manifest(tmp_path: Path, encrypted: bool) -> None:
root = tmp_path / "repository"
manifests = root / "manifests"
blobs = root / "blobs" / "sha256"
manifests.mkdir(parents=True)
blobs.mkdir(parents=True)
kept = "a" * 64
deleted = "b" * 64
write_manifest(manifests / "kept.json", [kept])
write_manifest(manifests / "deleted.json", [deleted])
unreadable = manifests / "unreadable.json"
unreadable.write_bytes(
encrypt_object(
b"k" * 32,
object_aad("repository", "key", "manifest", "unreadable"),
b'{"entries": []}',
)
if encrypted
else b"not json"
)
(blobs / kept).write_bytes(b"kept")
(blobs / deleted).write_bytes(b"deleted")
age(manifests / "deleted.json")
age(blobs / deleted)
report = purge_repository(root, {"deleted"})
assert report.purged_manifests == 0
assert report.purged_blobs == 0
assert (manifests / "deleted.json").exists()
assert (blobs / deleted).exists()
def test_gc_purges_encrypted_manifests_with_known_epoch_keys(tmp_path: Path) -> None:
root = tmp_path / "repository"
manifests = root / "manifests"
blobs = root / "blobs" / "sha256"
manifests.mkdir(parents=True)
blobs.mkdir(parents=True)
key = b"k" * 32
repository_id = "repository"
key_id = "epoch"
kept = "a" * 64
deleted = "b" * 64
for manifest_id, digests in (("kept", [kept]), ("deleted", [deleted])):
plaintext = json.dumps(
{"entries": [{"blob_digest": digest} for digest in digests]}
).encode()
(manifests / f"{manifest_id}.json").write_bytes(
encrypt_object(
key,
object_aad(repository_id, key_id, "manifest", manifest_id),
plaintext,
)
)
(blobs / kept).write_bytes(b"kept")
(blobs / deleted).write_bytes(b"deleted")
age(manifests / "deleted.json")
age(blobs / deleted)
report = purge_repository(
root,
{"deleted"},
repository_id=repository_id,
manifest_keys={"kept": (key_id, key), "deleted": (key_id, key)},
)
assert report.purged_manifests == 1
assert report.purged_blobs == 1
assert (blobs / kept).exists()
assert not (blobs / deleted).exists()
def test_gc_quarantines_unknown_blob_name(tmp_path: Path) -> None:
root = tmp_path / "repository"
blobs = root / "blobs" / "sha256"
blobs.mkdir(parents=True)
unknown = blobs / "not-a-digest"
unknown.write_bytes(b"unknown")
report = purge_repository(root, set())
assert report.quarantined == 1
assert not unknown.exists()
assert (root / "quarantine" / "not-a-digest").exists()
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
from backup_tool.snapshot import _unsigned_manifest
class Object:
def __init__(self, **values: object) -> None:
self.__dict__.update(values)
def test_incremental_request_without_baseline_emits_complete_full_manifest() -> None:
manifest = _unsigned_manifest(
"0198c57f-0000-7000-8000-000000000006",
"0198c57f-0000-7000-8000-000000000001",
Object(id="0198c57f-0000-7000-8000-000000000003", kind="local"),
Object(
id="0198c57f-0000-7000-8000-000000000004",
requested_mode="incremental",
exclusions=[],
),
Object(id="0198c57f-0000-7000-8000-000000000005"),
[],
0,
0,
)
assert manifest["requested_mode"] == "incremental"
assert manifest["effective_mode"] == "full"
+199
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import importlib
import os
import sqlite3
import stat
from datetime import UTC, datetime, timedelta, timezone
from pathlib import Path
@@ -24,8 +25,13 @@ EXPECTED_TABLES = {
"idempotency_records",
"jobs",
"notification_deliveries",
"notification_delivery_attempts",
"notification_email_settings",
"notification_events",
"notification_signing_keys",
"notification_subscriptions",
"repositories",
"repository_data_key_epochs",
"restores",
"schedules",
"secrets",
@@ -103,6 +109,199 @@ async def test_startup_rejects_unmigrated_database(tmp_path: Path) -> None:
await engine.dispose()
def test_local_only_sources_migration_rejects_existing_remote_sources(
tmp_path: Path,
) -> None:
settings = settings_for(tmp_path)
migration = alembic_config(settings.database_url)
command.upgrade(migration, "0005_restore_dry_run")
with sqlite3.connect(settings.database_path) as connection:
connection.execute(
"""
INSERT INTO sources (name, kind, public_config, secret_refs, state, last_probe, id)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
("remote", "sftp", "{}", "[]", "active", None, "source-remote"),
)
with pytest.raises(RuntimeError, match="found 1 non-local source row"):
command.upgrade(migration, "head")
with sqlite3.connect(settings.database_path) as connection:
connection.execute("UPDATE sources SET kind = ? WHERE id = ?", ("local", "source-remote"))
command.upgrade(migration, "head")
with (
sqlite3.connect(settings.database_path) as connection,
pytest.raises(sqlite3.IntegrityError),
):
connection.execute(
"""
INSERT INTO sources (name, kind, public_config, secret_refs, state, last_probe, id)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
("remote-two", "sftp", "{}", "[]", "active", None, "source-remote-two"),
)
command.downgrade(migration, "0005_restore_dry_run")
with sqlite3.connect(settings.database_path) as connection:
connection.execute(
"""
INSERT INTO sources (name, kind, public_config, secret_refs, state, last_probe, id)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
("remote-three", "sftp", "{}", "[]", "active", None, "source-remote-three"),
)
def test_0009_allows_ssh_sources_and_refuses_populated_downgrade(
tmp_path: Path,
) -> None:
settings = settings_for(tmp_path)
migration = alembic_config(settings.database_url)
command.upgrade(migration, "0008_notification_outbox")
with sqlite3.connect(settings.database_path) as connection:
connection.execute(
"""
INSERT INTO sources (name, kind, public_config, secret_refs, state, last_probe, id)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
("local", "local", '{"root":"/tmp"}', "[]", "active", None, "source-local"),
)
command.upgrade(migration, "head")
with sqlite3.connect(settings.database_path) as connection:
local_source = connection.execute("SELECT kind FROM sources").fetchone()
assert local_source is not None
assert local_source[0] == "local"
connection.execute(
"""
INSERT INTO sources (name, kind, public_config, secret_refs, state, last_probe, id)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
"ssh",
"ssh",
'{"hostname":"backup.example.test"}',
'["secret"]',
"active",
None,
"source-ssh",
),
)
with pytest.raises(sqlite3.IntegrityError):
connection.execute(
"""
INSERT INTO sources (name, kind, public_config, secret_refs, state, last_probe, id)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
("sftp", "sftp", "{}", "[]", "active", None, "source-sftp"),
)
with pytest.raises(RuntimeError, match="found 1 SSH source row"):
command.downgrade(migration, "0008_notification_outbox")
with sqlite3.connect(settings.database_path) as connection:
connection.execute("DELETE FROM sources WHERE id = ?", ("source-ssh",))
command.downgrade(migration, "0008_notification_outbox")
with (
sqlite3.connect(settings.database_path) as connection,
pytest.raises(sqlite3.IntegrityError),
):
connection.execute(
"""
INSERT INTO sources (name, kind, public_config, secret_refs, state, last_probe, id)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
"ssh-after-downgrade",
"ssh",
"{}",
"[]",
"active",
None,
"source-ssh-after",
),
)
def test_0008_migrates_unexpected_legacy_notification_delivery(tmp_path: Path) -> None:
settings = settings_for(tmp_path)
migration = alembic_config(settings.database_url)
command.upgrade(migration, "0007_repository_data_key_epochs")
with sqlite3.connect(settings.database_path) as connection:
connection.execute(
"""
INSERT INTO notification_subscriptions
(id, channel, event_filters, destination_config, state)
VALUES (?, ?, ?, ?, ?)
""",
(
"subscription",
"email",
'["execution.queued"]',
'{"recipients":["a@example.test"]}',
"active",
),
)
connection.execute(
"""
INSERT INTO notification_deliveries
(id, event_id, subscription_id, attempt, state, response_class)
VALUES (?, ?, ?, ?, ?, ?)
""",
("delivery", "legacy-event", "subscription", 1, "delivered", "http_200"),
)
command.upgrade(migration, "head")
with sqlite3.connect(settings.database_path) as connection:
event = connection.execute("SELECT type, payload FROM notification_events").fetchone()
attempt = connection.execute(
"SELECT number, outcome, response_class FROM notification_delivery_attempts"
).fetchone()
assert event is not None and event[0] == "notification.legacy"
assert "legacy-event" in event[1]
assert attempt is not None
assert attempt[0] == 1
assert attempt[1] == "delivered"
assert attempt[2] == "http_200"
def test_0007_downgrade_refuses_populated_key_metadata(tmp_path: Path) -> None:
settings = settings_for(tmp_path)
migration = alembic_config(settings.database_url)
command.upgrade(migration, "head")
with sqlite3.connect(settings.database_path) as connection:
connection.execute(
"""
INSERT INTO repositories
(id, name, root, format_version, compression, encryption, active_data_key_id, state)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
"repository",
"repository",
str(settings.repository_roots[0]),
1,
"none",
"none",
"epoch",
"active",
),
)
connection.execute(
"""
INSERT INTO repository_data_key_epochs (id, repository_id, key_id, state)
VALUES (?, ?, ?, ?)
""",
("epoch-row", "repository", "epoch", "active"),
)
with pytest.raises(
RuntimeError, match="cannot downgrade while repository data key metadata exists"
):
command.downgrade(migration, "0006_local_sources_only")
def test_database_role_rejects_unmigrated_database(tmp_path: Path) -> None:
cli = importlib.import_module("backup_tool.cli")
db_module = importlib.import_module("backup_tool.db.engine")
+233
View File
@@ -0,0 +1,233 @@
from __future__ import annotations
from collections.abc import Callable
from datetime import UTC, datetime
from typing import cast
import pytest
from backup_tool.db.models import (
NotificationDelivery,
NotificationEmailSettings,
NotificationEvent,
)
from backup_tool.ids import new_uuid7
from backup_tool.notifications.email import SMTPClient, deliver_email
from backup_tool.notifications.events import emit_event
from sqlalchemy import select
PASSWORD = "correct horse battery staple"
async def _setup(client) -> str:
response = await client.post("/api/v2/setup", json={"username": "admin", "password": PASSWORD})
assert response.status_code == 201
return client.cookies["backup_tool_csrf"]
@pytest.mark.asyncio
async def test_filters_manual_test_retry_and_history(app_client) -> None:
client, _ = app_client
csrf = await _setup(client)
created = await client.post(
"/api/v2/notifications/subscriptions",
json={
"channel": "email",
"event_filters": ["schedule.*", "notification.test_requested"],
"destination": {"recipients": ["operator@example.test"]},
},
headers={"X-CSRF-Token": csrf},
)
assert created.status_code == 201
subscription_id = created.json()["id"]
app = client._transport.app
async with app.state.sessions() as db:
await emit_event(
db,
"execution.queued",
correlation_id=str(new_uuid7()),
resource={},
deduplication_key="filtered-out",
)
scheduled = await emit_event(
db,
"schedule.created",
correlation_id=str(new_uuid7()),
resource={},
deduplication_key="filtered-in",
)
await db.commit()
statement = select(NotificationDelivery).where(
NotificationDelivery.event_id == scheduled.id
)
deliveries = list((await db.scalars(statement)).all())
assert len(deliveries) == 1
delivery = deliveries[0]
delivery.state = "failed"
delivery.terminal_reason = "http_permanent"
await db.commit()
delivery_id = delivery.id
tested = await client.post(
f"/api/v2/notifications/subscriptions/{subscription_id}/test",
headers={"X-CSRF-Token": csrf, "Idempotency-Key": "test-one"},
)
assert tested.status_code == 202
retried = await client.post(
f"/api/v2/notifications/deliveries/{delivery_id}/retry",
headers={"X-CSRF-Token": csrf, "Idempotency-Key": "retry-one"},
)
assert retried.status_code == 202
replayed = await client.post(
f"/api/v2/notifications/deliveries/{delivery_id}/retry",
headers={"X-CSRF-Token": csrf, "Idempotency-Key": "retry-one"},
)
assert replayed.status_code == 202
assert replayed.json() == retried.json()
history = await client.get("/api/v2/notifications/deliveries")
assert history.status_code == 200
row = next(item for item in history.json()["items"] if item["id"] == delivery_id)
assert row["state"] == "retry"
@pytest.mark.asyncio
async def test_manual_test_bypasses_filters_and_targets_only_selected_subscription(
app_client,
) -> None:
client, _ = app_client
csrf = await _setup(client)
selected = await client.post(
"/api/v2/notifications/subscriptions",
json={
"channel": "email",
"event_filters": ["execution.failed"],
"destination": {"recipients": ["selected@example.test"]},
},
headers={"X-CSRF-Token": csrf},
)
other = await client.post(
"/api/v2/notifications/subscriptions",
json={
"channel": "email",
"event_filters": ["notification.test_requested"],
"destination": {"recipients": ["other@example.test"]},
},
headers={"X-CSRF-Token": csrf},
)
assert selected.status_code == other.status_code == 201
response = await client.post(
f"/api/v2/notifications/subscriptions/{selected.json()['id']}/test",
headers={"X-CSRF-Token": csrf, "Idempotency-Key": "selected-test"},
)
assert response.status_code == 202
app = client._transport.app
async with app.state.sessions() as db:
rows = list(
(
await db.scalars(
select(NotificationDelivery.subscription_id).where(
NotificationDelivery.event_id == response.json()["event_id"]
)
)
).all()
)
assert rows == [selected.json()["id"]]
@pytest.mark.asyncio
async def test_disabled_subscription_does_not_receive_future_events(app_client) -> None:
client, _ = app_client
csrf = await _setup(client)
created = await client.post(
"/api/v2/notifications/subscriptions",
json={
"channel": "email",
"event_filters": ["execution.*"],
"destination": {"recipients": ["operator@example.test"]},
},
headers={"X-CSRF-Token": csrf},
)
subscription = created.json()
disabled = await client.patch(
f"/api/v2/notifications/subscriptions/{subscription['id']}",
json={"state": "disabled"},
headers={"X-CSRF-Token": csrf, "If-Match": created.headers["ETag"]},
)
assert disabled.status_code == 200
app = client._transport.app
async with app.state.sessions() as db:
event = await emit_event(
db,
"execution.queued",
correlation_id=str(new_uuid7()),
resource={},
occurred_at=datetime.now(UTC),
)
await db.commit()
assert (
await db.scalar(
select(NotificationDelivery.id).where(NotificationDelivery.event_id == event.id)
)
is None
)
@pytest.mark.asyncio
async def test_email_uses_ehlo_starttls_then_auth_with_hermetic_fake() -> None:
calls: list[str] = []
class FakeSMTP:
def __init__(self, *_args, **_kwargs) -> None:
calls.append("connect")
def __enter__(self):
return self
def __exit__(self, *_args) -> None:
calls.append("close")
def ehlo(self) -> None:
calls.append("ehlo")
def starttls(self, *, context) -> None:
assert context.check_hostname
calls.append("starttls")
def login(self, username: str, password: str) -> None:
assert username == "operator"
assert password == "smtp-password"
calls.append("auth")
def send_message(self, message) -> None:
assert "smtp-password" not in message.as_string()
calls.append("send")
event = NotificationEvent(
id=str(new_uuid7()),
type="execution.queued",
schema_version=1,
occurred_at=datetime.now(UTC),
correlation_id=str(new_uuid7()),
severity="info",
resource_refs={},
payload={},
canonical_envelope="{}",
)
settings = NotificationEmailSettings(
id=1,
host="smtp.example.test",
port=587,
username="operator",
password_secret_id=str(new_uuid7()),
sender="sender@example.test",
max_attempts=5,
rate_limit_per_minute=60,
)
result = await deliver_email(
settings,
"smtp-password",
event,
["operator@example.test"],
smtp_factory=cast(Callable[..., SMTPClient], FakeSMTP),
)
assert result.response_class == "smtp_2xx"
assert calls == ["connect", "ehlo", "starttls", "ehlo", "auth", "send", "close"]
+195
View File
@@ -0,0 +1,195 @@
from __future__ import annotations
import asyncio
import importlib
from collections.abc import AsyncIterator
from datetime import UTC, datetime, timedelta
from pathlib import Path
import pytest
import pytest_asyncio
from alembic import command
from backup_tool.config import Settings
from backup_tool.db.engine import create_engine
from backup_tool.db.models import Execution, Job, Repository, Source
from backup_tool.execution import (
EnqueueError,
claim,
complete_cancellation,
enqueue,
heartbeat,
request_cancellation,
retry,
)
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
cli = importlib.import_module("backup_tool.cli")
@pytest_asyncio.fixture
async def database(
tmp_path: Path,
) -> AsyncIterator[tuple[async_sessionmaker[AsyncSession], AsyncEngine]]:
key = tmp_path / "master.key"
key.write_bytes(b"m5-test-master-key-material-32-bytes-minimum")
key.chmod(0o600)
data_dir = tmp_path / "data"
repositories = tmp_path / "repositories"
sources = tmp_path / "sources"
restores = tmp_path / "restores"
for directory in (data_dir, repositories, sources, restores):
directory.mkdir()
settings = Settings(
data_dir=data_dir,
database_url=f"sqlite+aiosqlite:///{data_dir / 'metadata.db'}",
repository_roots=(repositories,),
local_source_roots=(sources,),
restore_roots=(restores,),
master_key_file=key,
)
command.upgrade(cli.build_alembic_config(settings), "head")
engine = create_engine(settings)
yield async_sessionmaker(engine, expire_on_commit=False), engine
await engine.dispose()
async def create_job(
db: AsyncSession, suffix: str, *, enabled: bool = True, state: str = "active"
) -> str:
repository = Repository(
name=f"repository-{suffix}",
root=f"/repositories/{suffix}",
format_version=1,
compression="none",
encryption="none",
)
source = Source(
name=f"source-{suffix}",
kind="local",
public_config={"root": f"/sources/{suffix}"},
secret_refs=[],
)
db.add_all([repository, source])
await db.flush()
job = Job(
name=f"job-{suffix}",
source_id=source.id,
repository_id=repository.id,
requested_mode="full",
exclusions=[],
retention={},
enabled=enabled,
state=state,
)
db.add(job)
await db.commit()
return job.id
@pytest.mark.asyncio
async def test_concurrent_enqueue_allows_exactly_one_active_execution(
database: tuple[async_sessionmaker[AsyncSession], AsyncEngine],
) -> None:
sessions, _ = database
async with sessions() as db:
job_id = await create_job(db, "concurrent")
async def start() -> Execution | EnqueueError:
async with sessions() as db:
try:
return await enqueue(db, job_id)
except EnqueueError as error:
return error
first, second = await asyncio.gather(start(), start())
results = [first, second]
successes = [result for result in results if isinstance(result, Execution)]
failures = [result for result in results if isinstance(result, EnqueueError)]
assert len(successes) == 1
assert len(failures) == 1
assert failures[0].code == "execution_active"
assert failures[0].active_execution_id == successes[0].id
async with sessions() as db:
executions = list(await db.scalars(select(Execution).where(Execution.job_id == job_id)))
assert [execution.id for execution in executions] == [successes[0].id]
@pytest.mark.asyncio
@pytest.mark.parametrize(("enabled", "state"), [(False, "active"), (True, "archived")])
async def test_enqueue_rejects_disabled_or_archived_jobs(
database: tuple[async_sessionmaker[AsyncSession], AsyncEngine],
enabled: bool,
state: str,
) -> None:
sessions, _ = database
async with sessions() as db:
job_id = await create_job(
db, f"unavailable-{enabled}-{state}", enabled=enabled, state=state
)
with pytest.raises(EnqueueError) as raised:
await enqueue(db, job_id)
assert raised.value.code == "job_disabled"
@pytest.mark.asyncio
async def test_reclaimed_lease_fences_the_previous_worker(
database: tuple[async_sessionmaker[AsyncSession], AsyncEngine],
) -> None:
sessions, _ = database
async with sessions() as db:
execution = await enqueue(db, await create_job(db, "leases"))
assert await claim(db, execution.id, "worker-a") is not None
assert await heartbeat(db, execution.id, "worker-a")
persisted = await db.get(Execution, execution.id)
assert persisted is not None
persisted.lease_expires_at = datetime.now(UTC) - timedelta(seconds=1)
await db.commit()
async with sessions() as db:
assert await claim(db, execution.id, "worker-b") is not None
assert not await heartbeat(db, execution.id, "worker-a")
assert await request_cancellation(db, execution.id) is not None
assert not await complete_cancellation(db, execution.id, "worker-a")
assert await complete_cancellation(db, execution.id, "worker-b")
async with sessions() as db:
persisted = await db.get(Execution, execution.id)
assert persisted is not None
assert persisted.state == "cancelled"
assert persisted.lease_owner is None
@pytest.mark.asyncio
async def test_retry_reuses_execution_and_rejects_non_transient_failures(
database: tuple[async_sessionmaker[AsyncSession], AsyncEngine],
) -> None:
sessions, _ = database
async with sessions() as db:
execution = await enqueue(db, await create_job(db, "retry"))
execution.state = "failed"
execution.reason_code = "transient_io"
execution.operator_message = "temporary failure"
execution.lease_owner = "worker-a"
execution.lease_expires_at = datetime.now(UTC) + timedelta(seconds=60)
await db.commit()
retried = await retry(db, execution.id)
assert retried is not None
assert retried.id == execution.id
assert retried.state == "queued"
assert retried.attempt == 2
assert retried.reason_code is None
assert retried.operator_message is None
assert retried.lease_owner is None
assert retried.lease_expires_at is None
retried.state = "failed"
retried.reason_code = "integrity_failure"
await db.commit()
with pytest.raises(EnqueueError) as raised:
await retry(db, execution.id)
assert raised.value.code == "retry_not_allowed"
+272
View File
@@ -0,0 +1,272 @@
from __future__ import annotations
import asyncio
import os
from datetime import UTC, datetime
from pathlib import Path
import pytest
from alembic import command
from backup_tool.cli import (
build_alembic_config,
import_recovery_payload,
)
from backup_tool.cli import (
main as cli_main,
)
from backup_tool.config import Settings
from backup_tool.db.engine import create_engine
from backup_tool.db.models import (
Backup,
Execution,
Job,
Repository,
RepositoryDataKeyEpoch,
Restore,
Source,
)
from backup_tool.ids import new_uuid7
from backup_tool.repository import initialize
from backup_tool.security.recovery_bundle import RecoveryBundleError, decrypt_bundle
from backup_tool.snapshot import finalize_publication, publish_full_snapshot
from backup_tool.worker import Worker
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker
from tests.conftest import make_settings
PASSPHRASE = b"correct horse battery staple"
def _passphrase_fd(value: bytes) -> int:
read_fd, write_fd = os.pipe()
os.write(write_fd, value + b"\n")
os.close(write_fd)
return read_fd
def _recovered_settings(tmp_path: Path, original: Settings) -> Settings:
data_dir = tmp_path / "recovered-data"
source_root = tmp_path / "recovered-sources"
restore_root = tmp_path / "recovered-restores"
for path in (data_dir, source_root, restore_root):
path.mkdir()
master_key = tmp_path / "recovered-master.key"
master_key.write_bytes(b"recovered-host-master-key-material-32-bytes")
master_key.chmod(0o600)
return Settings(
data_dir=data_dir,
database_url=f"sqlite+aiosqlite:///{data_dir / 'metadata.db'}",
repository_roots=original.repository_roots,
local_source_roots=(source_root,),
restore_roots=(restore_root,),
master_key_file=master_key,
min_free_bytes=1,
)
def test_recovery_import_restores_encrypted_snapshot_after_host_loss(
tmp_path: Path,
) -> None:
original_path = tmp_path / "original"
original_path.mkdir()
original = make_settings(original_path)
command.upgrade(build_alembic_config(original), "head")
source_root = original.local_source_roots[0] / "project"
source_root.mkdir()
plaintext = b"recovery host-loss content\n"
(source_root / "document.txt").write_bytes(plaintext)
initialized = initialize(original, "encrypted", "none", "aes-256-gcm")
assert initialized.data_key_id is not None
expected_created_at = datetime(2024, 1, 2, 3, 4, 5, tzinfo=UTC)
expected_tombstoned_at = datetime(2024, 2, 3, 4, 5, 6, tzinfo=UTC)
async def create_snapshot() -> tuple[str, str]:
engine = create_engine(original)
sessions = async_sessionmaker(engine, expire_on_commit=False)
try:
async with sessions() as db:
repository = Repository(
name="encrypted",
root=str(initialized.root),
format_version=initialized.format_version,
compression=initialized.compression,
encryption=initialized.encryption,
signing_key_id=initialized.signing_key_id,
signing_public_key=initialized.signing_public_key,
active_data_key_id=initialized.data_key_id,
)
source = Source(
name="project",
kind="local",
public_config={"root": str(source_root)},
secret_refs=[],
)
db.add_all([repository, source])
await db.flush()
db.add(
RepositoryDataKeyEpoch(
repository_id=repository.id,
key_id=initialized.data_key_id,
state="active",
)
)
job = Job(
name="encrypted-job",
source_id=source.id,
repository_id=repository.id,
requested_mode="full",
exclusions=[],
retention={},
allow_empty=False,
)
db.add(job)
await db.flush()
execution = Execution(job_id=job.id, trigger="manual", progress={})
db.add(execution)
await db.flush()
backup = await publish_full_snapshot(
original, db, execution, job, source, repository
)
backup.created_at = expected_created_at
execution.state = "committed"
tombstoned_execution = Execution(job_id=job.id, trigger="manual", progress={})
db.add(tombstoned_execution)
await db.flush()
tombstoned_backup = Backup(
execution_id=tombstoned_execution.id,
parent_backup_id=None,
manifest_id=str(new_uuid7()),
manifest_digest="0" * 64,
logical_bytes=0,
stored_bytes=0,
integrity="verified",
data_key_id=initialized.data_key_id,
tombstoned_at=expected_tombstoned_at,
created_at=expected_created_at,
)
db.add(tombstoned_backup)
await db.commit()
finalize_publication(initialized.root, execution.id)
return backup.id, tombstoned_backup.id
finally:
await engine.dispose()
original_backup_id, tombstoned_backup_id = asyncio.run(create_snapshot())
bundle = tmp_path / "offline.btrec"
export_fd = _passphrase_fd(PASSPHRASE)
try:
assert (
cli_main(
[
"admin",
"recovery",
"export",
"--output",
str(bundle),
"--passphrase-fd",
str(export_fd),
],
settings=original,
)
== 0
)
finally:
os.close(export_fd)
retry_path = tmp_path / "retry-recovery"
retry_path.mkdir()
retry = _recovered_settings(retry_path, original)
command.upgrade(build_alembic_config(retry), "head")
retry_payload = decrypt_bundle(bundle.read_bytes(), PASSPHRASE)
def interrupted_after_key_install() -> None:
raise OSError("simulated crash before metadata commit")
with pytest.raises(RecoveryBundleError, match="recovery import failed"):
asyncio.run(
import_recovery_payload(
retry,
retry_payload,
after_key_install=interrupted_after_key_install,
)
)
assert not list((retry.data_dir / "repository-keys").glob("*"))
assert not list((retry.data_dir / "repository-data-keys").glob("*"))
assert asyncio.run(import_recovery_payload(retry, retry_payload)) == 1
unsafe_path = tmp_path / "unsafe-recovery"
unsafe_path.mkdir()
unsafe = _recovered_settings(unsafe_path, original)
command.upgrade(build_alembic_config(unsafe), "head")
unsafe_payload = decrypt_bundle(bundle.read_bytes(), PASSPHRASE)
unsafe_payload["catalog"]["repositories"][0]["root"] = str(tmp_path)
with pytest.raises(RecoveryBundleError, match="recovery import failed"):
asyncio.run(import_recovery_payload(unsafe, unsafe_payload))
assert not (unsafe.data_dir / "repository-keys").exists()
assert not (unsafe.data_dir / "repository-data-keys").exists()
recovered = _recovered_settings(tmp_path, original)
command.upgrade(build_alembic_config(recovered), "head")
import_fd = _passphrase_fd(PASSPHRASE)
try:
assert (
cli_main(
[
"admin",
"recovery",
"import",
"--input",
str(bundle),
"--passphrase-fd",
str(import_fd),
],
settings=recovered,
)
== 0
)
finally:
os.close(import_fd)
async def restore_and_assert() -> None:
engine = create_engine(recovered)
sessions = async_sessionmaker(engine, expire_on_commit=False)
try:
async with sessions() as db:
backup = await db.get(Backup, original_backup_id)
tombstoned_backup = await db.get(Backup, tombstoned_backup_id)
job = await db.scalar(select(Job))
recovered_source = await db.scalar(select(Source))
assert backup is not None
assert tombstoned_backup is not None
assert backup.created_at == expected_created_at
assert tombstoned_backup.created_at == expected_created_at
assert tombstoned_backup.tombstoned_at == expected_tombstoned_at
assert job is not None
assert recovered_source is not None
assert recovered_source.state == "unavailable"
assert job.state == "archived"
assert not job.enabled
restore = Restore(
backup_id=backup.id,
destination=str(recovered.restore_roots[0] / "restored"),
selection=[],
overwrite_policy="fail",
)
db.add(restore)
await db.commit()
worker = Worker(recovered, owner="host-loss-restore")
try:
assert await worker.run_once()
finally:
await worker.engine.dispose()
finally:
await engine.dispose()
asyncio.run(restore_and_assert())
assert (recovered.restore_roots[0] / "restored" / "document.txt").read_bytes() == plaintext
payload = decrypt_bundle(bundle.read_bytes(), PASSPHRASE)
with pytest.raises(RecoveryBundleError, match="destination is not empty"):
asyncio.run(import_recovery_payload(recovered, payload))
+27 -1
View File
@@ -1,11 +1,14 @@
from __future__ import annotations
from pathlib import Path
from typing import Any, cast
import httpx
import pytest
from backup_tool.api.app import create_app
from backup_tool.config import Settings
from backup_tool.db.models import RepositoryDataKeyEpoch
from sqlalchemy import select
@pytest.mark.asyncio
@@ -30,7 +33,7 @@ async def test_admin_can_create_and_inspect_allowlisted_repository(
async with app.state.engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
transport = httpx.ASGITransport(app=app)
transport = httpx.ASGITransport(app=cast(Any, app))
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
setup = await client.post(
"/api/v2/setup", json={"username": "admin", "password": "a secure password"}
@@ -52,6 +55,29 @@ async def test_admin_can_create_and_inspect_allowlisted_repository(
assert body["name"] == "main"
assert body["format_version"] == 1
assert (root / "main" / "repository.json").is_file()
encrypted = await client.post(
"/api/v2/repositories",
json={
"name": "encrypted",
"relative_path": "encrypted",
"compression": "none",
"encryption": "aes-256-gcm",
},
headers={"X-CSRF-Token": csrf},
)
assert encrypted.status_code == 201, encrypted.text
async with app.state.sessions() as db:
epochs = list(
(
await db.scalars(
select(RepositoryDataKeyEpoch).where(
RepositoryDataKeyEpoch.repository_id == encrypted.json()["id"]
)
)
).all()
)
assert len(epochs) == 1
assert epochs[0].state == "active"
got = await client.get(f"/api/v2/repositories/{body['id']}")
assert got.status_code == 200
changed = await client.patch(
@@ -42,6 +42,7 @@ def test_partial_initialization_is_removed_on_publish_failure(tmp_path: Path) ->
initialize(settings, "main", "none", "none")
assert not (settings.repository_roots[0] / "main").exists()
assert not list(settings.repository_roots[0].glob(".main.staging-*"))
assert not list((settings.data_dir / "repository-keys").glob("*"))
@pytest.mark.parametrize("relative_path", ["/absolute", "../escape"])
@@ -0,0 +1,93 @@
from __future__ import annotations
from pathlib import Path
from typing import Protocol, cast
import httpx
import pytest
from backup_tool.api.app import create_app
from backup_tool.config import Settings
from backup_tool.db.models import Repository
from backup_tool.repository import initialize
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
PASSWORD = "a secure password"
class SignedRepository(Protocol):
repository_id: str
signing_key_id: str
signing_public_key: str
def settings_for(tmp_path: Path) -> Settings:
key = tmp_path / "master.key"
key.write_bytes(b"m6-test-master-key-material-32-bytes-minimum")
key.chmod(0o600)
repositories = tmp_path / "repositories"
repositories.mkdir()
return Settings(
data_dir=tmp_path,
database_url=f"sqlite+aiosqlite:///{tmp_path / 'metadata.db'}",
repository_roots=(repositories,),
local_source_roots=(tmp_path,),
restore_roots=(tmp_path,),
master_key_file=key,
min_free_bytes=1,
)
def test_repository_initialization_creates_a_bound_ed25519_keypair(
tmp_path: Path,
) -> None:
settings = settings_for(tmp_path)
initialized = cast(SignedRepository, initialize(settings, "main", "none", "none"))
key_path = settings.data_dir / "repository-keys" / f"{initialized.repository_id}.ed25519"
assert key_path.read_bytes()
assert key_path.stat().st_mode & 0o777 == 0o600
private_key = Ed25519PrivateKey.from_private_bytes(key_path.read_bytes())
public_key = private_key.public_key().public_bytes(
serialization.Encoding.Raw,
serialization.PublicFormat.Raw,
)
assert initialized.signing_public_key == public_key.hex()
assert initialized.signing_key_id.startswith("ed25519-")
@pytest.mark.asyncio
async def test_repository_api_persists_its_bound_public_signing_key(
tmp_path: Path,
) -> None:
settings = settings_for(tmp_path)
app = create_app(settings)
from backup_tool.db.models import Base
async with app.state.engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="https://test"
) as client:
assert (
await client.post("/api/v2/setup", json={"username": "admin", "password": PASSWORD})
).status_code == 201
created = await client.post(
"/api/v2/repositories",
json={
"name": "main",
"relative_path": "main",
"compression": "none",
"encryption": "none",
},
headers={"X-CSRF-Token": client.cookies["backup_tool_csrf"]},
)
assert created.status_code == 201
async with app.state.sessions() as db:
repository = await db.get(Repository, created.json()["id"])
await app.state.engine.dispose()
assert repository is not None
assert repository.signing_key_id.startswith("ed25519-")
assert len(repository.signing_public_key) == 64
@@ -0,0 +1,113 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Any, cast
import httpx
import pytest
from backup_tool.config import Settings
from backup_tool.db.models import NotificationDelivery, NotificationEvent, Schedule
from backup_tool.scheduler import SchedulerService
from sqlalchemy import select
PASSWORD = "correct-horse-battery-staple"
async def setup_job(client: httpx.AsyncClient, settings: Settings) -> tuple[dict[str, str], str]:
source_root = settings.local_source_roots[0] / "source"
source_root.mkdir()
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": True,
},
headers=headers,
)
assert job.status_code == 201
return headers, job.json()["id"]
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_schedule_role_delivery_and_live_crud_sync(
app_client: tuple[httpx.AsyncClient, Settings],
) -> None:
client, settings = app_client
headers, job_id = await setup_job(client, settings)
subscription = await client.post(
"/api/v2/notifications/subscriptions",
json={
"channel": "email",
"event_filters": ["schedule.occurrence_enqueued"],
"destination": {"recipients": ["operator@example.test"]},
},
headers=headers,
)
assert subscription.status_code == 201
created = await client.post(
f"/api/v2/jobs/{job_id}/schedule",
json={"cron": "* * * * *", "timezone": "UTC"},
headers=headers,
)
assert created.status_code == 201
app = cast(Any, client._transport).app
async with app.state.sessions() as db:
schedule = await db.scalar(select(Schedule).where(Schedule.job_id == job_id))
assert schedule is not None
schedule.next_nominal_at = datetime.now(UTC) - timedelta(seconds=1)
await db.commit()
service = SchedulerService(settings)
try:
assert await service.run_once() == 1
finally:
await service.engine.dispose()
async with app.state.sessions() as db:
delivery = await db.scalar(
select(NotificationDelivery.id)
.join(NotificationEvent, NotificationDelivery.event_id == NotificationEvent.id)
.where(NotificationEvent.type == "schedule.occurrence_enqueued")
.limit(1)
)
assert delivery is not None
updated = await client.patch(
f"/api/v2/jobs/{job_id}/schedule",
json={"cron": "0 10 * * *", "timezone": "UTC", "enabled": False},
headers=headers,
)
assert updated.status_code == 200
assert updated.json()["next_nominal_at"] is None
# Deletion with historical executions is deliberately restricted; schedule
# delete behavior is covered before occurrence enqueue in the catalog test.
+32 -6
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import json
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any, cast
import httpx
import pytest
@@ -18,7 +19,6 @@ from backup_tool.execution import (
recover_stale,
request_cancellation,
)
from backup_tool.worker import Worker
PASSWORD = "correct-horse-battery-staple"
@@ -58,7 +58,7 @@ async def test_local_source_probe_archive_and_repository_targeted_job(
async with app.state.engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
transport = httpx.ASGITransport(app=app)
transport = httpx.ASGITransport(app=cast(Any, app))
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
headers = await login(client)
repository = await client.post(
@@ -108,6 +108,24 @@ async def test_local_source_probe_archive_and_repository_targeted_job(
assert duplicate.status_code == 409
assert duplicate.json()["code"] == "execution_active"
execution_id = execution.json()["id"]
listed_sources = await client.get("/api/v2/sources", headers=headers)
assert listed_sources.status_code == 200
assert listed_sources.json()["items"] == [
{
"id": source_id,
"name": "local",
"kind": "local",
"state": "active",
"public_config": {"root": str(source_root)},
}
]
listed_jobs = await client.get("/api/v2/jobs", headers=headers)
assert listed_jobs.status_code == 200
assert listed_jobs.json()["items"][0]["id"] == job.json()["id"]
assert listed_jobs.json()["items"][0]["schedule"] is None
listed_executions = await client.get("/api/v2/executions", headers=headers)
assert listed_executions.status_code == 200
assert listed_executions.json()["items"][0]["id"] == execution_id
scoped_token = await client.post(
"/api/v2/auth/tokens",
json={"scopes": ["audit:read"], "expires_at": None},
@@ -252,7 +270,9 @@ async def test_execution_sse_replays_later_redacted_revision(tmp_path: Path) ->
@pytest.mark.asyncio
async def test_execution_events_preserve_progress_replay_and_recovery_order(tmp_path: Path) -> None:
async def test_execution_events_preserve_progress_replay_and_recovery_order(
tmp_path: Path,
) -> None:
source_root = tmp_path / "sources"
source_root.mkdir()
data_dir = tmp_path / "data"
@@ -306,8 +326,14 @@ async def test_execution_events_preserve_progress_replay_and_recovery_order(tmp_
execution = await enqueue(db, job.id)
execution_id = execution.id
worker = Worker(settings, owner="ordering-worker")
assert await worker.run_once()
async with app.state.sessions() as db:
assert await claim(db, execution_id, "ordering-worker") is not None
execution = await db.get(Execution, execution_id)
assert execution is not None and execution.state == "preparing"
execution.state = "running"
execution.started_at = datetime.now(UTC)
await record_event(db, execution)
await db.commit()
async with app.state.sessions() as db:
execution = await db.get(Execution, execution_id)
assert execution is not None and execution.state == "running"
@@ -383,7 +409,7 @@ async def test_local_source_rejects_unallowlisted_root(tmp_path: Path) -> None:
async with app.state.engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="https://test"
transport=httpx.ASGITransport(app=cast(Any, app)), base_url="https://test"
) as client:
headers = await login(client)
response = await client.post(
@@ -0,0 +1,197 @@
"""Opt-in live forced-SFTP chroot coverage; all SSH keys are generated under tmp_path."""
from __future__ import annotations
import os
import socket
import subprocess
import time
from pathlib import Path
import httpx
import pytest
from backup_tool.db.engine import create_engine
from backup_tool.db.models import Backup, Execution, Repository
from backup_tool.snapshot import verify_published_snapshot
from backup_tool.worker import Worker
from sqlalchemy import select
ROOT = Path(__file__).resolve().parents[2]
PASSWORD = "correct-horse-battery-staple"
def _enabled() -> bool:
return os.environ.get("BACKUP_TOOL_SSH_INTEGRATION") == "1"
def _port() -> int:
with socket.socket() as listener:
listener.bind(("127.0.0.1", 0))
address = listener.getsockname()
if not isinstance(address, tuple) or not isinstance(address[1], int):
raise RuntimeError("could not allocate SSH fixture port")
return address[1]
def _wait(port: int) -> None:
deadline = time.monotonic() + 60
while time.monotonic() < deadline:
try:
with socket.create_connection(("127.0.0.1", port), timeout=1):
return
except OSError:
time.sleep(0.25)
raise AssertionError("SSHD fixture did not become reachable")
@pytest.fixture
def sshd_fixture(tmp_path: Path):
fixture = tmp_path / "fixture"
host = fixture / "host"
source = fixture / "source"
host.mkdir(parents=True)
source.mkdir()
fixture.chmod(0o755)
host.chmod(0o755)
source.chmod(0o755)
private = fixture / "client"
for target in (host / "ssh_host_ed25519_key", private):
subprocess.run(
["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(target)],
check=True,
)
(host / "ssh_host_ed25519_key").chmod(0o644)
(fixture / "authorized_keys").write_text(private.with_suffix(".pub").read_text())
# Public keys are copied into a backup-owned 0600 tmpfs file at startup.
(fixture / "authorized_keys").chmod(0o644)
port = _port()
environment = os.environ | {
"SSH_FIXTURE_DIR": str(fixture),
"SSH_FIXTURE_PORT": str(port),
"COMPOSE_PROJECT_NAME": f"backup-tool-ssh-{os.getpid()}-{port}",
}
command = ["docker", "compose", "-f", "tests/compose.ssh.yaml"]
try:
subprocess.run([*command, "up", "--build", "-d"], cwd=ROOT, env=environment, check=True)
_wait(port)
host_key = " ".join(host.joinpath("ssh_host_ed25519_key.pub").read_text().split()[:2])
yield {
"port": port,
"key": private,
"host_key": host_key,
"source": source,
"env": environment,
}
finally:
subprocess.run(
[*command, "down", "--volumes", "--remove-orphans"],
cwd=ROOT,
env=environment,
check=False,
)
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.skipif(not _enabled(), reason="set BACKUP_TOOL_SSH_INTEGRATION=1")
@pytest.mark.asyncio
async def test_forced_sftp_chroot_probe_backup_and_restore(app_client, sshd_fixture) -> None:
client, settings = app_client
source = sshd_fixture["source"]
(source / "nested").mkdir()
(source / "nested" / "hello.txt").write_text("hello ssh\n")
headers = await _login(client)
secret = await client.post(
"/api/v2/admin/secrets",
json={"purpose": "ssh_private_key", "value": sshd_fixture["key"].read_text()},
headers=headers,
)
assert secret.status_code == 201
remote = await client.post(
"/api/v2/sources",
json={
"name": "ssh",
"kind": "ssh",
"private_key_secret_id": secret.json()["id"],
"public_config": {
"hostname": "127.0.0.1",
"port": sshd_fixture["port"],
"username": "backup",
"host_key": sshd_fixture["host_key"],
"root": "/",
},
},
headers=headers,
)
assert remote.status_code == 201, remote.text
assert (
await client.post(f"/api/v2/sources/{remote.json()['id']}/probe", headers=headers)
).json() == {"entry_count": 1}
repository = await client.post(
"/api/v2/repositories",
json={"name": "repo", "relative_path": "ssh"},
headers=headers,
)
job = await client.post(
"/api/v2/jobs",
json={
"name": "ssh-job",
"source_id": remote.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)
worker = Worker(settings, owner="ssh-live")
try:
assert await worker.run_once()
finally:
await worker.engine.dispose()
engine = create_engine(settings)
try:
from sqlalchemy.ext.asyncio import async_sessionmaker
async with async_sessionmaker(engine, expire_on_commit=False)() as db:
stored = await db.get(Execution, execution.json()["id"])
backup = await db.scalar(
select(Backup).where(Backup.execution_id == execution.json()["id"])
)
stored_repository = await db.get(Repository, repository.json()["id"])
finally:
await engine.dispose()
assert stored is not None and stored.state == "committed", (
stored.operator_message if stored else None
)
assert backup is not None and stored_repository is not None
manifest = verify_published_snapshot(
Path(stored_repository.root),
Path(stored_repository.root) / "manifests" / f"{backup.manifest_id}.json",
stored_repository.signing_public_key,
)
assert any(entry["path"] == "data/nested/hello.txt" for entry in manifest["entries"])
destination = settings.restore_roots[0] / "ssh-restored"
restore = await client.post(
f"/api/v2/backups/{backup.id}/restores",
json={
"destination": str(destination),
"selection": ["data/nested"],
"overwrite_policy": "fail",
},
headers=headers,
)
assert restore.status_code == 202
restore_worker = Worker(settings, owner="ssh-restore")
try:
assert await restore_worker.run_once()
finally:
await restore_worker.engine.dispose()
assert (destination / "data" / "nested" / "hello.txt").read_text() == "hello ssh\n"
+173
View File
@@ -0,0 +1,173 @@
from __future__ import annotations
import pytest
PASSWORD = "correct horse battery staple"
HOST_KEY = "ssh-ed25519 AQID"
async def _setup_headers(client) -> 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"]}
async def _create_secret(client, headers: dict[str, str], purpose: str) -> str:
response = await client.post(
"/api/v2/admin/secrets",
json={"purpose": purpose, "value": "PRIVATE-KEY-CANARY"},
headers=headers,
)
assert response.status_code == 201
assert "PRIVATE-KEY-CANARY" not in response.text
return str(response.json()["id"])
def _source(secret_id: str) -> dict[str, object]:
return {
"name": "remote",
"kind": "ssh",
"public_config": {
"hostname": "backup.example.test",
"port": 22,
"username": "backup",
"host_key": HOST_KEY,
"root": "/",
},
"private_key_secret_id": secret_id,
}
@pytest.mark.asyncio
async def test_ssh_source_persists_only_safe_public_config_and_one_key_reference(
app_client,
) -> None:
client, _ = app_client
headers = await _setup_headers(client)
secret_id = await _create_secret(client, headers, "ssh_private_key")
created = await client.post("/api/v2/sources", json=_source(secret_id), headers=headers)
assert created.status_code == 201
body = created.json()
assert body["kind"] == "ssh"
assert body["public_config"] == _source(secret_id)["public_config"]
assert "secret" not in body
assert "PRIVATE-KEY-CANARY" not in created.text
listed = await client.get("/api/v2/sources", headers=headers)
assert listed.status_code == 200
assert listed.json()["items"] == [body]
assert "PRIVATE-KEY-CANARY" not in listed.text
probe = await client.post(f"/api/v2/sources/{body['id']}/probe", headers=headers)
assert probe.status_code == 409
assert probe.json()["code"] == "source_probe_failed"
@pytest.mark.asyncio
async def test_ssh_source_requires_exactly_one_existing_private_key_secret(
app_client,
) -> None:
client, _ = app_client
headers = await _setup_headers(client)
wrong_purpose = await _create_secret(client, headers, "ssh")
request = _source(wrong_purpose)
rejected_purpose = await client.post("/api/v2/sources", json=request, headers=headers)
assert rejected_purpose.status_code == 422
assert rejected_purpose.json()["code"] == "validation_failed"
assert "PRIVATE-KEY-CANARY" not in rejected_purpose.text
missing_secret = await client.post(
"/api/v2/sources",
json=_source("00000000-0000-0000-0000-000000000000"),
headers=headers,
)
assert missing_secret.status_code == 422
assert missing_secret.json()["code"] == "validation_failed"
valid_secret = await _create_secret(client, headers, "ssh_private_key")
extra_secret_reference = {
**_source(valid_secret),
"secret_refs": [valid_secret, wrong_purpose],
}
rejected_extra = await client.post(
"/api/v2/sources", json=extra_secret_reference, headers=headers
)
assert rejected_extra.status_code == 422
@pytest.mark.asyncio
@pytest.mark.parametrize(
"payload",
[
{"name": "unsupported", "kind": "sftp", "public_config": {}},
{"name": "unsupported", "kind": "postgresql", "public_config": {}},
{"name": "unsupported", "kind": "mysql", "public_config": {}},
{"name": "unsupported", "kind": "shell", "public_config": {}},
],
)
async def test_source_api_rejects_all_non_local_ssh_kinds(
app_client, payload: dict[str, object]
) -> None:
client, _ = app_client
headers = await _setup_headers(client)
response = await client.post("/api/v2/sources", json=payload, headers=headers)
assert response.status_code == 422
@pytest.mark.asyncio
@pytest.mark.parametrize(
"public_config",
[
{
"hostname": "backup.example.test",
"port": 22,
"username": "backup",
"root": "/",
},
{
"hostname": "backup.example.test",
"port": 0,
"username": "backup",
"host_key": HOST_KEY,
"root": "/",
},
{
"hostname": "backup.example.test",
"port": 22,
"username": "backup",
"host_key": HOST_KEY,
"root": "/not-the-chroot",
},
{
"hostname": "backup.example.test",
"port": 22,
"username": "backup",
"host_key": HOST_KEY,
"root": "/",
"password": "not-supported",
},
{
"hostname": "backup.example.test",
"port": 22,
"username": "backup",
"host_key": HOST_KEY,
"root": "/",
"remote_command": "not-supported",
},
],
)
async def test_ssh_source_rejects_noncanonical_or_unsupported_config(
app_client, public_config: dict[str, object]
) -> None:
client, _ = app_client
headers = await _setup_headers(client)
secret_id = await _create_secret(client, headers, "ssh_private_key")
payload = _source(secret_id)
payload["public_config"] = public_config
response = await client.post("/api/v2/sources", json=payload, headers=headers)
assert response.status_code == 422