diff --git a/backend/alembic/versions/20260224_drop_profile_display_name_and_trigger_username.py b/backend/alembic/versions/20260224_drop_profile_display_name_and_trigger_username.py new file mode 100644 index 0000000..503888b --- /dev/null +++ b/backend/alembic/versions/20260224_drop_profile_display_name_and_trigger_username.py @@ -0,0 +1,121 @@ +"""drop_profile_display_name_and_trigger_username + +Revision ID: 20260224_drop_profile +Revises: 20260224_bind_profiles_auth +Create Date: 2026-02-24 22:10:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op + + +revision: str = "20260224_drop_profile" +down_revision: Union[str, Sequence[str], None] = "20260224_bind_profiles_auth" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute( + """ + ALTER TABLE public.profiles + DROP CONSTRAINT IF EXISTS uq_profiles_username + """ + ) + + op.execute( + """ + ALTER TABLE public.profiles + DROP COLUMN IF EXISTS display_name + """ + ) + + op.execute( + """ + CREATE OR REPLACE FUNCTION public.create_profile_for_new_user() + RETURNS trigger + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = public + AS $$ + BEGIN + INSERT INTO public.profiles (id, username) + VALUES ( + NEW.id, + COALESCE( + NULLIF(NEW.raw_user_meta_data->>'username', ''), + 'user_' || substr(replace(NEW.id::text, '-', ''), 1, 25) + ) + ) + ON CONFLICT (id) DO NOTHING; + + RETURN NEW; + END; + $$ + """ + ) + + +def downgrade() -> None: + op.execute( + """ + ALTER TABLE public.profiles + ADD COLUMN IF NOT EXISTS display_name VARCHAR(50) + """ + ) + + op.execute( + """ + WITH ranked AS ( + SELECT + id, + username, + row_number() OVER ( + PARTITION BY username + ORDER BY created_at ASC, id ASC + ) AS rn + FROM public.profiles + WHERE username IS NOT NULL + ) + UPDATE public.profiles p + SET username = LEFT(p.username, 24) || '_' || (ranked.rn - 1)::text + FROM ranked + WHERE p.id = ranked.id + AND ranked.rn > 1 + """ + ) + + op.execute( + """ + ALTER TABLE public.profiles + ADD CONSTRAINT uq_profiles_username UNIQUE (username) + """ + ) + + op.execute( + """ + CREATE OR REPLACE FUNCTION public.create_profile_for_new_user() + RETURNS trigger + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = public + AS $$ + BEGIN + INSERT INTO public.profiles (id, username, display_name) + VALUES ( + NEW.id, + 'user_' || substr(replace(NEW.id::text, '-', ''), 1, 25), + COALESCE( + NULLIF(NEW.raw_user_meta_data->>'display_name', ''), + NULLIF(NEW.raw_user_meta_data->>'full_name', '') + ) + ) + ON CONFLICT (id) DO NOTHING; + + RETURN NEW; + END; + $$ + """ + ) diff --git a/backend/src/core/auth/models.py b/backend/src/core/auth/models.py index f0ee9ea..f7a31d5 100644 --- a/backend/src/core/auth/models.py +++ b/backend/src/core/auth/models.py @@ -7,3 +7,5 @@ from uuid import UUID @dataclass(frozen=True) class CurrentUser: id: UUID + email: str | None = None + role: str | None = None diff --git a/backend/src/models/profile.py b/backend/src/models/profile.py index f4d1cb6..d903c20 100644 --- a/backend/src/models/profile.py +++ b/backend/src/models/profile.py @@ -26,14 +26,9 @@ class Profile(TimestampMixin, SoftDeleteMixin, Base): ) username: Mapped[str] = mapped_column( String(30), - unique=True, nullable=False, index=True, ) - display_name: Mapped[str | None] = mapped_column( - String(50), - nullable=True, - ) avatar_url: Mapped[str | None] = mapped_column( Text, nullable=True, diff --git a/backend/src/v1/auth/dependencies.py b/backend/src/v1/auth/dependencies.py index 46b54b8..7de951e 100644 --- a/backend/src/v1/auth/dependencies.py +++ b/backend/src/v1/auth/dependencies.py @@ -1,6 +1,7 @@ from __future__ import annotations -from v1.auth.service import AuthService, SupabaseAuthGateway +from v1.auth.gateway import SupabaseAuthGateway +from v1.auth.service import AuthService def get_auth_service() -> AuthService: diff --git a/backend/src/v1/auth/gateway.py b/backend/src/v1/auth/gateway.py new file mode 100644 index 0000000..a003bc3 --- /dev/null +++ b/backend/src/v1/auth/gateway.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import asyncio +from typing import Any, cast + +from fastapi import HTTPException +from supabase import AuthError, create_client + +from core.config.settings import SupabaseSettings, config +from core.logging import get_logger +from v1.auth.schemas import ( + AuthTokenResponse, + AuthUser, + AuthUserByEmailResponse, + LoginRequest, + RefreshRequest, + SignupRequest, +) +from v1.auth.service import AuthServiceGateway + +logger = get_logger("v1.auth.gateway") + + +class SupabaseAuthGateway(AuthServiceGateway): + _client: Any + _admin_client: Any + + def __init__(self) -> None: + settings: SupabaseSettings = config.supabase + self._client = create_client(settings.url, settings.anon_key) + self._admin_client = create_client(settings.url, settings.service_role_key) + + async def signup(self, request: SignupRequest) -> AuthTokenResponse: + payload: dict[str, Any] = { + "email": request.email, + "password": request.password, + "data": {"username": request.username}, + } + try: + sign_up = cast(Any, self._client.auth.sign_up) + response = await asyncio.to_thread(sign_up, payload) + return _map_auth_response(response, "Authentication failed") + except AuthError as exc: + logger.warning("Signup failed", error_type=type(exc).__name__) + raise HTTPException( + status_code=401, detail="Authentication failed" + ) from exc + + async def login(self, request: LoginRequest) -> AuthTokenResponse: + payload: dict[str, Any] = {"email": request.email, "password": request.password} + try: + sign_in = cast(Any, self._client.auth.sign_in_with_password) + response = await asyncio.to_thread(sign_in, payload) + return _map_auth_response(response, "Invalid credentials") + except AuthError as exc: + logger.warning("Login failed", error_type=type(exc).__name__) + raise HTTPException(status_code=401, detail="Invalid credentials") from exc + + async def refresh(self, request: RefreshRequest) -> AuthTokenResponse: + try: + response = await asyncio.to_thread( + self._client.auth.refresh_session, + request.refresh_token, + ) + return _map_auth_response(response, "Invalid refresh token") + except AuthError as exc: + logger.warning("Refresh failed", error_type=type(exc).__name__) + raise HTTPException( + status_code=401, detail="Invalid refresh token" + ) from exc + + async def logout(self, refresh_token: str | None) -> None: + if not refresh_token: + raise HTTPException(status_code=401, detail="Missing refresh token") + try: + response = await asyncio.to_thread( + self._client.auth.refresh_session, + refresh_token, + ) + session = getattr(response, "session", None) + if session is None: + raise HTTPException(status_code=401, detail="Invalid refresh token") + await asyncio.to_thread( + self._client.auth.set_session, + str(session.access_token), + str(session.refresh_token), + ) + await asyncio.to_thread(self._client.auth.sign_out) + except AuthError as exc: + logger.warning("Logout failed", error_type=type(exc).__name__) + raise HTTPException( + status_code=401, detail="Invalid refresh token" + ) from exc + + async def get_user_by_email(self, email: str) -> AuthUserByEmailResponse: + users = await asyncio.to_thread(_list_auth_users, self._admin_client) + normalized_email = email.lower() + user = next( + ( + candidate + for candidate in users + if str(getattr(candidate, "email", "")).lower() == normalized_email + ), + None, + ) + if user is None: + raise HTTPException(status_code=404, detail="User not found") + + return AuthUserByEmailResponse( + id=str(getattr(user, "id", "")), + email=str(getattr(user, "email", "")), + created_at=str(getattr(user, "created_at", "")), + email_confirmed_at=( + str(getattr(user, "email_confirmed_at", "")) + if getattr(user, "email_confirmed_at", None) + else None + ), + ) + + +def _map_auth_response(response: object, failure_message: str) -> AuthTokenResponse: + session = getattr(response, "session", None) + user = getattr(response, "user", None) + if session is None or user is None: + raise HTTPException(status_code=401, detail=failure_message) + + email = getattr(user, "email", None) + if not email: + raise HTTPException(status_code=401, detail=failure_message) + + auth_user = AuthUser(id=str(user.id), email=str(email)) + return AuthTokenResponse( + access_token=str(session.access_token), + refresh_token=str(session.refresh_token), + expires_in=int(session.expires_in or 0), + token_type=str(session.token_type), + user=auth_user, + ) + + +def _list_auth_users(client: Any) -> list[Any]: + users: list[Any] = [] + page = 1 + + while True: + response = client.auth.admin.list_users(page=page, per_page=100) + batch = list(getattr(response, "users", [])) + users.extend(batch) + + if len(batch) < 100: + break + page += 1 + + return users diff --git a/backend/src/v1/auth/router.py b/backend/src/v1/auth/router.py index 4ee6599..05067be 100644 --- a/backend/src/v1/auth/router.py +++ b/backend/src/v1/auth/router.py @@ -1,10 +1,16 @@ from __future__ import annotations -from fastapi import APIRouter, Depends, Response +from typing import Annotated +from fastapi import APIRouter, Depends, Response +from fastapi import HTTPException + +from core.auth.models import CurrentUser from v1.auth.dependencies import get_auth_service +from v1.profile.dependencies import get_current_user from v1.auth.schemas import ( AuthTokenResponse, + AuthUserByEmailResponse, LoginRequest, LogoutRequest, RefreshRequest, @@ -47,3 +53,14 @@ async def logout( ) -> Response: await service.logout(payload.refresh_token) return Response(status_code=204) + + +@router.get("/users/by-email", response_model=AuthUserByEmailResponse) +async def get_user_by_email( + email: str, + current_user: Annotated[CurrentUser, Depends(get_current_user)], + service: AuthService = Depends(get_auth_service), +) -> AuthUserByEmailResponse: + if current_user.role != "service_role" and current_user.email != email: + raise HTTPException(status_code=403, detail="Forbidden") + return await service.get_user_by_email(email) diff --git a/backend/src/v1/auth/schemas.py b/backend/src/v1/auth/schemas.py index 958c979..fbf37fd 100644 --- a/backend/src/v1/auth/schemas.py +++ b/backend/src/v1/auth/schemas.py @@ -6,9 +6,9 @@ from pydantic import BaseModel, EmailStr, Field class SignupRequest(BaseModel): + username: str = Field(min_length=3, max_length=30) email: EmailStr password: str = Field(min_length=6) - display_name: str | None = None redirect_to: str | None = None @@ -38,6 +38,13 @@ class AuthTokenResponse(BaseModel): user: AuthUser +class AuthUserByEmailResponse(BaseModel): + id: str + email: EmailStr + created_at: str + email_confirmed_at: str | None = None + + class SignupPendingResponse(BaseModel): status: Literal["pending_verification"] = "pending_verification" user: AuthUser diff --git a/backend/src/v1/auth/service.py b/backend/src/v1/auth/service.py index 3217eaf..6d5a7e3 100644 --- a/backend/src/v1/auth/service.py +++ b/backend/src/v1/auth/service.py @@ -1,25 +1,16 @@ from __future__ import annotations -import asyncio -from typing import Any, Protocol, cast +from typing import Protocol -from fastapi import HTTPException -from supabase import AuthError, create_client - -from core.config.settings import SupabaseSettings, config -from core.logging import get_logger from v1.auth.schemas import ( AuthTokenResponse, - AuthUser, + AuthUserByEmailResponse, LoginRequest, RefreshRequest, SignupRequest, ) -logger = get_logger("v1.auth.service") - - class AuthServiceGateway(Protocol): async def signup(self, request: SignupRequest) -> AuthTokenResponse: raise NotImplementedError @@ -33,79 +24,8 @@ class AuthServiceGateway(Protocol): async def logout(self, refresh_token: str | None) -> None: raise NotImplementedError - -class SupabaseAuthGateway(AuthServiceGateway): - _client: Any - - def __init__(self) -> None: - settings: SupabaseSettings = config.supabase - self._client = create_client(settings.url, settings.anon_key) - - async def signup(self, request: SignupRequest) -> AuthTokenResponse: - payload: dict[str, Any] = { - "email": request.email, - "password": request.password, - } - if request.display_name: - payload = { - **payload, - "data": {"display_name": request.display_name}, - } - try: - sign_up = cast(Any, self._client.auth.sign_up) - response = await asyncio.to_thread(sign_up, payload) - return _map_auth_response(response, "Authentication failed") - except AuthError as exc: - logger.warning("Signup failed", error=str(exc)) - raise HTTPException( - status_code=401, detail="Authentication failed" - ) from exc - - async def login(self, request: LoginRequest) -> AuthTokenResponse: - payload: dict[str, Any] = {"email": request.email, "password": request.password} - try: - sign_in = cast(Any, self._client.auth.sign_in_with_password) - response = await asyncio.to_thread(sign_in, payload) - return _map_auth_response(response, "Invalid credentials") - except AuthError as exc: - logger.warning("Login failed", error=str(exc)) - raise HTTPException(status_code=401, detail="Invalid credentials") from exc - - async def refresh(self, request: RefreshRequest) -> AuthTokenResponse: - try: - response = await asyncio.to_thread( - self._client.auth.refresh_session, - request.refresh_token, - ) - return _map_auth_response(response, "Invalid refresh token") - except AuthError as exc: - logger.warning("Refresh failed", error=str(exc)) - raise HTTPException( - status_code=401, detail="Invalid refresh token" - ) from exc - - async def logout(self, refresh_token: str | None) -> None: - if not refresh_token: - raise HTTPException(status_code=401, detail="Missing refresh token") - try: - response = await asyncio.to_thread( - self._client.auth.refresh_session, - refresh_token, - ) - session = getattr(response, "session", None) - if session is None: - raise HTTPException(status_code=401, detail="Invalid refresh token") - await asyncio.to_thread( - self._client.auth.set_session, - str(session.access_token), - str(session.refresh_token), - ) - await asyncio.to_thread(self._client.auth.sign_out) - except AuthError as exc: - logger.warning("Logout failed", error=str(exc)) - raise HTTPException( - status_code=401, detail="Invalid refresh token" - ) from exc + async def get_user_by_email(self, email: str) -> AuthUserByEmailResponse: + raise NotImplementedError class AuthService: @@ -126,22 +46,5 @@ class AuthService: async def logout(self, refresh_token: str | None) -> None: await self._gateway.logout(refresh_token) - -def _map_auth_response(response: object, failure_message: str) -> AuthTokenResponse: - session = getattr(response, "session", None) - user = getattr(response, "user", None) - if session is None or user is None: - raise HTTPException(status_code=401, detail=failure_message) - - email = getattr(user, "email", None) - if not email: - raise HTTPException(status_code=401, detail=failure_message) - - auth_user = AuthUser(id=str(user.id), email=str(email)) - return AuthTokenResponse( - access_token=str(session.access_token), - refresh_token=str(session.refresh_token), - expires_in=int(session.expires_in or 0), - token_type=str(session.token_type), - user=auth_user, - ) + async def get_user_by_email(self, email: str) -> AuthUserByEmailResponse: + return await self._gateway.get_user_by_email(email) diff --git a/backend/src/v1/profile/dependencies.py b/backend/src/v1/profile/dependencies.py index 9088e71..8a4536f 100644 --- a/backend/src/v1/profile/dependencies.py +++ b/backend/src/v1/profile/dependencies.py @@ -82,7 +82,9 @@ def get_current_user(authorization: str | None = Header(default=None)) -> Curren raise HTTPException(status_code=401, detail="Unauthorized") logger.debug("JWT validation successful", user_id=str(user_id)) - return CurrentUser(id=user_id) + email = payload.get("email") if isinstance(payload.get("email"), str) else None + role = payload.get("role") if isinstance(payload.get("role"), str) else None + return CurrentUser(id=user_id, email=email, role=role) def get_profile_service( diff --git a/backend/src/v1/profile/repository.py b/backend/src/v1/profile/repository.py index faaffa8..253027f 100644 --- a/backend/src/v1/profile/repository.py +++ b/backend/src/v1/profile/repository.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Protocol from uuid import UUID +from sqlalchemy import select from sqlalchemy.exc import SQLAlchemyError from core.db.base_repository import BaseRepository @@ -54,7 +55,15 @@ class SQLAlchemyProfileRepository(BaseRepository[Profile]): async def get_by_username(self, username: str) -> Profile | None: try: - return await self.get_one(Profile.username == username) + stmt = ( + select(Profile) + .where(Profile.username == username) + .where(Profile.deleted_at.is_(None)) + .order_by(Profile.created_at.asc()) + .limit(1) + ) + result = await self._session.execute(stmt) + return result.scalar_one_or_none() except SQLAlchemyError: logger.exception("Profile lookup failed", username=username) raise diff --git a/backend/src/v1/profile/schemas.py b/backend/src/v1/profile/schemas.py index 64db607..4bb025b 100644 --- a/backend/src/v1/profile/schemas.py +++ b/backend/src/v1/profile/schemas.py @@ -1,18 +1,26 @@ from __future__ import annotations -from pydantic import AnyHttpUrl, BaseModel, Field, field_validator, model_validator +from pydantic import ( + AnyHttpUrl, + BaseModel, + ConfigDict, + Field, + field_validator, + model_validator, +) class ProfileResponse(BaseModel): id: str username: str - display_name: str | None = None avatar_url: str | None = None bio: str | None = None class ProfileUpdateRequest(BaseModel): - display_name: str | None = Field(default=None, max_length=50) + model_config = ConfigDict(extra="forbid") + + username: str | None = Field(default=None, min_length=3, max_length=30) avatar_url: str | None = Field(default=None) bio: str | None = Field(default=None, max_length=200) @@ -28,6 +36,6 @@ class ProfileUpdateRequest(BaseModel): @model_validator(mode="after") def require_one_field(self) -> "ProfileUpdateRequest": - if self.display_name is None and self.avatar_url is None and self.bio is None: + if self.username is None and self.avatar_url is None and self.bio is None: raise ValueError("At least one field must be provided") return self diff --git a/backend/src/v1/profile/service.py b/backend/src/v1/profile/service.py index 11449d9..410aa4d 100644 --- a/backend/src/v1/profile/service.py +++ b/backend/src/v1/profile/service.py @@ -51,7 +51,6 @@ class ProfileService(BaseService): return ProfileResponse( id=str(profile.id), username=profile.username, - display_name=profile.display_name, avatar_url=profile.avatar_url, bio=profile.bio, ) @@ -61,7 +60,7 @@ class ProfileService(BaseService): update_data: dict[str, str | None] = { key: value for key, value in { - "display_name": update.display_name, + "username": update.username, "avatar_url": update.avatar_url, "bio": update.bio, }.items() @@ -84,7 +83,6 @@ class ProfileService(BaseService): return ProfileResponse( id=str(profile.id), username=profile.username, - display_name=profile.display_name, avatar_url=profile.avatar_url, bio=profile.bio, ) @@ -100,7 +98,6 @@ class ProfileService(BaseService): return ProfileResponse( id=str(profile.id), username=profile.username, - display_name=profile.display_name, avatar_url=profile.avatar_url, bio=profile.bio, ) diff --git a/backend/tests/e2e/test_auth_flow.py b/backend/tests/e2e/test_auth_flow.py index 22dac1c..8777d0e 100644 --- a/backend/tests/e2e/test_auth_flow.py +++ b/backend/tests/e2e/test_auth_flow.py @@ -95,7 +95,11 @@ def test_auth_flow_e2e() -> None: signup = request_context.post( "/api/v1/auth/signup", data=json.dumps( - {"email": "user@example.com", "password": "secret123"} + { + "username": "demo", + "email": "user@example.com", + "password": "secret123", + } ), headers={"Content-Type": "application/json"}, ) diff --git a/backend/tests/e2e/test_profile_flow.py b/backend/tests/e2e/test_profile_flow.py index da5cb15..b46d2d7 100644 --- a/backend/tests/e2e/test_profile_flow.py +++ b/backend/tests/e2e/test_profile_flow.py @@ -27,11 +27,10 @@ class FakeProfileService: async def update_me(self, update: ProfileUpdateRequest) -> ProfileResponse: return ProfileResponse( id=self._profile.id, - username=self._profile.username, - display_name=( - update.display_name - if update.display_name is not None - else self._profile.display_name + username=( + update.username + if update.username is not None + else self._profile.username ), avatar_url=( update.avatar_url @@ -75,7 +74,6 @@ def test_profile_flow_e2e() -> None: profile = ProfileResponse( id=str(user_id), username="demo", - display_name="Demo User", avatar_url=None, bio=None, ) @@ -98,11 +96,11 @@ def test_profile_flow_e2e() -> None: updated = request_context.patch( "/api/v1/profile/me", - data=json.dumps({"display_name": "Updated"}), + data=json.dumps({"username": "updated"}), headers={"Content-Type": "application/json"}, ) assert updated.status == 200 - assert updated.json()["display_name"] == "Updated" + assert updated.json()["username"] == "updated" public = request_context.get("/api/v1/profile/demo") assert public.status == 200 diff --git a/backend/tests/integration/test_auth_routes.py b/backend/tests/integration/test_auth_routes.py index df25c79..adaaa70 100644 --- a/backend/tests/integration/test_auth_routes.py +++ b/backend/tests/integration/test_auth_routes.py @@ -1,14 +1,18 @@ from __future__ import annotations from typing import Callable +from uuid import UUID from fastapi import HTTPException from fastapi.testclient import TestClient from app import app +from core.auth.models import CurrentUser from v1.auth.dependencies import get_auth_service +from v1.profile.dependencies import get_current_user from v1.auth.schemas import ( AuthTokenResponse, + AuthUserByEmailResponse, AuthUser, LoginRequest, RefreshRequest, @@ -33,6 +37,16 @@ class FakeAuthService(AuthService): async def logout(self, refresh_token: str | None) -> None: return None + async def get_user_by_email(self, email: str) -> AuthUserByEmailResponse: + if email == "missing@example.com": + raise HTTPException(status_code=404, detail="User not found") + return AuthUserByEmailResponse( + id="user-1", + email=email, + created_at="2026-02-24T00:00:00Z", + email_confirmed_at=None, + ) + def _override_auth_service(service: AuthService) -> Callable[[], AuthService]: def _get_service() -> AuthService: @@ -58,7 +72,11 @@ def test_signup_returns_token_response() -> None: try: response = client.post( "/api/v1/auth/signup", - json={"email": "user@example.com", "password": "secret123"}, + json={ + "username": "demo", + "email": "user@example.com", + "password": "secret123", + }, ) assert response.status_code == 200 body = response.json() @@ -176,3 +194,129 @@ def test_signup_validation_error_returns_problem_details() -> None: assert body["detail"] == "Invalid request" finally: app.dependency_overrides = {} + + +def test_signup_missing_username_returns_problem_details() -> None: + user = AuthUser(id="user-1", email="user@example.com") + token_response = AuthTokenResponse( + access_token="access", + refresh_token="refresh", + expires_in=3600, + token_type="bearer", + user=user, + ) + app.dependency_overrides[get_auth_service] = _override_auth_service( + FakeAuthService(token_response) + ) + + client = TestClient(app) + try: + response = client.post( + "/api/v1/auth/signup", + json={"email": "user@example.com", "password": "secret123"}, + ) + assert response.status_code == 422 + assert response.headers["content-type"].startswith("application/problem+json") + body = response.json() + assert body["title"] == "Unprocessable Content" + assert body["status"] == 422 + assert body["detail"] == "Invalid request" + finally: + app.dependency_overrides = {} + + +def test_get_user_by_email_returns_user() -> None: + user = AuthUser(id="user-1", email="user@example.com") + token_response = AuthTokenResponse( + access_token="access", + refresh_token="refresh", + expires_in=3600, + token_type="bearer", + user=user, + ) + app.dependency_overrides[get_auth_service] = _override_auth_service( + FakeAuthService(token_response) + ) + app.dependency_overrides[get_current_user] = lambda: CurrentUser( + id=UUID("00000000-0000-0000-0000-000000000001"), + email="user@example.com", + ) + + client = TestClient(app) + try: + response = client.get( + "/api/v1/auth/users/by-email", + params={"email": "user@example.com"}, + ) + assert response.status_code == 200 + body = response.json() + assert body["email"] == "user@example.com" + assert body["id"] == "user-1" + finally: + app.dependency_overrides = {} + + +def test_get_user_by_email_not_found_returns_problem_details() -> None: + user = AuthUser(id="user-1", email="user@example.com") + token_response = AuthTokenResponse( + access_token="access", + refresh_token="refresh", + expires_in=3600, + token_type="bearer", + user=user, + ) + app.dependency_overrides[get_auth_service] = _override_auth_service( + FakeAuthService(token_response) + ) + app.dependency_overrides[get_current_user] = lambda: CurrentUser( + id=UUID("00000000-0000-0000-0000-000000000001"), + email="missing@example.com", + ) + + client = TestClient(app) + try: + response = client.get( + "/api/v1/auth/users/by-email", + params={"email": "missing@example.com"}, + ) + assert response.status_code == 404 + assert response.headers["content-type"].startswith("application/problem+json") + body = response.json() + assert body["title"] == "Not Found" + assert body["status"] == 404 + assert body["detail"] == "User not found" + finally: + app.dependency_overrides = {} + + +def test_get_user_by_email_forbidden_when_querying_other_user() -> None: + user = AuthUser(id="user-1", email="user@example.com") + token_response = AuthTokenResponse( + access_token="access", + refresh_token="refresh", + expires_in=3600, + token_type="bearer", + user=user, + ) + app.dependency_overrides[get_auth_service] = _override_auth_service( + FakeAuthService(token_response) + ) + app.dependency_overrides[get_current_user] = lambda: CurrentUser( + id=UUID("00000000-0000-0000-0000-000000000001"), + email="self@example.com", + ) + + client = TestClient(app) + try: + response = client.get( + "/api/v1/auth/users/by-email", + params={"email": "target@example.com"}, + ) + assert response.status_code == 403 + assert response.headers["content-type"].startswith("application/problem+json") + body = response.json() + assert body["title"] == "Forbidden" + assert body["status"] == 403 + assert body["detail"] == "Forbidden" + finally: + app.dependency_overrides = {} diff --git a/backend/tests/integration/test_profile_routes.py b/backend/tests/integration/test_profile_routes.py index 07f8165..15a08fa 100644 --- a/backend/tests/integration/test_profile_routes.py +++ b/backend/tests/integration/test_profile_routes.py @@ -29,11 +29,10 @@ class FakeProfileService: raise HTTPException(status_code=404, detail="Profile not found") return ProfileResponse( id=self._profile.id, - username=self._profile.username, - display_name=( - update.display_name - if update.display_name is not None - else self._profile.display_name + username=( + update.username + if update.username is not None + else self._profile.username ), avatar_url=( update.avatar_url @@ -70,7 +69,6 @@ def test_get_me_returns_profile() -> None: profile = ProfileResponse( id=str(user_id), username="demo", - display_name="Demo User", avatar_url=None, bio=None, ) @@ -94,7 +92,6 @@ def test_patch_me_updates_profile() -> None: profile = ProfileResponse( id=str(user_id), username="demo", - display_name="Demo User", avatar_url=None, bio=None, ) @@ -107,11 +104,11 @@ def test_patch_me_updates_profile() -> None: try: response = client.patch( "/api/v1/profile/me", - json={"display_name": "Updated"}, + json={"username": "updated"}, ) assert response.status_code == 200 body = response.json() - assert body["display_name"] == "Updated" + assert body["username"] == "updated" finally: app.dependency_overrides = {} @@ -120,7 +117,6 @@ def test_get_profile_by_username() -> None: profile = ProfileResponse( id="00000000-0000-0000-0000-000000000001", username="demo", - display_name="Demo User", avatar_url=None, bio=None, ) @@ -142,7 +138,6 @@ def test_profile_not_found_returns_problem_details() -> None: profile = ProfileResponse( id="00000000-0000-0000-0000-000000000001", username="demo", - display_name="Demo User", avatar_url=None, bio=None, ) @@ -167,7 +162,6 @@ def test_patch_me_validation_error_returns_problem_details() -> None: profile = ProfileResponse( id=str(user_id), username="demo", - display_name="Demo User", avatar_url=None, bio=None, ) @@ -186,3 +180,25 @@ def test_patch_me_validation_error_returns_problem_details() -> None: assert body["status"] == 422 finally: app.dependency_overrides = {} + + +def test_patch_me_rejects_display_name_field() -> None: + user_id = UUID("00000000-0000-0000-0000-000000000001") + profile = ProfileResponse( + id=str(user_id), + username="demo", + avatar_url=None, + bio=None, + ) + app.dependency_overrides[get_profile_service] = _override_profile_service( + FakeProfileService(profile) + ) + app.dependency_overrides[get_current_user] = _override_current_user(user_id) + + client = TestClient(app) + try: + response = client.patch("/api/v1/profile/me", json={"display_name": "x"}) + assert response.status_code == 422 + assert response.headers["content-type"].startswith("application/problem+json") + finally: + app.dependency_overrides = {} diff --git a/backend/tests/unit/database/test_base_repository.py b/backend/tests/unit/database/test_base_repository.py index 0cdb410..9989139 100644 --- a/backend/tests/unit/database/test_base_repository.py +++ b/backend/tests/unit/database/test_base_repository.py @@ -4,7 +4,7 @@ from datetime import datetime, timezone from uuid import UUID, uuid4 import pytest -from sqlalchemy import String +from sqlalchemy import Column, String, Table from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.orm import Mapped, mapped_column @@ -21,10 +21,19 @@ class Widget(SoftDeleteMixin, Base): @pytest.fixture async def db_engine(): + auth_users = Table( + "users", + Base.metadata, + Column("id", String, primary_key=True), + schema="auth", + extend_existing=True, + ) engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False) async with engine.begin() as conn: + await conn.exec_driver_sql("ATTACH DATABASE ':memory:' AS auth") await conn.run_sync(Base.metadata.create_all) yield engine + Base.metadata.remove(auth_users) await engine.dispose() diff --git a/backend/tests/unit/database/test_profile_migration_contract.py b/backend/tests/unit/database/test_profile_migration_contract.py new file mode 100644 index 0000000..890a339 --- /dev/null +++ b/backend/tests/unit/database/test_profile_migration_contract.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from pathlib import Path + + +def test_drop_display_name_migration_exists_and_uses_username_metadata() -> None: + versions_dir = Path(__file__).resolve().parents[3] / "alembic" / "versions" + migration = ( + versions_dir / "20260224_drop_profile_display_name_and_trigger_username.py" + ) + + assert migration.exists() + + content = migration.read_text(encoding="utf-8") + assert "DROP COLUMN" in content and "display_name" in content + assert "raw_user_meta_data->>'username'" in content diff --git a/backend/tests/unit/database/test_profile_models.py b/backend/tests/unit/database/test_profile_models.py index af68cd3..1e9c0b5 100644 --- a/backend/tests/unit/database/test_profile_models.py +++ b/backend/tests/unit/database/test_profile_models.py @@ -3,7 +3,7 @@ from __future__ import annotations from uuid import uuid4 import pytest -from sqlalchemy import select +from sqlalchemy import Column, String, Table, select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from core.db.base import Base @@ -13,13 +13,22 @@ from models.profile import Profile @pytest.fixture async def db_engine(): """Create in-memory SQLite engine for testing.""" + users_table = Table( + "users", + Base.metadata, + Column("id", String, primary_key=True), + schema="auth", + extend_existing=True, + ) engine = create_async_engine( "sqlite+aiosqlite:///:memory:", echo=False, ) async with engine.begin() as conn: + await conn.exec_driver_sql("ATTACH DATABASE ':memory:' AS auth") await conn.run_sync(Base.metadata.create_all) yield engine + Base.metadata.remove(users_table) await engine.dispose() @@ -43,7 +52,6 @@ async def test_profile_model_create(db_session: AsyncSession) -> None: profile = Profile( id=profile_id, username="testuser", - display_name="Test User", ) db_session.add(profile) await db_session.commit() @@ -51,7 +59,6 @@ async def test_profile_model_create(db_session: AsyncSession) -> None: assert profile.id == profile_id assert profile.username == "testuser" - assert profile.display_name == "Test User" assert profile.created_at is not None assert profile.updated_at is not None assert profile.deleted_at is None @@ -64,7 +71,6 @@ async def test_profile_model_get_by_id(db_session: AsyncSession) -> None: profile = Profile( id=profile_id, username="testuser", - display_name="Test User", ) db_session.add(profile) await db_session.commit() @@ -80,7 +86,6 @@ async def test_profile_model_get_by_username(db_session: AsyncSession) -> None: profile = Profile( id=uuid4(), username="testuser", - display_name="Test User", ) db_session.add(profile) await db_session.commit() @@ -99,16 +104,31 @@ async def test_profile_model_update(db_session: AsyncSession) -> None: profile = Profile( id=uuid4(), username="testuser", - display_name="Test User", bio="Old bio", ) db_session.add(profile) await db_session.commit() - profile.display_name = "Updated User" profile.bio = "New bio" await db_session.commit() await db_session.refresh(profile) - assert profile.display_name == "Updated User" assert profile.bio == "New bio" + + +@pytest.mark.asyncio +async def test_profile_model_allows_duplicate_usernames( + db_session: AsyncSession, +) -> None: + first = Profile(id=uuid4(), username="same_name") + second = Profile(id=uuid4(), username="same_name") + + db_session.add(first) + db_session.add(second) + await db_session.commit() + + result = await db_session.execute( + select(Profile).where(Profile.username == "same_name") + ) + found = result.scalars().all() + assert len(found) == 2 diff --git a/backend/tests/unit/v1/auth/test_auth_models.py b/backend/tests/unit/v1/auth/test_auth_models.py index c4913f3..d94827a 100644 --- a/backend/tests/unit/v1/auth/test_auth_models.py +++ b/backend/tests/unit/v1/auth/test_auth_models.py @@ -14,7 +14,14 @@ from v1.auth.schemas import ( def test_signup_requires_valid_email() -> None: with pytest.raises(ValidationError): - SignupRequest(email="not-an-email", password="secret123") + SignupRequest(username="demo", email="not-an-email", password="secret123") + + +def test_signup_requires_username() -> None: + with pytest.raises(ValidationError): + SignupRequest.model_validate( + {"email": "user@example.com", "password": "secret123"} + ) def test_login_requires_valid_email() -> None: diff --git a/backend/tests/unit/v1/auth/test_auth_service.py b/backend/tests/unit/v1/auth/test_auth_service.py index c61a0e1..1981964 100644 --- a/backend/tests/unit/v1/auth/test_auth_service.py +++ b/backend/tests/unit/v1/auth/test_auth_service.py @@ -2,8 +2,10 @@ from __future__ import annotations import pytest +import v1.auth.gateway as auth_gateway_module from v1.auth.schemas import ( AuthTokenResponse, + AuthUserByEmailResponse, AuthUser, LoginRequest, RefreshRequest, @@ -28,6 +30,14 @@ class FakeGateway(AuthServiceGateway): async def logout(self, refresh_token: str | None) -> None: return None + async def get_user_by_email(self, email: str) -> AuthUserByEmailResponse: + return AuthUserByEmailResponse( + id="user-1", + email=email, + created_at="2026-02-24T00:00:00Z", + email_confirmed_at=None, + ) + @pytest.mark.asyncio async def test_signup_maps_response() -> None: @@ -42,7 +52,7 @@ async def test_signup_maps_response() -> None: service = AuthService(gateway=FakeGateway(token_response)) result = await service.signup( - SignupRequest(email="user@example.com", password="secret123") + SignupRequest(username="demo", email="user@example.com", password="secret123") ) assert result.access_token == "access" @@ -66,9 +76,72 @@ class LogoutAssertingGateway(AuthServiceGateway): async def logout(self, refresh_token: str | None) -> None: assert refresh_token == self._expected_refresh_token + async def get_user_by_email(self, email: str) -> AuthUserByEmailResponse: + raise NotImplementedError + @pytest.mark.asyncio async def test_logout_forwards_refresh_token() -> None: service = AuthService(gateway=LogoutAssertingGateway("refresh-token")) await service.logout("refresh-token") + + +@pytest.mark.asyncio +async def test_get_user_by_email_forwards_to_gateway() -> None: + user = AuthUser(id="user-1", email="user@example.com") + token_response = AuthTokenResponse( + access_token="access", + refresh_token="refresh", + expires_in=3600, + token_type="bearer", + user=user, + ) + service = AuthService(gateway=FakeGateway(token_response)) + + result = await service.get_user_by_email("user@example.com") + + assert result.email == "user@example.com" + + +@pytest.mark.asyncio +async def test_supabase_signup_passes_username_in_metadata( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured_payload: dict[str, object] = {} + + class FakeSupabaseAuth: + def sign_up(self, payload: dict[str, object]) -> object: + captured_payload.update(payload) + + class _User: + id = "user-1" + email = "user@example.com" + + class _Session: + access_token = "access" + refresh_token = "refresh" + expires_in = 3600 + token_type = "bearer" + + class _Response: + user = _User() + session = _Session() + + return _Response() + + class FakeClient: + auth = FakeSupabaseAuth() + + monkeypatch.setattr(auth_gateway_module, "create_client", lambda *_: FakeClient()) + + gateway = auth_gateway_module.SupabaseAuthGateway() + await gateway.signup( + SignupRequest( + username="demo", + email="user@example.com", + password="secret123", + ) + ) + + assert captured_payload["data"] == {"username": "demo"} diff --git a/backend/tests/unit/v1/profile/test_profile_service.py b/backend/tests/unit/v1/profile/test_profile_service.py index 598e33f..6ed714c 100644 --- a/backend/tests/unit/v1/profile/test_profile_service.py +++ b/backend/tests/unit/v1/profile/test_profile_service.py @@ -16,7 +16,6 @@ from v1.profile.service import ProfileService def _create_mock_profile( user_id: UUID = UUID("00000000-0000-0000-0000-000000000001"), username: str = "demo", - display_name: str | None = "Demo User", avatar_url: str | None = None, bio: str | None = None, ) -> Profile: @@ -24,7 +23,6 @@ def _create_mock_profile( profile = MagicMock(spec=Profile) profile.id = user_id profile.username = username - profile.display_name = display_name profile.avatar_url = avatar_url profile.bio = bio return profile @@ -115,9 +113,9 @@ async def test_update_me_updates_fields(mock_session: AsyncMock) -> None: current_user=user, ) - result = await service.update_me(ProfileUpdateRequest(display_name="Updated")) + result = await service.update_me(ProfileUpdateRequest(username="updated")) - assert result.display_name == "Updated" + assert result.username == "updated" mock_session.commit.assert_awaited_once() @@ -134,7 +132,7 @@ async def test_update_me_no_fields_raises_400(mock_session: AsyncMock) -> None: # Create a request with all None values by bypassing validation update = MagicMock(spec=ProfileUpdateRequest) - update.display_name = None + update.username = None update.avatar_url = None update.bio = None diff --git a/backend/tests/unit/v1/profile/test_schemas.py b/backend/tests/unit/v1/profile/test_schemas.py index f7ee8a8..92ea17c 100644 --- a/backend/tests/unit/v1/profile/test_schemas.py +++ b/backend/tests/unit/v1/profile/test_schemas.py @@ -10,7 +10,6 @@ def test_profile_response_maps_fields() -> None: response = ProfileResponse( id="user-1", username="demo", - display_name="Demo User", avatar_url=None, bio=None, ) @@ -56,6 +55,11 @@ def test_profile_update_rejects_data_url() -> None: def test_profile_update_accepts_none_avatar_url_with_other_field() -> None: - request = ProfileUpdateRequest(display_name="Test", avatar_url=None) + request = ProfileUpdateRequest(username="tester", avatar_url=None) assert request.avatar_url is None - assert request.display_name == "Test" + assert request.username == "tester" + + +def test_profile_update_rejects_display_name_field() -> None: + with pytest.raises(ValidationError): + ProfileUpdateRequest.model_validate({"display_name": "legacy"}) diff --git a/docs/runtime/runtime-runbook.md b/docs/runtime/runtime-runbook.md index 6ccf92f..7f1bfe9 100644 --- a/docs/runtime/runtime-runbook.md +++ b/docs/runtime/runtime-runbook.md @@ -28,18 +28,43 @@ tmux attach -t social-dev docker compose --env-file .env -f infra/docker/docker-compose.yml up -d # 2. 运行迁移和初始化 -docker compose --env-file .env -f infra/docker/docker-compose.yml --profile job run --rm init-job +docker compose --env-file .env -f infra/docker/docker-compose.yml --profile job run --rm --build init-job # 3. 一键执行应用层启动(bootstrap + web + workers) bash infra/scripts/dev-app-up.sh ``` +### 生产环境迁移防遗漏(必读) + +- 生产发布前必须先通过 bootstrap gate,再启动业务进程;禁止绕过 gate 直接起服务。 +- 使用容器执行迁移时必须带 `--build`,确保最新 Alembic 迁移已进入镜像。 +- 建议在迁移后做一次版本核对,确认已到预期 head。 + +```bash +# 1) 先执行 bootstrap gate +make runtime-bootstrap-gate + +# 2) 如采用 init-job 单跑,必须带 --build +docker compose --env-file .env -f infra/docker/docker-compose.yml --profile job run --rm --build init-job + +# 3) 核对 Alembic 版本 +docker compose --env-file .env -f infra/docker/docker-compose.yml exec -T db \ + psql -U postgres -d postgres -c "SELECT version_num FROM public.alembic_version;" +``` + ### 本地 CLI (开发调试) > 适用于本地开发调试,不依赖 Docker。 +> 开发调试阶段推荐直接使用本地一次性迁移脚本,不通过 Docker 触发数据库迁移,避免反复重建镜像。 ```bash -# 初始化/迁移 +# 推荐:一次性迁移(开发调试) +PYTHONPATH=backend/src uv run python -m core.runtime.cli migrate + +# 需要初始化数据时再执行 +PYTHONPATH=backend/src uv run python -m core.runtime.cli init-data + +# 或一键执行(migrate + init-data) PYTHONPATH=backend/src uv run python -m core.runtime.cli bootstrap # 启动 Web (gunicorn) @@ -102,7 +127,7 @@ tmux kill-session -t social-dev curl -fsS http://127.0.0.1:${SOCIAL_SUPABASE__KONG_HTTP_PORT:-8000}/health # 数据库迁移与初始化 -docker compose --env-file .env -f infra/docker/docker-compose.yml --profile job run --rm init-job +docker compose --env-file .env -f infra/docker/docker-compose.yml --profile job run --rm --build init-job ``` ## 查看服务状态 @@ -112,7 +137,30 @@ docker compose --env-file .env -f infra/docker/docker-compose.yml ps docker compose --env-file .env -f infra/docker/docker-compose.yml logs -f db # init-job 为一次性任务(run --rm),如需查看日志请重跑: -docker compose --env-file .env -f infra/docker/docker-compose.yml --profile job run --rm init-job +docker compose --env-file .env -f infra/docker/docker-compose.yml --profile job run --rm --build init-job +``` + +## Auth/Profile 验证 + +```bash +# signup: username + email + password +curl -sS -X POST http://127.0.0.1:8000/api/v1/auth/signup \ + -H 'Content-Type: application/json' \ + -d '{"username":"demo","email":"demo@example.com","password":"secret123"}' + +# login: email + password +curl -sS -X POST http://127.0.0.1:8000/api/v1/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"email":"demo@example.com","password":"secret123"}' + +# by-email lookup +curl -sS "http://127.0.0.1:8000/api/v1/auth/users/by-email?email=demo@example.com" + +# patch profile: username/avatar_url/bio only +curl -sS -X PATCH http://127.0.0.1:8000/api/v1/profile/me \ + -H 'Content-Type: application/json' \ + -H "Authorization: Bearer " \ + -d '{"username":"demo2","bio":"hello"}' ``` --- @@ -125,3 +173,4 @@ docker compose --env-file .env -f infra/docker/docker-compose.yml --profile job | 2026-02-24 | 清理配置:合并 AppSettings 到 WebSettings,删除 Worker 旧配置 (enabled_queues/queues),统一使用 SOCIAL_WEB__GUNICORN__* 命名 | | 2026-02-24 | 开发阶段 compose 暂不编排 web/worker,仅保留 redis/supabase 与 init-job | | 2026-02-24 | 新增 dev-app-up 脚本:手动基础设施后,一键 bootstrap + tmux 拉起 web/worker | +| 2026-02-25 | 补充迁移防遗漏规则:容器迁移命令统一追加 --build;开发调试优先使用本地 CLI 一次性迁移脚本 |