"""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