Files
versioning/tests/test_purge.py
T

199 lines
8.7 KiB
Python
Raw Normal View History

"""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 = ['<?xml version="1.0"?><d:multistatus xmlns:d="DAV:">']
parts.append(f"<d:response><d:href>{path}</d:href><d:propstat><d:prop>"
"<d:resourcetype><d:collection/></d:resourcetype>"
"</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>")
for child, is_dir in self._children(path):
rtype = "<d:resourcetype><d:collection/></d:resourcetype>" if is_dir else "<d:resourcetype/>"
size = "" if is_dir else f"<d:getcontentlength>{len(self.files[child])}</d:getcontentlength>"
parts.append(f"<d:response><d:href>{child}</d:href><d:propstat><d:prop>"
f"{rtype}{size}</d:prop><d:status>HTTP/1.1 200 OK</d:status>"
"</d:propstat></d:response>")
parts.append("</d:multistatus>")
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'<?xml version="1.0"?><d:multistatus xmlns:d="DAV:">'
f"<d:response><d:href>{path}</d:href><d:propstat><d:prop>"
f"<d:resourcetype/><d:getcontentlength>{len(self.files[path])}</d:getcontentlength>"
"</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>"
"</d:multistatus>").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'<?xml version="1.0"?><d:multistatus xmlns:d="DAV:">'
f"<d:response><d:href>{path}</d:href><d:propstat><d:prop>"
f"<d:quota-used-bytes>{used}</d:quota-used-bytes>"
f"<d:quota-available-bytes>{available}</d:quota-available-bytes>"
"</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>"
"</d:multistatus>").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