"""Purge-remote: date-guarded, async, remote-only deletion of this system's backup.""" import base64 import json import time from datetime import datetime import httpx import pytest from fastapi.testclient import TestClient from versiond.api import create_app from versiond.config import Config, Paths from test_service import CONFIG, history, wait_for class FakeDAV: """In-memory WebDAV with PROPFIND Depth:0/1 (XML) and recursive DELETE.""" def __init__(self, user="u1", password="secret"): self.auth = "Basic " + base64.b64encode(f"{user}:{password}".encode()).decode() self.dirs = {"/"} self.files: dict[str, bytes] = {} self.down = False self.capacity_bytes = 10 ** 12 # tiny servers set this low to simulate pressure def _usage(self, path): used = sum(len(v) for k, v in self.files.items() if k == path or k.startswith(path + "/")) return used, max(0, self.capacity_bytes - used) @staticmethod def parent(path): return path.rsplit("/", 1)[0] or "/" def _children(self, path): kids = [] for d in sorted(self.dirs): if d != path and self.parent(d) == path: kids.append((d, True)) for f in sorted(self.files): if self.parent(f) == path: kids.append((f, False)) return kids def _multistatus(self, path): parts = [''] parts.append(f"{path}" "" "HTTP/1.1 200 OK") for child, is_dir in self._children(path): rtype = "" if is_dir else "" size = "" if is_dir else f"{len(self.files[child])}" parts.append(f"{child}" f"{rtype}{size}HTTP/1.1 200 OK" "") parts.append("") return "".join(parts).encode() def handler(self, request: httpx.Request) -> httpx.Response: if self.down: raise httpx.ConnectError("connection refused", request=request) if request.headers.get("authorization") != self.auth: return httpx.Response(401) path = request.url.path.rstrip("/") or "/" method = request.method if method == "PROPFIND": if path in self.files: body = (f'' f"{path}" f"{len(self.files[path])}" "HTTP/1.1 200 OK" "").encode() return httpx.Response(207, content=body) if path not in self.dirs: return httpx.Response(404) if request.headers.get("depth", "0") == "1": return httpx.Response(207, content=self._multistatus(path), headers={"Content-Type": "application/xml"}) used, available = self._usage(path) body = (f'' f"{path}" f"{used}" f"{available}" "HTTP/1.1 200 OK" "").encode() return httpx.Response(207, content=body) if method == "MKCOL": if path in self.dirs: return httpx.Response(405) if self.parent(path) not in self.dirs: return httpx.Response(409) self.dirs.add(path) return httpx.Response(201) if method == "PUT": if self.parent(path) not in self.dirs: return httpx.Response(409) if request.headers.get("if-none-match") == "*" and path in self.files: return httpx.Response(412) self.files[path] = request.read() return httpx.Response(201) if method == "GET": return httpx.Response(200, content=self.files[path]) if path in self.files else httpx.Response(404) if method == "DELETE": for f in [f for f in self.files if f == path or f.startswith(path + "/")]: del self.files[f] for d in [d for d in self.dirs if d != "/" and (d == path or d.startswith(path + "/"))]: self.dirs.discard(d) return httpx.Response(204) return httpx.Response(405) @pytest.fixture def purge_env(tmp_path, monkeypatch): monkeypatch.setenv("VERSIOND_HOME", str(tmp_path / "home")) paths = Paths.resolve() paths.ensure() paths.config_file.write_text(CONFIG + "\n[upload]\nmanifest_interval_seconds = 0.2\n") cfg = Config.load(paths) dav = FakeDAV() project = tmp_path / "proj" project.mkdir() (project / "main.py").write_text("print('hello')\n") app = create_app(cfg, remote_transport=httpx.MockTransport(dav.handler)) with TestClient(app, base_url="http://127.0.0.1:9922") as client: client.headers["Authorization"] = f"Bearer {cfg.api_token()}" app.state.services.uploader.offline_sleep = 0.3 yield client, dav, project REMOTE = {"url": "https://dav.example", "username": "u1", "password": "secret", "base_path": "/versioned/"} def _configure_and_fill(client, project): r = client.put("/api/v1/config/remote", json=REMOTE) assert r.status_code == 200, r.text directory = r.json()["directory"] client.post("/api/v1/roots", json={"path": str(project)}) wait_for(lambda: (lambda p: p["upload"]["versions_local"] == 0 and p["upload"]["versions_durable"] >= 1)( client.get("/api/v1/progress").json()), timeout=10) return directory def test_purge_rejects_bad_date(purge_env): client, _, project = purge_env _configure_and_fill(client, project) r = client.post("/api/v1/admin/purge-remote", json={"today": "01-01-2000"}) assert r.status_code == 400, r.text r = client.post("/api/v1/admin/purge-remote", json={"today": "today"}) assert r.status_code == 400, r.text def test_purge_unconfigured_is_409(tmp_path, monkeypatch): monkeypatch.setenv("VERSIOND_HOME", str(tmp_path / "home2")) paths = Paths.resolve() paths.ensure() paths.config_file.write_text(CONFIG) cfg = Config.load(paths) app = create_app(cfg) with TestClient(app, base_url="http://127.0.0.1:9922") as client: client.headers["Authorization"] = f"Bearer {cfg.api_token()}" today = datetime.now().strftime("%d-%m-%Y") r = client.post("/api/v1/admin/purge-remote", json={"today": today}) assert r.status_code == 409, r.text def test_purge_deletes_only_this_system(purge_env): client, dav, project = purge_env directory = _configure_and_fill(client, project) before = [p for p in dav.files if p.startswith(directory + "/blobs/") or p.startswith(directory + "/manifests/")] assert before, "expected uploaded blobs/manifests before purge" today = datetime.now().strftime("%d-%m-%Y") expected_sizes = {p: len(dav.files[p]) for p in before} r = client.post("/api/v1/admin/purge-remote", json={"today": today}) assert r.status_code == 202, r.text body = r.json() assert body["directory"] == directory assert body["files"] == len(before) and body["files"] > 0 assert body["bytes"] == sum(expected_sizes.values()) task_id = body["task_id"] def done(): s = client.get(f"/api/v1/admin/purge-remote/{task_id}").json() return s if s["status"] == "done" else None status = wait_for(done, timeout=10) assert status["files_deleted"] == body["files"] assert status["errors"] == [] assert [p for p in dav.files if p.startswith(directory + "/blobs/")] == [] assert [p for p in dav.files if p.startswith(directory + "/manifests/")] == [] # meta (claim) is kept; local history is untouched assert directory + "/meta/owner.json" in dav.files assert history(client, project / "main.py"), "local index must be untouched" assert client.get(f"/api/v1/admin/purge-remote/nope").status_code == 404