from __future__ import annotations from datetime import UTC, datetime, timedelta from pathlib import Path import httpx import pytest from backup_tool.api.app import create_app, execution_events_stream from backup_tool.config import Settings from backup_tool.db.models import Execution, Repository, Source from backup_tool.execution import ( claim, complete_cancellation, enqueue, heartbeat, recover_stale, request_cancellation, ) 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_local_source_probe_archive_and_repository_targeted_job( tmp_path: Path, ) -> None: source_root = tmp_path / "sources" source_root.mkdir() (source_root / "data.txt").write_text("contents") data_dir = tmp_path / "data" data_dir.mkdir() key = tmp_path / "master.key" key.write_bytes(b"x" * 32) key.chmod(0o600) repositories = tmp_path / "repositories" restore = tmp_path / "restore" repositories.mkdir() restore.mkdir() settings = Settings( data_dir=data_dir, database_url=f"sqlite+aiosqlite:///{data_dir / 'db.sqlite'}", repository_roots=(repositories,), local_source_roots=(source_root,), restore_roots=(restore,), master_key_file=key, ) 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) transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="https://test") as client: headers = await login(client) repository = await client.post( "/api/v2/repositories", json={"name": "repo", "relative_path": "main"}, headers=headers, ) assert repository.status_code == 201 source = await client.post( "/api/v2/sources", json={ "name": "local", "kind": "local", "public_config": {"root": str(source_root)}, }, headers=headers, ) assert source.status_code == 201 source_id = source.json()["id"] probe = await client.post(f"/api/v2/sources/{source_id}/probe", headers=headers) assert probe.status_code == 200 assert probe.json()["entry_count"] == 1 job = await client.post( "/api/v2/jobs", json={ "name": "job", "source_id": source_id, "repository_id": repository.json()["id"], "requested_mode": "full", "exclusions": ["*.tmp"], "retention": {}, "enabled": True, "allow_empty": False, }, headers=headers, ) assert job.status_code == 201 assert "destination_path" not in job.json() execution = await client.post( f"/api/v2/jobs/{job.json()['id']}/executions", headers=headers ) assert execution.status_code == 202 assert execution.json()["state"] == "queued" duplicate = await client.post( f"/api/v2/jobs/{job.json()['id']}/executions", headers=headers ) assert duplicate.status_code == 409 assert duplicate.json()["code"] == "execution_active" execution_id = execution.json()["id"] scoped_token = await client.post( "/api/v2/auth/tokens", json={"scopes": ["audit:read"], "expires_at": None}, headers={**headers, "Idempotency-Key": "execution-audit-token"}, ) assert scoped_token.status_code == 201 token_headers = {"Authorization": f"Bearer {scoped_token.json()['token']}"} assert ( await client.get(f"/api/v2/executions/{execution_id}", headers=token_headers) ).status_code == 403 assert ( await client.post(f"/api/v2/executions/{execution_id}/cancel", headers=token_headers) ).status_code == 403 async with app.state.sessions() as db: assert await claim(db, execution_id, "expired-worker") is not None stored = await db.get(Execution, execution_id) assert stored is not None stored.lease_expires_at = datetime.now(UTC) - timedelta(seconds=1) await db.commit() async with app.state.sessions() as db: assert await recover_stale(db) == 1 assert await claim(db, execution_id, "replacement-worker") is not None async with app.state.sessions() as db: assert not await heartbeat(db, execution_id, "expired-worker") assert await request_cancellation(db, execution_id) is not None assert not await complete_cancellation(db, execution_id, "expired-worker") assert await complete_cancellation(db, execution_id, "replacement-worker") async with app.state.sessions() as db: stored = await db.get(Execution, execution_id) assert stored is not None stored.progress = { "source_path": "/private/source", "details": {"Token": "nested-secret", "password": "nested-password"}, } await db.commit() polled = await client.get(f"/api/v2/executions/{execution_id}", headers=headers) assert polled.status_code == 200 assert polled.json()["state"] == "cancelled" assert "/private/source" not in str(polled.json()) assert "nested-secret" not in str(polled.json()) assert "nested-password" not in str(polled.json()) async with app.state.sessions() as db: stored = await db.get(Execution, execution.json()["id"]) assert stored is not None stored.state = "failed" stored.reason_code = "timeout" await db.commit() retried = await client.post( f"/api/v2/executions/{execution.json()['id']}/retry", headers=headers ) assert retried.status_code == 202 assert retried.json()["id"] == execution.json()["id"] assert retried.json()["attempt"] == 2 cancelled = await client.post( f"/api/v2/executions/{execution.json()['id']}/cancel", headers=headers ) assert cancelled.status_code == 202 stream = await client.get( f"/api/v2/executions/{execution.json()['id']}/events", headers=headers ) assert stream.status_code == 200 assert "event: execution" in stream.text assert "/private/source" not in stream.text assert "nested-secret" not in stream.text assert "nested-password" not in stream.text archived = await client.delete(f"/api/v2/sources/{source_id}", headers=headers) assert archived.status_code == 204 assert ( await client.post(f"/api/v2/sources/{source_id}/probe", headers=headers) ).status_code == 409 @pytest.mark.asyncio async def test_execution_sse_replays_later_redacted_revision(tmp_path: Path) -> None: source_root = tmp_path / "sources" source_root.mkdir() data_dir = tmp_path / "data" data_dir.mkdir() key = tmp_path / "master.key" key.write_bytes(b"x" * 32) key.chmod(0o600) repositories = tmp_path / "repositories" restore = tmp_path / "restore" repositories.mkdir() restore.mkdir() settings = Settings( data_dir=data_dir, database_url=f"sqlite+aiosqlite:///{data_dir / 'db.sqlite'}", repository_roots=(repositories,), local_source_roots=(source_root,), restore_roots=(restore,), master_key_file=key, ) app = create_app(settings) from backup_tool.db.models import Base, Job async with app.state.engine.begin() as connection: await connection.run_sync(Base.metadata.create_all) async with app.state.sessions() as db: repository = Repository( name="stream-repository", root=str(repositories / "stream"), format_version=1, compression="none", encryption="none", ) source = Source( name="stream-source", kind="local", public_config={"root": str(source_root)}, secret_refs=[], ) db.add_all([repository, source]) await db.flush() job = Job( name="stream-job", source_id=source.id, repository_id=repository.id, requested_mode="full", exclusions=[], retention={}, ) db.add(job) await db.commit() execution = await enqueue(db, job.id) execution_id = execution.id stream = execution_events_stream(app.state.sessions, execution_id, None) first = await anext(stream) first_id = first.split("\\n", 1)[0].removeprefix("id: ") assert "queued" in first async with app.state.sessions() as db: assert await request_cancellation(db, execution_id) is not None second = await anext(stream) assert second.split("\\n", 1)[0].removeprefix("id: ") != first_id assert "cancelled" in second replay = execution_events_stream(app.state.sessions, execution_id, first_id) assert "cancelled" in await anext(replay) await stream.aclose() await replay.aclose() @pytest.mark.asyncio async def test_local_source_rejects_unallowlisted_root(tmp_path: Path) -> None: allowed = tmp_path / "allowed" allowed.mkdir() outside = tmp_path / "outside" outside.mkdir() data_dir = tmp_path / "data" data_dir.mkdir() key = tmp_path / "master.key" key.write_bytes(b"x" * 32) key.chmod(0o600) repositories = tmp_path / "repositories" restore = tmp_path / "restore" repositories.mkdir() restore.mkdir() settings = Settings( data_dir=data_dir, database_url=f"sqlite+aiosqlite:///{data_dir / 'db.sqlite'}", repository_roots=(repositories,), local_source_roots=(allowed,), restore_roots=(restore,), master_key_file=key, ) 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: headers = await login(client) response = await client.post( "/api/v2/sources", json={ "name": "bad", "kind": "local", "public_config": {"root": str(outside)}, }, headers=headers, ) assert response.status_code == 422