granthi-sync v1: granthi-link provisioning service + client daemon + tests
Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user