Files
versioning/tests/test_service.py
T
retoor 3499f6cda0 Remove encryption; user-data filters; SQLite safety; recovery, retention, scheduler
- No client-side encryption: plaintext content-addressed remote (SHA-256
  names), no backup key, no cryptography dependency (server disk encryption
  is the trust model).
- Filters back user data: images, archives, databases, PDFs accepted; 10 MiB
  cap; binary sniffing removed; temp names hardened (~$, #..#, .temp).
- SQLite zero-error policy: backup-API snapshots + integrity_check, journal
  folding, locked/corrupt loud skips, verified restores.
- Recovery: reindex from manifests, remote adopt, on-demand blob fetch,
  5-day retention + thinning, GC, date-guarded remote purge, metrics.
- Scheduler with WebDAV quota signal and 70% pressure backstop (floor kept).
- 37 tests incl. live-monitor capture safety and DB safety.
2026-10-10 03:41:36 +02:00

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" * 10_485_761).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