from __future__ import annotations from pathlib import Path import httpx import pytest from backup_tool.api.app import create_app from backup_tool.config import Settings from backup_tool.db.models import Execution 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" polled = await client.get(f"/api/v2/executions/{execution.json()['id']}", headers=headers) assert polled.status_code == 200 assert polled.json()["state"] == "queued" cancellation = await client.post( f"/api/v2/executions/{execution.json()['id']}/cancel", headers=headers ) assert cancellation.status_code == 202 assert cancellation.json()["state"] == "cancelled" 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 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 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_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