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.
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
"""Disaster recovery, retention, GC, adopt, metrics, verified restores."""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from versiond import dbsafe
|
||||
from versiond.api import create_app
|
||||
from versiond.config import Config, Paths
|
||||
from versiond.store import BlobStore
|
||||
|
||||
from test_service import CONFIG, history, wait_for
|
||||
from test_purge import FakeDAV, REMOTE
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rec_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")
|
||||
conn = sqlite3.connect(project / "app.db")
|
||||
conn.execute("CREATE TABLE t(id INTEGER PRIMARY KEY, v TEXT)")
|
||||
conn.execute("INSERT INTO t(v) VALUES ('one')")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
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, cfg
|
||||
|
||||
|
||||
def _configure_and_fill(client, project, files=2):
|
||||
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"] == files)(
|
||||
client.get("/api/v1/progress").json()), timeout=15)
|
||||
return directory
|
||||
|
||||
|
||||
def _db_index(cfg):
|
||||
return sqlite3.connect(cfg.paths.index_file, timeout=5.0)
|
||||
|
||||
|
||||
def test_reindex_rebuilds_lost_index(rec_env):
|
||||
client, dav, project, cfg = rec_env
|
||||
directory = _configure_and_fill(client, project)
|
||||
before = {v["id"]: v for v in history(client, project / "main.py")}
|
||||
assert before
|
||||
|
||||
# Simulate total local loss of metadata (spool stays, like a surviving disk).
|
||||
conn = _db_index(cfg)
|
||||
conn.execute("DELETE FROM versions")
|
||||
conn.execute("DELETE FROM files")
|
||||
conn.execute("DELETE FROM blobs")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
assert history(client, project / "main.py") == []
|
||||
|
||||
r = client.post("/api/v1/admin/reindex")
|
||||
assert r.status_code == 202, r.text
|
||||
task_id = r.json()["task_id"]
|
||||
status = wait_for(lambda: (lambda s: s if s["status"] == "done" else None)(
|
||||
client.get(f"/api/v1/admin/reindex/{task_id}").json()), timeout=15)
|
||||
assert status["versions"] >= 2 and status["files"] == 2
|
||||
|
||||
after = history(client, project / "main.py")
|
||||
assert len(after) == len(before)
|
||||
assert after[0]["durability"] == "durable"
|
||||
assert client.get(f"/api/v1/versions/{after[0]['id']}/content").text == "print('hello')\n"
|
||||
# Restore works end to end from the rebuilt index.
|
||||
(project / "main.py").write_text("garbage\n")
|
||||
vid = after[0]["id"]
|
||||
out = str(project / "restored.py")
|
||||
assert client.post(f"/api/v1/versions/{vid}/restore", json={"target_path": out}).status_code == 200
|
||||
assert open(out).read() == "print('hello')\n"
|
||||
|
||||
|
||||
def test_reindex_fetches_blobs_from_remote(rec_env):
|
||||
"""True disaster: index AND spool gone; content streams back on demand."""
|
||||
client, dav, project, cfg = rec_env
|
||||
_configure_and_fill(client, project)
|
||||
conn = _db_index(cfg)
|
||||
conn.execute("DELETE FROM versions")
|
||||
conn.execute("DELETE FROM files")
|
||||
conn.execute("DELETE FROM blobs")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
for p in (cfg.paths.spool_dir).rglob("*"):
|
||||
if p.is_file():
|
||||
p.unlink()
|
||||
task_id = client.post("/api/v1/admin/reindex").json()["task_id"]
|
||||
wait_for(lambda: (lambda s: s if s["status"] == "done" else None)(
|
||||
client.get(f"/api/v1/admin/reindex/{task_id}").json()), timeout=15)
|
||||
after = history(client, project / "main.py")
|
||||
assert after
|
||||
assert client.get(f"/api/v1/versions/{after[0]['id']}/content").text == "print('hello')\n"
|
||||
|
||||
|
||||
def test_adopt_takes_over_directory(rec_env):
|
||||
client, dav, project, cfg = rec_env
|
||||
directory = _configure_and_fill(client, project)
|
||||
# A fresh machine against the same server adopts the existing directory.
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
home2 = Path(tempfile.mkdtemp())
|
||||
(home2 / "config").mkdir()
|
||||
(home2 / "config" / "config.toml").write_text(CONFIG)
|
||||
cfg2 = Config.load(Paths(config_dir=home2 / "config", data_dir=home2 / "data", cache_dir=home2 / "cache"))
|
||||
app2 = create_app(cfg2, remote_transport=httpx.MockTransport(dav.handler))
|
||||
with TestClient(app2, base_url="http://127.0.0.1:9922") as c2:
|
||||
c2.headers["Authorization"] = f"Bearer {cfg2.api_token()}"
|
||||
r = c2.post("/api/v1/config/remote/adopt",
|
||||
json={**REMOTE, "password": "secret", "directory": directory})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["directory"] == directory and r.json()["adopted"] is True
|
||||
|
||||
|
||||
def test_retention_thins_old_keeps_five_days(rec_env):
|
||||
client, dav, project, cfg = rec_env
|
||||
_configure_and_fill(client, project)
|
||||
for i in range(4):
|
||||
(project / "main.py").write_text(f"v{i}\n")
|
||||
time.sleep(0.5) # outside the 0.2s coalescing window: each is a version
|
||||
wait_for(lambda: len(history(client, project / "main.py")) == 5, timeout=10)
|
||||
now = time.time()
|
||||
conn = _db_index(cfg)
|
||||
# Age 3 versions 10 days; pin the oldest; keep the newest fresh.
|
||||
vids = [v["id"] for v in reversed(history(client, project / "main.py"))]
|
||||
for vid in vids[:3]:
|
||||
conn.execute("UPDATE versions SET captured_at = ? WHERE id = ?", (now - 10 * 86400, vid))
|
||||
conn.execute("UPDATE versions SET pinned = 1 WHERE id = ?", (vids[0],))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
dry = client.post("/api/v1/admin/retention/run", json={"dry_run": True}).json()
|
||||
assert dry["dry_run"] and dry["versions_to_delete"] == 1 # 3 old minus pinned minus daily-kept
|
||||
done = client.post("/api/v1/admin/retention/run", json={"dry_run": False}).json()
|
||||
assert done["versions_deleted"] == 1
|
||||
remaining = history(client, project / "main.py")
|
||||
assert len(remaining) == 4 # latest + pinned + one-per-day + fresh middle
|
||||
assert remaining[0]["pinned"] == 0 # newest still the newest
|
||||
assert any(v["pinned"] == 1 for v in remaining)
|
||||
|
||||
|
||||
def test_gc_reclaims_orphans_local_and_remote(rec_env):
|
||||
client, dav, project, cfg = rec_env
|
||||
directory = _configure_and_fill(client, project)
|
||||
(project / "main.py").write_text("v2\n")
|
||||
wait_for(lambda: len(history(client, project / "main.py")) == 2, timeout=10)
|
||||
conn = _db_index(cfg)
|
||||
conn.execute("DELETE FROM versions WHERE id = (SELECT max(id) FROM versions)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
dry = client.post("/api/v1/admin/gc", json={"dry_run": True}).json()
|
||||
assert dry["blobs"] >= 1
|
||||
done = client.post("/api/v1/admin/gc", json={"dry_run": False}).json()
|
||||
assert done["blobs_removed"] >= 1 and done["errors"] == []
|
||||
assert [p for p in dav.files if p.startswith(directory + "/blobs/")] != [] # referenced stay
|
||||
# Remaining history still restorable.
|
||||
assert client.get(f"/api/v1/versions/{history(client, project / 'main.py')[0]['id']}/content").status_code == 200
|
||||
|
||||
|
||||
def test_metrics_and_db_restore_guard(rec_env):
|
||||
client, dav, project, cfg = rec_env
|
||||
_configure_and_fill(client, project)
|
||||
m = client.get("/api/v1/metrics")
|
||||
assert m.status_code == 200 and "versiond_files_tracked" in m.text
|
||||
|
||||
db_hist = history(client, project / "app.db")
|
||||
assert db_hist
|
||||
vid = db_hist[0]["id"]
|
||||
row = _db_index(cfg).execute(
|
||||
"SELECT blob_sha256 FROM versions WHERE id = ?", (vid,)).fetchone()
|
||||
blob_path = None
|
||||
for cand in BlobStore(cfg.paths.spool_dir)._candidates(row[0]):
|
||||
if cand.exists():
|
||||
blob_path = cand
|
||||
assert blob_path is not None
|
||||
blob_path.write_bytes(dbsafe.SQLITE_MAGIC + b"\x00" * 200) # corrupt the stored bytes
|
||||
r = client.post(f"/api/v1/versions/{vid}/restore",
|
||||
json={"target_path": str(project / "evil.db")})
|
||||
assert r.status_code == 422 and "unrestorable" in r.text
|
||||
Reference in New Issue
Block a user