feat: complete auth/profile username migration and runtime safeguards

This commit is contained in:
qzl
2026-02-25 10:20:43 +08:00
parent 8bdcb674bb
commit 7d6dda57c1
24 changed files with 720 additions and 166 deletions
+5 -1
View File
@@ -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"},
)
+6 -8
View File
@@ -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
+145 -1
View File
@@ -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 = {}
@@ -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 = {}
@@ -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()
@@ -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
@@ -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
@@ -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:
@@ -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"}
@@ -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
@@ -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"})