diff --git a/app/api/routes/auth.py b/app/api/routes/auth.py index e0aa8fd..2e97295 100644 --- a/app/api/routes/auth.py +++ b/app/api/routes/auth.py @@ -460,6 +460,17 @@ async def oauth_callback( await db.commit() return tokens + # Guard: if the email is already taken but we couldn't auto-link (e.g. + # email_verified=False), refuse with 409 instead of hitting a DB constraint. + if not userinfo.email_verified: + conflict = await db.execute(select(User).where(User.email == userinfo.email)) + if conflict.scalar_one_or_none() is not None: + raise HTTPException( + status.HTTP_409_CONFLICT, + "An account with this email already exists. " + "Please sign in with your password.", + ) + # 3. New user — social-only account (no password). new_user = User( id=str(uuid.uuid4()), diff --git a/tests/test_auth.py b/tests/test_auth.py index f64c9c2..e4296fd 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -341,3 +341,18 @@ class TestOAuth: oauth_sub = self._decode_sub(resp.json()["access_token"]) # OAuth login must resolve to the same user as the original registration assert orig_sub == oauth_sub + + def test_callback_unverified_email_conflict_returns_409(self, client, monkeypatch) -> None: + """Unverified Google email matching an existing account returns 409, not 500.""" + email = "conflict@example.com" + reg_resp = client.post( + "/api/v1/auth/register", + json={"email": email, "password": "TestPass123!"}, + ) + assert reg_resp.status_code == 201 + + self._patch_google(monkeypatch) + state = self._authorize(client) + resp = self._callback(client, state, self._userinfo(email=email, email_verified=False)) + + assert resp.status_code == 409