- WebDAV uploader: concurrency, request and bandwidth limits, retry with backoff, offline catch-up, encrypted manifests, durability tracking - Automatic unique remote directory claimed with a conditional PUT - AES-256-GCM client-side encryption with keyed blob names - /stats, /progress, /progress/stream and a /dashboard page - Own ctypes inotify binding with a compact watch tree (263 MB -> 76 MB RSS) - Directory-path ignore patterns and a forget command - Indexed range queries instead of LIKE for path prefixes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
189 lines
7.7 KiB
Python
189 lines
7.7 KiB
Python
"""End-to-end: real inotify monitor driven through the HTTP API."""
|
|
|
|
import base64
|
|
import os
|
|
import time
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from versiond.api import create_app
|
|
from versiond.config import Config, Paths
|
|
|
|
CONFIG = """
|
|
[coalesce]
|
|
window_seconds = 0.2
|
|
max_hold_seconds = 1.0
|
|
versions_per_hour_per_path = 1000
|
|
|
|
[monitor]
|
|
poll_interval_seconds = 0.5
|
|
"""
|
|
|
|
|
|
def wait_for(predicate, timeout=5.0):
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
result = predicate()
|
|
if result:
|
|
return result
|
|
time.sleep(0.05)
|
|
raise AssertionError("condition not met in time")
|
|
|
|
|
|
@pytest.fixture
|
|
def env(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("VERSIOND_HOME", str(tmp_path / "home"))
|
|
paths = Paths.resolve()
|
|
paths.ensure()
|
|
paths.config_file.write_text(CONFIG)
|
|
cfg = Config.load(paths)
|
|
project = tmp_path / "work" / "proj"
|
|
(project / "src").mkdir(parents=True)
|
|
(project / "pyproject.toml").write_text("[project]\nname='x'\n")
|
|
(project / "src" / "app.py").write_text("print('v1')\n")
|
|
(project / "node_modules" / "dep").mkdir(parents=True)
|
|
(project / "node_modules" / "dep" / "index.js").write_text("x\n")
|
|
(project / ".venv" / "lib").mkdir(parents=True)
|
|
with TestClient(create_app(cfg), base_url="http://127.0.0.1:9922") as client:
|
|
client.headers["Authorization"] = f"Bearer {cfg.api_token()}"
|
|
yield client, project
|
|
|
|
|
|
def history(client, path):
|
|
r = client.get("/api/v1/files/history", params={"path": str(path)})
|
|
return r.json()["versions"] if r.status_code == 200 else []
|
|
|
|
|
|
def test_security(env):
|
|
client, _ = env
|
|
assert client.get("/api/v1/roots", headers={"Authorization": "Bearer nope"}).status_code == 401
|
|
bad_host = client.get("/health", headers={"Host": "evil.example:9922"})
|
|
assert bad_host.status_code == 421
|
|
assert client.get("/openapi.json").status_code == 200
|
|
|
|
|
|
def test_monitoring_end_to_end(env):
|
|
client, project = env
|
|
app_py = project / "src" / "app.py"
|
|
|
|
r = client.post("/api/v1/roots", json={"path": str(project.parent)})
|
|
assert r.status_code == 201, r.text
|
|
root = r.json()
|
|
# node_modules and .venv are pruned before watches are added
|
|
assert root["watch_count"] == 3 # work, proj, src
|
|
wait_for(lambda: client.get(f"/api/v1/roots/{root['id']}").json()["baseline_state"] == "done")
|
|
assert len(history(client, app_py)) == 1
|
|
assert history(client, project / "node_modules" / "dep" / "index.js") == []
|
|
|
|
# modification is captured
|
|
app_py.write_text("print('v2')\n")
|
|
wait_for(lambda: len(history(client, app_py)) == 2)
|
|
|
|
# project detection found pyproject.toml
|
|
projects = client.get("/api/v1/projects").json()
|
|
assert any(p["path"] == str(project) and p["detected_by"] == "python" for p in projects)
|
|
|
|
# new directory: watched, and files inside captured
|
|
(project / "pkg").mkdir()
|
|
(project / "pkg" / "mod.py").write_text("x = 1\n")
|
|
wait_for(lambda: len(history(client, project / "pkg" / "mod.py")) == 1)
|
|
(project / "pkg" / "mod.py").write_text("x = 2\n")
|
|
wait_for(lambda: len(history(client, project / "pkg" / "mod.py")) == 2)
|
|
|
|
# atomic save (write temp + rename) is captured, temp file is not
|
|
tmp = project / "src" / "app.py.tmp"
|
|
tmp.write_text("print('v3')\n")
|
|
os.replace(tmp, app_py)
|
|
wait_for(lambda: len(history(client, app_py)) == 3)
|
|
|
|
# rename keeps history
|
|
renamed = project / "src" / "main.py"
|
|
os.rename(app_py, renamed)
|
|
wait_for(lambda: len(history(client, renamed)) == 3)
|
|
|
|
# directory rename moves history of everything inside
|
|
os.rename(project / "pkg", project / "lib")
|
|
wait_for(lambda: len(history(client, project / "lib" / "mod.py")) == 2)
|
|
(project / "lib" / "mod.py").write_text("x = 3\n")
|
|
wait_for(lambda: len(history(client, project / "lib" / "mod.py")) == 3)
|
|
|
|
# delete is recorded, history kept
|
|
os.unlink(renamed)
|
|
wait_for(lambda: client.get("/api/v1/files/history", params={"path": str(renamed)}).json()["file"]["exists_on_disk"] == 0)
|
|
|
|
# diff between versions
|
|
versions = history(client, project / "lib" / "mod.py")
|
|
diff = client.get("/api/v1/diff", params={"from": versions[-1]["id"], "to": str(versions[0]["id"])})
|
|
assert "-x = 1" in diff.text and "+x = 3" in diff.text
|
|
|
|
|
|
def test_restore_plan_and_execute(env):
|
|
client, project = env
|
|
app_py = project / "src" / "app.py"
|
|
root = client.post("/api/v1/roots", json={"path": str(project)}).json()
|
|
wait_for(lambda: client.get(f"/api/v1/roots/{root['id']}").json()["baseline_state"] == "done")
|
|
time.sleep(0.05)
|
|
checkpoint = time.time()
|
|
time.sleep(0.05)
|
|
app_py.write_text("broken by agent\n")
|
|
wait_for(lambda: len(history(client, app_py)) == 2)
|
|
|
|
plan = client.post("/api/v1/restores", json={"globs": [str(project / "**")], "as_of": checkpoint})
|
|
assert plan.status_code == 201, plan.text
|
|
plan = plan.json()
|
|
actions = {a["path"]: a["action"] for a in plan["actions"]}
|
|
assert actions[str(app_py)] == "overwrite"
|
|
assert app_py.read_text() == "broken by agent\n" # dry run changed nothing
|
|
|
|
result = client.post(f"/api/v1/restores/{plan['plan_id']}/execute").json()
|
|
assert app_py.read_text() == "print('v1')\n"
|
|
assert any(r["result"] == "restored" for r in result["results"])
|
|
# executing twice is refused
|
|
assert client.post(f"/api/v1/restores/{plan['plan_id']}/execute").status_code == 409
|
|
# the broken state is still in history (pre-restore), and the monitor did not add a duplicate
|
|
time.sleep(0.5)
|
|
contents = [client.get(f"/api/v1/versions/{v['id']}/content").text for v in history(client, app_py)]
|
|
assert contents[0] == "print('v1')\n"
|
|
assert "broken by agent\n" in contents
|
|
|
|
|
|
def test_push_snapshots_and_limits(env):
|
|
client, project = env
|
|
path = project / "src" / "pushed.py"
|
|
body = {"path": str(path), "content_b64": base64.b64encode(b"a = 1\n").decode(), "source": "claude-agent"}
|
|
assert client.post("/api/v1/snapshots", json=body).json()["status"] == "committed"
|
|
assert client.post("/api/v1/snapshots", json=body).json()["status"] == "unchanged"
|
|
|
|
big = {**body, "content_b64": base64.b64encode(b"x" * 204_801).decode()}
|
|
r = client.post("/api/v1/snapshots", json=big)
|
|
assert r.status_code == 413 and r.headers["content-type"] == "application/problem+json"
|
|
|
|
ignored = {**body, "path": str(project / "node_modules" / "x.js")}
|
|
assert client.post("/api/v1/snapshots", json=ignored).status_code == 422
|
|
|
|
|
|
def test_restore_refuses_unsafe_paths(env):
|
|
client, project = env
|
|
client.post("/api/v1/roots", json={"path": str(project)})
|
|
path = project / "src" / "app.py"
|
|
wait_for(lambda: history(client, path))
|
|
vid = history(client, path)[0]["id"]
|
|
r = client.post(f"/api/v1/versions/{vid}/restore", json={"target_path": "/etc/versiond-test"})
|
|
assert r.status_code == 400 and "unsafe" in r.text
|
|
os.symlink("/etc", project / "escape")
|
|
r = client.post(f"/api/v1/versions/{vid}/restore", json={"target_path": str(project / "escape" / "x")})
|
|
assert r.status_code == 400
|
|
|
|
|
|
def test_forget(env):
|
|
client, project = env
|
|
root = client.post("/api/v1/roots", json={"path": str(project)}).json()
|
|
wait_for(lambda: client.get(f"/api/v1/roots/{root['id']}").json()["baseline_state"] == "done")
|
|
dry = client.post("/api/v1/forget", json={"path": str(project / "src")}).json()
|
|
assert dry["dry_run"] and dry["files"] == 1 and dry["versions"] == 1
|
|
done = client.post("/api/v1/forget", json={"path": str(project / "src"), "dry_run": False}).json()
|
|
assert done["files_removed"] == 1 and done["blobs_removed"] == 1
|
|
assert history(client, project / "src" / "app.py") == []
|
|
assert history(client, project / "pyproject.toml") # outside the forgotten tree
|