From f773a1b77133ed87e4bc13a8b9bea03e64f9dbdd Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Mon, 27 Jul 2026 21:27:32 +0200 Subject: [PATCH] test(v2): define local source and job contracts --- tests/integration/test_sources_jobs.py | 90 ++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 tests/integration/test_sources_jobs.py diff --git a/tests/integration/test_sources_jobs.py b/tests/integration/test_sources_jobs.py new file mode 100644 index 0000000..742e8b4 --- /dev/null +++ b/tests/integration/test_sources_jobs.py @@ -0,0 +1,90 @@ +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 + +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": response.json()["csrf_token"]} + + +@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") + settings = Settings( + data_dir=tmp_path / "data", + database_url=f"sqlite+aiosqlite:///{tmp_path / 'data' / 'db.sqlite'}", + repository_roots=(tmp_path / "repositories",), + local_source_roots=(source_root,), + restore_roots=(tmp_path / "restore",), + master_key_file=tmp_path / "master.key", + ) + settings.master_key_file.write_bytes(b"x" * 32) + settings.master_key_file.chmod(0o600) + for root in (*settings.repository_roots, *settings.restore_roots): + root.mkdir(parents=True) + app = create_app(settings) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://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() + 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() + key = tmp_path / "master.key" + key.write_bytes(b"x" * 32) + key.chmod(0o600) + settings = Settings( + data_dir=tmp_path / "data", + database_url=f"sqlite+aiosqlite:///{tmp_path / 'data' / 'db.sqlite'}", + repository_roots=(tmp_path / "repositories",), + local_source_roots=(allowed,), + restore_roots=(tmp_path / "restore",), + master_key_file=key, + ) + settings.repository_roots[0].mkdir() + settings.restore_roots[0].mkdir() + app = create_app(settings) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://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