granthi-sync v1: granthi-link provisioning service + client daemon + tests

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Nirav Patel
2026-08-19 00:09:19 -04:00
co-authored by Claude Fable 5
commit 2077591755
13 changed files with 1286 additions and 0 deletions
Binary file not shown.
Binary file not shown.
+197
View File
@@ -0,0 +1,197 @@
"""Unit tests for the granthi-sync client: autocommit / ff / diverged logic,
config handling, device-flow polling (mocked HTTP). Stdlib unittest only."""
import json
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from unittest import mock
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "client"))
# Point client config at a temp home BEFORE import side effects.
_TMP_HOME = tempfile.mkdtemp(prefix="granthi-test-home-")
os.environ["GRANTHI_SYNC_HOME"] = _TMP_HOME
import granthi_sync_client as client # noqa: E402
GIT_ENV = {
"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t",
"HOME": _TMP_HOME, "PATH": os.environ["PATH"],
}
def run_git(cwd, *args):
return subprocess.run(["git", "-C", cwd] + list(args), check=True,
capture_output=True, text=True, env=GIT_ENV).stdout.strip()
class GitScenarioBase(unittest.TestCase):
"""bare 'cloud' repo + two working clones to simulate device vs remote."""
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix="granthi-test-")
self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True)
self.bare = os.path.join(self.tmp, "cloud.git")
subprocess.run(["git", "init", "--bare", "-b", "main", self.bare],
check=True, capture_output=True, env=GIT_ENV)
self.local = os.path.join(self.tmp, "local")
os.makedirs(self.local)
client.ensure_repo(self.local)
run_git(self.local, "remote", "add", "granthi", self.bare)
# git() in the client inherits our env via subprocess default; set
# identity locally in the repo so commits work.
run_git(self.local, "config", "user.name", "t")
run_git(self.local, "config", "user.email", "t@t")
def write(self, repo, name, content):
with open(os.path.join(repo, name), "w") as f:
f.write(content)
def other_clone(self):
other = os.path.join(self.tmp, "other")
subprocess.run(["git", "clone", self.bare, other], check=True,
capture_output=True, env=GIT_ENV)
run_git(other, "config", "user.name", "o")
run_git(other, "config", "user.email", "o@o")
return other
class TestAutocommit(GitScenarioBase):
def test_autocommit_commits_changes(self):
self.write(self.local, "a.txt", "one")
self.assertTrue(client.autocommit(self.local))
msg = run_git(self.local, "log", "-1", "--format=%s")
self.assertTrue(msg.startswith("sync: "), msg)
def test_autocommit_noop_when_clean(self):
self.write(self.local, "a.txt", "one")
client.autocommit(self.local)
self.assertFalse(client.autocommit(self.local))
class TestSyncFolder(GitScenarioBase):
def test_initial_push(self):
self.write(self.local, "a.txt", "one")
outcome, _ = client.sync_folder(self.local)
self.assertEqual(outcome, "pushed")
self.assertIn("a.txt", run_git(self.local, "ls-tree", "--name-only",
"granthi/main"))
def test_ff_pull_when_remote_ahead(self):
self.write(self.local, "a.txt", "one")
client.sync_folder(self.local)
other = self.other_clone()
self.write(other, "b.txt", "from-other")
run_git(other, "add", "-A")
run_git(other, "commit", "-m", "remote change")
run_git(other, "push", "origin", "main")
outcome, detail = client.sync_folder(self.local)
self.assertEqual((outcome, detail), ("synced", "ff-pulled"))
self.assertTrue(os.path.exists(os.path.join(self.local, "b.txt")))
def test_diverged_is_skipped_never_forced(self):
self.write(self.local, "a.txt", "one")
client.sync_folder(self.local)
other = self.other_clone()
self.write(other, "b.txt", "remote side")
run_git(other, "add", "-A")
run_git(other, "commit", "-m", "remote change")
run_git(other, "push", "origin", "main")
remote_sha = run_git(other, "rev-parse", "HEAD")
self.write(self.local, "a.txt", "local side") # divergence
outcome, _ = client.sync_folder(self.local)
self.assertEqual(outcome, "diverged")
# remote must be untouched (not forced, not merged)
bare_sha = run_git(self.bare, "rev-parse", "main")
self.assertEqual(bare_sha, remote_sha)
def test_clean_when_in_sync(self):
self.write(self.local, "a.txt", "one")
client.sync_folder(self.local)
outcome, _ = client.sync_folder(self.local)
self.assertEqual(outcome, "clean")
class TestConfig(unittest.TestCase):
def test_save_creates_0600(self):
client.save_config({"login": "x", "folders": {}})
st = os.stat(client.CONFIG_PATH)
self.assertEqual(st.st_mode & 0o777, 0o600)
self.assertEqual(client.load_config()["login"], "x")
def test_load_missing_returns_empty(self):
with mock.patch.object(client, "CONFIG_PATH", "/nonexistent/nope.json"):
self.assertEqual(client.load_config(), {})
class TestDeviceFlow(unittest.TestCase):
def test_device_flow_polls_until_token(self):
calls = []
def fake_http(method, url, headers=None, body=None, form=None, timeout=30):
calls.append(url)
if url.endswith("/device_authorization"):
return 200, {"device_code": "dc", "user_code": "AB-CD",
"verification_uri": "https://id/device",
"verification_uri_complete": "https://id/device?u=AB-CD",
"interval": 0, "expires_in": 300}
if len([c for c in calls if c.endswith("/token")]) < 3:
return 400, {"error": "authorization_pending"}
return 200, {"access_token": "ZTOK"}
with mock.patch.object(client, "http_json", fake_http), \
mock.patch.object(client.time, "sleep"):
tok = client.device_flow()
self.assertEqual(tok, "ZTOK")
self.assertEqual(len([c for c in calls if c.endswith("/token")]), 3)
def test_device_flow_slow_down_backs_off(self):
state = {"n": 0}
def fake_http(method, url, headers=None, body=None, form=None, timeout=30):
if url.endswith("/device_authorization"):
return 200, {"device_code": "dc", "user_code": "AB",
"verification_uri": "u", "interval": 1,
"expires_in": 300}
state["n"] += 1
if state["n"] == 1:
return 400, {"error": "slow_down"}
return 200, {"access_token": "T"}
sleeps = []
with mock.patch.object(client, "http_json", fake_http), \
mock.patch.object(client.time, "sleep", sleeps.append):
self.assertEqual(client.device_flow(), "T")
self.assertIn(6, sleeps) # 1 + 5 backoff after slow_down
class TestCredentialHelper(GitScenarioBase):
def test_helper_emits_creds_for_matching_host(self):
client.save_config({"gitea_base": "http://100.111.127.127:3041",
"login": "alice", "token": "sekrit", "folders": {}})
stdin = "protocol=http\nhost=100.111.127.127:3041\n\n"
out = subprocess.run(
[sys.executable, client.__file__, "git-credential", "get"],
input=stdin, capture_output=True, text=True,
env={**GIT_ENV, "GRANTHI_SYNC_HOME": _TMP_HOME})
self.assertIn("username=alice", out.stdout)
self.assertIn("password=sekrit", out.stdout)
def test_helper_silent_for_other_host(self):
client.save_config({"gitea_base": "http://100.111.127.127:3041",
"login": "alice", "token": "sekrit", "folders": {}})
out = subprocess.run(
[sys.executable, client.__file__, "git-credential", "get"],
input="protocol=https\nhost=github.com\n\n",
capture_output=True, text=True,
env={**GIT_ENV, "GRANTHI_SYNC_HOME": _TMP_HOME})
self.assertNotIn("password=", out.stdout)
if __name__ == "__main__":
unittest.main()
+186
View File
@@ -0,0 +1,186 @@
"""Unit tests for granthi-link: login derivation, link/repos flows against a
stub HTTP server that plays both Zitadel userinfo and the Gitea API."""
import json
import os
import sys
import threading
import unittest
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "server"))
import granthi_link # noqa: E402
class StubUpstream(BaseHTTPRequestHandler):
"""Plays Zitadel (/oidc/v1/userinfo) and Gitea (everything else)."""
state = None # dict injected per-test
def _json(self, status, obj):
payload = json.dumps(obj).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def do_GET(self):
st = self.state
if self.path == "/oidc/v1/userinfo":
auth = self.headers.get("Authorization", "")
if auth == "Bearer good-token":
return self._json(200, {"sub": "123", "preferred_username":
"[email protected]", "email":
"[email protected]", "name": "Alice"})
return self._json(401, {"error": "invalid token"})
if self.path.startswith("/api/v1/users/"):
login = self.path.rsplit("/", 1)[1]
if login in st["users"]:
return self._json(200, {"login": login})
return self._json(404, {"message": "not found"})
self._json(404, {})
def do_POST(self):
st = self.state
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length) or b"{}")
if self.path == "/api/v1/admin/users":
st["users"].add(body["username"])
st["created"].append(body)
return self._json(201, {"login": body["username"]})
if self.path.startswith("/api/v1/users/") and self.path.endswith("/tokens"):
# must arrive with basic auth + Sudo (the verified 1.27 mechanism)
st["token_reqs"].append({
"auth": self.headers.get("Authorization", ""),
"sudo": self.headers.get("Sudo", ""), "body": body})
if not self.headers.get("Authorization", "").startswith("Basic "):
return self._json(401, {"message": "auth required"})
return self._json(201, {"sha1": "MINTED", "name": body["name"]})
if self.path == "/api/v1/user/repos":
if body["name"] in st["repos"]:
return self._json(409, {"message": "exists"})
st["repos"].add(body["name"])
return self._json(201, {"name": body["name"], "private":
body.get("private"), "full_name":
f"alice/{body['name']}"})
self._json(404, {})
def log_message(self, *a):
pass
class ServiceTestBase(unittest.TestCase):
def setUp(self):
StubUpstream.state = {"users": set(), "created": [], "repos": set(),
"token_reqs": []}
self.upstream = ThreadingHTTPServer(("127.0.0.1", 0), StubUpstream)
threading.Thread(target=self.upstream.serve_forever, daemon=True).start()
self.addCleanup(self.upstream.shutdown)
base = f"http://127.0.0.1:{self.upstream.server_address[1]}"
self.svc = granthi_link.LinkService({
"gitea_base": base,
"public_gitea_base": "http://public.example:3041",
"zitadel_userinfo": f"{base}/oidc/v1/userinfo",
"admin_token": "ADMTOK", "admin_login": "root",
"admin_password": "rootpw", "test_mode": False,
})
class TestDeriveLogin(unittest.TestCase):
def test_strips_domain_and_sanitizes(self):
self.assertEqual(
granthi_link.LinkService.derive_login(
{"preferred_username": "[email protected]"}),
"alice.smith")
def test_email_fallback(self):
self.assertEqual(
granthi_link.LinkService.derive_login({"email": "[email protected]"}),
"bob-x")
def test_empty_returns_none(self):
self.assertIsNone(granthi_link.LinkService.derive_login({}))
class TestLink(ServiceTestBase):
def test_link_creates_user_and_mints_token(self):
status, resp = self.svc.link({"zitadel_access_token": "good-token",
"device_name": "mac studio"})
self.assertEqual(status, 200)
self.assertEqual(resp["login"], "alice.smith")
self.assertEqual(resp["token"], "MINTED")
self.assertEqual(resp["gitea_base"], "http://public.example:3041")
st = StubUpstream.state
self.assertEqual(len(st["created"]), 1)
created = st["created"][0]
self.assertFalse(created["must_change_password"])
self.assertEqual(created["visibility"], "private")
self.assertGreaterEqual(len(created["password"]), 30)
req = st["token_reqs"][0]
self.assertTrue(req["auth"].startswith("Basic "))
self.assertEqual(req["sudo"], "alice.smith")
self.assertEqual(sorted(req["body"]["scopes"]),
["write:repository", "write:user"])
def test_link_existing_user_skips_create(self):
StubUpstream.state["users"].add("alice.smith")
status, resp = self.svc.link({"zitadel_access_token": "good-token",
"device_name": "d"})
self.assertEqual(status, 200)
self.assertEqual(StubUpstream.state["created"], [])
def test_link_bad_token_401(self):
status, resp = self.svc.link({"zitadel_access_token": "bad",
"device_name": "d"})
self.assertEqual(status, 401)
def test_link_missing_token_400(self):
status, _ = self.svc.link({"device_name": "d"})
self.assertEqual(status, 400)
def test_test_mode_stub_only_when_enabled(self):
# disabled -> stub ignored, token required
status, _ = self.svc.link({"test_userinfo": {"sub": "1",
"preferred_username": "x"}})
self.assertEqual(status, 400)
self.svc.cfg["test_mode"] = True
status, resp = self.svc.link({"test_userinfo": {
"sub": "1", "preferred_username": "evetest"}, "device_name": "d"})
self.assertEqual(status, 200)
self.assertEqual(resp["login"], "evetest")
class TestRepos(ServiceTestBase):
def test_repo_create_returns_public_clone_url(self):
status, resp = self.svc.repos({"token": "USERTOK", "name": "notes",
"private": True})
self.assertEqual(status, 200)
self.assertEqual(resp["clone_url"],
"http://public.example:3041/alice/notes.git")
def test_repo_conflict_409(self):
self.svc.repos({"token": "T", "name": "notes"})
status, _ = self.svc.repos({"token": "T", "name": "notes"})
self.assertEqual(status, 409)
def test_missing_fields_400(self):
status, _ = self.svc.repos({"name": "x"})
self.assertEqual(status, 400)
class TestHealth(ServiceTestBase):
def test_health_endpoint(self):
granthi_link.Handler.service = self.svc
srv = ThreadingHTTPServer(("127.0.0.1", 0), granthi_link.Handler)
threading.Thread(target=srv.serve_forever, daemon=True).start()
self.addCleanup(srv.shutdown)
with urllib.request.urlopen(
f"http://127.0.0.1:{srv.server_address[1]}/health") as r:
body = json.loads(r.read())
self.assertEqual(body["status"], "ok")
self.assertEqual(body["service"], "granthi-link")
if __name__ == "__main__":
unittest.main()