diff --git a/server/granthi_link.py b/server/granthi_link.py index 3701c43..59aa0ff 100644 --- a/server/granthi_link.py +++ b/server/granthi_link.py @@ -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: diff --git a/tests/test_server.py b/tests/test_server.py index 98be554..bc3ee0b 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -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("dan@x.test")], ["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"] = "taken@x.test" + + def test_it_is_a_409_that_names_the_problem(self): + status, resp = self.stub_link("s-new", "brandnew", email="taken@x.test", + verified=True, device_id="dev-x") + self.assertEqual(status, 409, resp) + msg = resp["error"] + self.assertIn("taken@x.test", 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="fresh@x.test", + verified=True, device_id="dev-y") + self.assertEqual(status, 200, resp) + self.assertIn("token", resp) + if __name__ == "__main__": unittest.main()