diff --git a/api/auth.py b/api/auth.py index 5d4c2027f..7282bc882 100644 --- a/api/auth.py +++ b/api/auth.py @@ -131,10 +131,16 @@ async def _find_or_create_user( conn: asyncpg.Connection, email: str, google_id: Optional[str] = None, + first_name: Optional[str] = None, + last_name: Optional[str] = None, ) -> dict: """ Find an existing auth_user by email (or google_id), or create one. Always ensures a matching profiles row exists. + + first_name / last_name are populated from the Google profile on sign-in. + They are only written when the profile field is currently empty (COALESCE), + so a name the user later edited is never overwritten by a subsequent login. Returns a dict with at least { id, email }. """ email = email.lower() @@ -174,13 +180,20 @@ async def _find_or_create_user( else: user = {"id": str(row["id"]), "email": row["email"]} - # Ensure profiles row exists and email is current + # Ensure profiles row exists and email is current. Seed the name from the + # OAuth profile, but only when not already set (COALESCE keeps an edited + # name; NULLIF treats an empty incoming value as "no name"). await conn.execute( """ - INSERT INTO profiles (id, email) VALUES ($1, $2) - ON CONFLICT (id) DO UPDATE SET email = EXCLUDED.email + INSERT INTO profiles (id, email, first_name, last_name) VALUES ($1, $2, $3, $4) + ON CONFLICT (id) DO UPDATE SET + email = EXCLUDED.email, + first_name = COALESCE(profiles.first_name, EXCLUDED.first_name), + last_name = COALESCE(profiles.last_name, EXCLUDED.last_name) """, user["id"], email, + (first_name or "").strip() or None, + (last_name or "").strip() or None, ) # Link any pending group invitations sent before registration await conn.execute( @@ -535,8 +548,12 @@ async def google_callback( status_code=302, ) - google_id = userinfo.get("id") - email = userinfo.get("email", "").lower() + google_id = userinfo.get("id") + email = userinfo.get("email", "").lower() + # Google's oauth2/v2/userinfo returns given_name / family_name when the + # 'profile' scope is granted. Persist them on first sign-in. + first_name = userinfo.get("given_name") + last_name = userinfo.get("family_name") if not email or not google_id: return RedirectResponse( @@ -544,7 +561,10 @@ async def google_callback( status_code=302, ) - user = await _find_or_create_user(conn, email, google_id=google_id) + user = await _find_or_create_user( + conn, email, google_id=google_id, + first_name=first_name, last_name=last_name, + ) rt = await _issue_refresh_token(conn, user["id"]) access_token = _issue_access_token(user["id"], user["email"]) diff --git a/api/tests/test_auth_google_name.py b/api/tests/test_auth_google_name.py new file mode 100644 index 000000000..7cb942bfe --- /dev/null +++ b/api/tests/test_auth_google_name.py @@ -0,0 +1,80 @@ +""" +Google OAuth name persistence (_find_or_create_user). + +Exercises the helper against a fake asyncpg connection so no real database is +needed (mirrors test_events.py). Verifies that the name extracted from the +Google profile reaches the profiles upsert, that empty names become NULL, and +that the upsert uses COALESCE so a later login never overwrites an edited name. +""" + +from __future__ import annotations + +import asyncio +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +os.environ.setdefault("JWT_SECRET", "x" * 48) + +import auth as auth_module # noqa: E402 + + +class FakeConn: + """Records execute() calls; returns no existing user (new sign-up).""" + + def __init__(self): + self.executed: list[tuple] = [] + + async def fetchrow(self, query, *args): + return None # no existing auth_user → new-user path + + async def execute(self, query, *args): + self.executed.append((query, args)) + return "INSERT 0 1" + + def profiles_upsert(self): + for query, args in self.executed: + if "INSERT INTO profiles" in query: + return query, args + raise AssertionError("no profiles upsert was executed") + + +def _run(coro): + return asyncio.get_event_loop().run_until_complete(coro) + + +def test_google_name_reaches_profiles_upsert(): + conn = FakeConn() + _run(auth_module._find_or_create_user( + conn, "Ada@Example.com", google_id="g-123", + first_name="Ada", last_name="Lovelace", + )) + query, args = conn.profiles_upsert() + # args: (id, email, first_name, last_name) + assert args[1] == "ada@example.com" + assert args[2] == "Ada" + assert args[3] == "Lovelace" + # Must not overwrite an edited name on a later login. + assert "COALESCE(profiles.first_name" in query + assert "COALESCE(profiles.last_name" in query + + +def test_empty_google_name_becomes_null(): + conn = FakeConn() + _run(auth_module._find_or_create_user( + conn, "nobody@example.com", google_id="g-456", + first_name=" ", last_name="", + )) + _query, args = conn.profiles_upsert() + assert args[2] is None + assert args[3] is None + + +def test_no_name_args_default_to_null(): + # Password / magic-link callers pass no name at all. + conn = FakeConn() + _run(auth_module._find_or_create_user(conn, "plain@example.com")) + _query, args = conn.profiles_upsert() + assert args[2] is None + assert args[3] is None