Author SHA1 Message Date
Nirav Patel 835d6705d6 test: preserve empty credential helper reset 2026-08-24 03:30:14 -04:00
Nirav Patel 7eb57acdbe Merge pull request 'fix(link): say "that address is already an account here", not "502"' (#10) from fix/link-duplicate-email-message into main 2026-08-23 16:46:22 -04:00
claude ea14038355 fix(link): say "that address is already an account here", not "502"
Hit live today. An operator moved an email onto a different forge account; the
next sign-in tried to create a user with that address, Gitea answered
422 "e-mail already in use", and the generic path turned it into a bare 502.
The person approved a device code and got a number that reads like an outage,
when the real answer was "that address already belongs to somebody here".

create_user now distinguishes that case, and the binding rules translate it to
409 with a message naming the address, the login it tried to create, and the
two ways out: have an operator bind your identity to the existing account, or
use a different address.

211 tests (was 209): the stub now enforces unique emails like real Gitea, one
test asserts the 409 names the address and login and never says 502, and one
asserts an ordinary create is unaffected.
2026-08-23 16:44:49 -04:00
3 changed files with 67 additions and 12 deletions
+23
View File
@@ -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
View File
@@ -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):
+32
View File
@@ -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()