Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 26 additions & 6 deletions api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -535,16 +548,23 @@ 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(
url=f"{_FRONTEND_URL}/auth?error=missing_google_email",
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"])
Expand Down
80 changes: 80 additions & 0 deletions api/tests/test_auth_google_name.py
Original file line number Diff line number Diff line change
@@ -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
Loading