Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
835d6705d6 | ||
|
|
7eb57acdbe | ||
|
|
ea14038355 |
@@ -80,6 +80,9 @@ TEST_MODE_ENV = "GRANTHI_LINK_ALLOW_TEST_MODE"
|
|||||||
|
|
||||||
# Sentinel: create_user hit a 409 (someone else created the login first).
|
# Sentinel: create_user hit a 409 (someone else created the login first).
|
||||||
USER_CREATE_CONFLICT = object()
|
USER_CREATE_CONFLICT = object()
|
||||||
|
# Sentinel: the address already belongs to another forge account, which is a
|
||||||
|
# 409 the caller can act on -- not a 502 that reads like the service is down.
|
||||||
|
EMAIL_IN_USE = object()
|
||||||
|
|
||||||
LOGIN_SAFE = re.compile(r"[^a-zA-Z0-9._-]+")
|
LOGIN_SAFE = re.compile(r"[^a-zA-Z0-9._-]+")
|
||||||
|
|
||||||
@@ -292,6 +295,14 @@ def check_config_perms(path, euid=None):
|
|||||||
# Identity map: zitadel sub -> gitea login (JSON, 0600, atomic writes)
|
# Identity map: zitadel sub -> gitea login (JSON, 0600, atomic writes)
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _email_in_use_message(login, userinfo):
|
||||||
|
email = userinfo.get("email") or "your address"
|
||||||
|
return (f"cannot create the account '{login}': {email} already belongs to "
|
||||||
|
f"a different account on this forge. If that other account is "
|
||||||
|
f"yours, an operator must bind your identity to it; if it is not, "
|
||||||
|
f"use a different address.")
|
||||||
|
|
||||||
|
|
||||||
class IdentityStore:
|
class IdentityStore:
|
||||||
"""Persistent map of Zitadel `sub` -> Gitea login binding records.
|
"""Persistent map of Zitadel `sub` -> Gitea login binding records.
|
||||||
|
|
||||||
@@ -677,6 +688,14 @@ class LinkService:
|
|||||||
headers=self._admin_hdr(), body=body)
|
headers=self._admin_hdr(), body=body)
|
||||||
if status == 409:
|
if status == 409:
|
||||||
return USER_CREATE_CONFLICT
|
return USER_CREATE_CONFLICT
|
||||||
|
if status == 422 and "e-mail already in use" in str(resp).lower():
|
||||||
|
# The address belongs to a DIFFERENT forge account. Reported as its
|
||||||
|
# own case because the generic path turns it into a bare 502, and
|
||||||
|
# "502" sends the person looking for an outage when the real answer
|
||||||
|
# is "that address is already somebody's account here". Hit live on
|
||||||
|
# 2026-08-23: an operator moved an email onto another account and
|
||||||
|
# the next sign-in failed with nothing but the number.
|
||||||
|
return EMAIL_IN_USE
|
||||||
if status != 201:
|
if status != 201:
|
||||||
return f"gitea admin user create failed (HTTP {status}): {resp}"
|
return f"gitea admin user create failed (HTTP {status}): {resp}"
|
||||||
return None
|
return None
|
||||||
@@ -733,6 +752,8 @@ class LinkService:
|
|||||||
"was not created by this service; refusing "
|
"was not created by this service; refusing "
|
||||||
"to re-create"}, None
|
"to re-create"}, None
|
||||||
err = self.create_user(login, userinfo)
|
err = self.create_user(login, userinfo)
|
||||||
|
if err is EMAIL_IN_USE:
|
||||||
|
return 409, {"error": _email_in_use_message(login, userinfo)}, None
|
||||||
if err and err is not USER_CREATE_CONFLICT:
|
if err and err is not USER_CREATE_CONFLICT:
|
||||||
return 502, {"error": err}, None
|
return 502, {"error": err}, None
|
||||||
LOG.info("re-created service-managed gitea user %s", login)
|
LOG.info("re-created service-managed gitea user %s", login)
|
||||||
@@ -758,6 +779,8 @@ class LinkService:
|
|||||||
return 409, {"error": "login exists and is not linked "
|
return 409, {"error": "login exists and is not linked "
|
||||||
"to this identity"}, None
|
"to this identity"}, None
|
||||||
LOG.info("user %s created concurrently; continuing", login)
|
LOG.info("user %s created concurrently; continuing", login)
|
||||||
|
elif err is EMAIL_IN_USE:
|
||||||
|
return 409, {"error": _email_in_use_message(login, userinfo)}, None
|
||||||
elif err:
|
elif err:
|
||||||
return 502, {"error": err}, None
|
return 502, {"error": err}, None
|
||||||
else:
|
else:
|
||||||
|
|||||||
+12
-12
@@ -29,9 +29,11 @@ GIT_ENV = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def run_git(cwd, *args):
|
def run_git(cwd, *args, strip=True):
|
||||||
return subprocess.run(["git", "-C", cwd] + list(args), check=True,
|
stdout = subprocess.run(["git", "-C", cwd] + list(args), check=True,
|
||||||
capture_output=True, text=True, env=GIT_ENV).stdout.strip()
|
capture_output=True, text=True,
|
||||||
|
env=GIT_ENV).stdout
|
||||||
|
return stdout.strip() if strip else stdout
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -905,15 +907,13 @@ class TestCredentialHelperIsolation(GitScenarioBase):
|
|||||||
def test_install_leaves_exactly_one_helper(self):
|
def test_install_leaves_exactly_one_helper(self):
|
||||||
run_git(self.local, "config", "--add", "credential.helper", "store")
|
run_git(self.local, "config", "--add", "credential.helper", "store")
|
||||||
client.install_credential_helper(self.local)
|
client.install_credential_helper(self.local)
|
||||||
# --get-all merges system + global + local, so entries inherited from
|
# Inspect the repo-local list so this assertion is deterministic even
|
||||||
# the machine still appear. What matters is that the last two are the
|
# when the machine has no inherited helper. Keep the leading newline:
|
||||||
# reset and ours: git reads an empty value as "forget every helper
|
# it represents the empty reset value, not disposable whitespace.
|
||||||
# inherited so far", so nothing before it can answer.
|
helpers = run_git(self.local, "config", "--local", "--get-all",
|
||||||
helpers = run_git(self.local, "config", "--get-all",
|
"credential.helper", strip=False).splitlines()
|
||||||
"credential.helper").splitlines()
|
self.assertEqual(helpers, ["", client.credential_helper_value()])
|
||||||
self.assertEqual(helpers[-2], "", helpers)
|
# The repo-level 'store' this test added is gone, not merely outvoted.
|
||||||
self.assertIn("git-credential", helpers[-1])
|
|
||||||
# the repo-level 'store' this test added is gone, not merely outvoted
|
|
||||||
self.assertNotIn("store", helpers)
|
self.assertNotIn("store", helpers)
|
||||||
|
|
||||||
def test_inherited_helper_cannot_answer_for_the_forge(self):
|
def test_inherited_helper_cannot_answer_for_the_forge(self):
|
||||||
|
|||||||
@@ -77,6 +77,10 @@ class StubUpstream(BaseHTTPRequestHandler):
|
|||||||
if self.path == "/api/v1/admin/users":
|
if self.path == "/api/v1/admin/users":
|
||||||
if body["username"] in st["users"]:
|
if body["username"] in st["users"]:
|
||||||
return self._json(409, {"message": "user already exists"})
|
return self._json(409, {"message": "user already exists"})
|
||||||
|
if body.get("email") in st["users"].values():
|
||||||
|
# real Gitea: emails are unique across accounts
|
||||||
|
return self._json(422, {"message":
|
||||||
|
f"e-mail already in use [email: {body['email']}]"})
|
||||||
st["users"][body["username"]] = body["email"]
|
st["users"][body["username"]] = body["email"]
|
||||||
st["created"].append(body)
|
st["created"].append(body)
|
||||||
return self._json(201, {"login": body["username"]})
|
return self._json(201, {"login": body["username"]})
|
||||||
@@ -1001,6 +1005,34 @@ class TestInviteSurvivesAFailedGrant(ServiceTestBase):
|
|||||||
self.assertEqual([g["repo"] for g in self.svc.state.peek_invites("[email protected]")],
|
self.assertEqual([g["repo"] for g in self.svc.state.peek_invites("[email protected]")],
|
||||||
["alice/reports"])
|
["alice/reports"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestDuplicateEmailIsExplained(ServiceTestBase):
|
||||||
|
"""A 502 sends someone looking for an outage. The real answer is that the
|
||||||
|
address already belongs to another account here -- say so. (Hit live on
|
||||||
|
2026-08-23 when an operator moved an email onto a different account.)"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.enable_test_mode()
|
||||||
|
# an existing account already holds the address
|
||||||
|
StubUpstream.state["users"]["existing"] = "[email protected]"
|
||||||
|
|
||||||
|
def test_it_is_a_409_that_names_the_problem(self):
|
||||||
|
status, resp = self.stub_link("s-new", "brandnew", email="[email protected]",
|
||||||
|
verified=True, device_id="dev-x")
|
||||||
|
self.assertEqual(status, 409, resp)
|
||||||
|
msg = resp["error"]
|
||||||
|
self.assertIn("[email protected]", msg)
|
||||||
|
self.assertIn("already belongs to a different account", msg)
|
||||||
|
self.assertIn("brandnew", msg) # names the login it tried
|
||||||
|
self.assertNotIn("502", msg)
|
||||||
|
|
||||||
|
def test_a_normal_create_is_unaffected(self):
|
||||||
|
status, resp = self.stub_link("s-ok", "fresh", email="[email protected]",
|
||||||
|
verified=True, device_id="dev-y")
|
||||||
|
self.assertEqual(status, 200, resp)
|
||||||
|
self.assertIn("token", resp)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user