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).
|
||||
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._-]+")
|
||||
|
||||
@@ -292,6 +295,14 @@ def check_config_perms(path, euid=None):
|
||||
# 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:
|
||||
"""Persistent map of Zitadel `sub` -> Gitea login binding records.
|
||||
|
||||
@@ -677,6 +688,14 @@ class LinkService:
|
||||
headers=self._admin_hdr(), body=body)
|
||||
if status == 409:
|
||||
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:
|
||||
return f"gitea admin user create failed (HTTP {status}): {resp}"
|
||||
return None
|
||||
@@ -733,6 +752,8 @@ class LinkService:
|
||||
"was not created by this service; refusing "
|
||||
"to re-create"}, None
|
||||
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:
|
||||
return 502, {"error": err}, None
|
||||
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 "
|
||||
"to this identity"}, None
|
||||
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:
|
||||
return 502, {"error": err}, None
|
||||
else:
|
||||
|
||||
+12
-12
@@ -29,9 +29,11 @@ GIT_ENV = {
|
||||
}
|
||||
|
||||
|
||||
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()
|
||||
def run_git(cwd, *args, strip=True):
|
||||
stdout = subprocess.run(["git", "-C", cwd] + list(args), check=True,
|
||||
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):
|
||||
run_git(self.local, "config", "--add", "credential.helper", "store")
|
||||
client.install_credential_helper(self.local)
|
||||
# --get-all merges system + global + local, so entries inherited from
|
||||
# the machine still appear. What matters is that the last two are the
|
||||
# reset and ours: git reads an empty value as "forget every helper
|
||||
# inherited so far", so nothing before it can answer.
|
||||
helpers = run_git(self.local, "config", "--get-all",
|
||||
"credential.helper").splitlines()
|
||||
self.assertEqual(helpers[-2], "", helpers)
|
||||
self.assertIn("git-credential", helpers[-1])
|
||||
# the repo-level 'store' this test added is gone, not merely outvoted
|
||||
# Inspect the repo-local list so this assertion is deterministic even
|
||||
# when the machine has no inherited helper. Keep the leading newline:
|
||||
# it represents the empty reset value, not disposable whitespace.
|
||||
helpers = run_git(self.local, "config", "--local", "--get-all",
|
||||
"credential.helper", strip=False).splitlines()
|
||||
self.assertEqual(helpers, ["", client.credential_helper_value()])
|
||||
# The repo-level 'store' this test added is gone, not merely outvoted.
|
||||
self.assertNotIn("store", helpers)
|
||||
|
||||
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 body["username"] in st["users"]:
|
||||
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["created"].append(body)
|
||||
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]")],
|
||||
["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__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user