refactor: 统一认证端点并删除冗余 profile 模块

- 合并 auth 端点: /verifications/verify → /verify, /verifications/resend → /resend
- 整合密码重置到 /verify 端点 (type=recovery)
- 移除未使用的 /auth/users 端点
- 添加 redirect URL 白名单验证 (site_url + additional_redirect_urls)
- 限流改用 Redis + IP 标识,替代内存锁
- 删除 v1/profile 死代码模块
- 更新前端 auth_api 适配新端点
- 添加 supabase site_url 和 additional_redirect_urls 配置
This commit is contained in:
zl-q
2026-03-07 14:55:00 +08:00
parent 1f6cb1a48f
commit ec33bb0cee
25 changed files with 421 additions and 1614 deletions
+17 -6
View File
@@ -21,14 +21,17 @@ class AuthApi {
Future<AuthResponse> verifyVerification(SignupVerifyRequest request) async {
final response = await _client.post(
'$_prefix/verifications/verify',
data: request.toJson(),
'$_prefix/verify',
data: {'type': 'signup', ...request.toJson()},
);
return AuthResponse.fromJson(response.data);
}
Future<void> resendVerification(SignupResendRequest request) async {
await _client.post('$_prefix/verifications/resend', data: request.toJson());
await _client.post(
'$_prefix/resend',
data: {'type': 'signup', ...request.toJson()},
);
}
Future<AuthResponse> createSession(LoginRequest request) async {
@@ -52,7 +55,10 @@ class AuthApi {
}
Future<void> requestPasswordReset(String email) async {
await _client.post('$_prefix/password-reset', data: {'email': email});
await _client.post(
'$_prefix/resend',
data: {'type': 'recovery', 'email': email},
);
}
Future<void> confirmPasswordReset({
@@ -61,8 +67,13 @@ class AuthApi {
required String newPassword,
}) async {
await _client.post(
'$_prefix/password-reset/confirm',
data: {'email': email, 'token': token, 'new_password': newPassword},
'$_prefix/verify',
data: {
'type': 'recovery',
'email': email,
'token': token,
'new_password': newPassword,
},
);
}
}
+13
View File
@@ -119,10 +119,23 @@ class SupabaseSettings(BaseModel):
public_scheme: str = "http"
public_host: str = "localhost"
kong_http_port: int = 8000
site_url: str = "http://localhost:3000"
additional_redirect_urls: list[str] = Field(default_factory=list)
anon_key: str = "CHANGE_ME"
service_role_key: str = "CHANGE_ME"
jwt_secret: str | None = None
@field_validator("additional_redirect_urls", mode="before")
@classmethod
def normalize_redirect_urls(cls, value: object) -> list[str]:
if value is None:
return []
if isinstance(value, str):
return [item.strip() for item in value.split(",") if item.strip()]
if isinstance(value, list):
return [str(item).strip() for item in value if str(item).strip()]
return []
@computed_field
@property
def public_url(self) -> str:
+51 -5
View File
@@ -3,10 +3,12 @@ from __future__ import annotations
import asyncio
from collections.abc import Mapping
from typing import Any, cast
from urllib.parse import urlparse
from fastapi import HTTPException
from supabase import AuthError
from core.config.settings import config
from core.logging import get_logger
from services.base.supabase import supabase_service
from v1.auth.schemas import (
@@ -47,7 +49,11 @@ class SupabaseAuthGateway(AuthServiceGateway):
"data": metadata,
}
if request.redirect_to:
payload["options"] = {"email_redirect_to": request.redirect_to}
payload["options"] = {
"email_redirect_to": _validate_redirect_url_or_raise(
request.redirect_to
)
}
try:
sign_up = cast(Any, client.auth.sign_up)
await asyncio.to_thread(sign_up, payload)
@@ -61,9 +67,12 @@ class SupabaseAuthGateway(AuthServiceGateway):
async def verify_verification(
self, request: VerificationVerifyRequest
) -> SessionResponse:
if request.type != "signup":
raise HTTPException(status_code=422, detail="Invalid request")
client = self._get_client()
payload: dict[str, Any] = {
"type": "signup",
"type": request.type,
"email": request.email,
"token": request.token,
}
@@ -79,7 +88,16 @@ class SupabaseAuthGateway(AuthServiceGateway):
async def resend_verification(self, request: VerificationResendRequest) -> None:
client = self._get_client()
payload: dict[str, Any] = {"type": "signup", "email": request.email}
if request.type == "recovery":
await self.request_password_reset(
PasswordResetRequest(
email=request.email,
redirect_to=request.redirect_to,
)
)
return
payload: dict[str, Any] = {"type": request.type, "email": request.email}
try:
resend = cast(Any, client.auth.resend)
await asyncio.to_thread(resend, payload)
@@ -167,7 +185,9 @@ class SupabaseAuthGateway(AuthServiceGateway):
reset_email = cast(Any, client.auth.reset_password_email)
email = _coerce_reset_email(request.email)
if request.redirect_to:
options: dict[str, str] = {"redirect_to": request.redirect_to}
options: dict[str, str] = {
"redirect_to": _validate_redirect_url_or_raise(request.redirect_to)
}
await asyncio.to_thread(reset_email, email, options=options)
else:
await asyncio.to_thread(reset_email, email)
@@ -243,11 +263,37 @@ def _map_auth_response(response: object, failure_message: str) -> SessionRespons
)
def _validate_redirect_url_or_raise(url: str) -> str:
parsed = urlparse(url)
if parsed.scheme not in {"http", "https"}:
raise HTTPException(status_code=422, detail="Invalid redirect URL")
if not parsed.netloc:
raise HTTPException(status_code=422, detail="Invalid redirect URL")
site_origin = _origin_of(config.supabase.site_url)
allowlist = {
site_origin,
*(_origin_of(item) for item in config.supabase.additional_redirect_urls),
}
target_origin = f"{parsed.scheme}://{parsed.netloc}".lower()
if target_origin not in allowlist:
raise HTTPException(status_code=422, detail="Invalid redirect URL")
return url
def _origin_of(url: str) -> str:
parsed = urlparse(url.strip())
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
return ""
return f"{parsed.scheme}://{parsed.netloc}".lower()
def _list_auth_users(client: Any) -> list[Any]:
users: list[Any] = []
page = 1
max_pages = 100
while True:
while page <= max_pages:
response = client.auth.admin.list_users(page=page, per_page=100)
batch = list(getattr(response, "users", []))
users.extend(batch)
+67 -7
View File
@@ -1,13 +1,27 @@
from __future__ import annotations
import asyncio
from collections import deque
from threading import Lock
from time import monotonic
from fastapi import HTTPException
from core.logging import get_logger
from services.base.redis import get_or_init_redis_client
_BUCKETS: dict[str, deque[float]] = {}
_LOCK = Lock()
_LAST_SEEN: dict[str, float] = {}
_LOCK = asyncio.Lock()
_CLEANUP_INTERVAL = 200
_CALL_COUNT = 0
logger = get_logger("v1.auth.rate_limit")
_REDIS_LIMIT_SCRIPT = """
local current = redis.call("INCR", KEYS[1])
if current == 1 then
redis.call("EXPIRE", KEYS[1], ARGV[1])
end
return current
"""
async def enforce_rate_limit(
@@ -17,30 +31,76 @@ async def enforce_rate_limit(
limit: int,
window_seconds: int,
) -> None:
_enforce_rate_limit_in_memory(
key=f"auth:rate_limit:{scope}:{identifier.lower()}",
key = f"auth:rate_limit:{scope}:{identifier.lower()}"
try:
await _enforce_rate_limit_with_redis(
key=key,
limit=limit,
window_seconds=window_seconds,
)
return
except HTTPException:
raise
except Exception as exc: # noqa: BLE001
logger.warning(
"Rate limit fallback to in-memory",
scope=scope,
error_type=type(exc).__name__,
)
await _enforce_rate_limit_in_memory(
key=key,
limit=limit,
window_seconds=window_seconds,
)
def _enforce_rate_limit_in_memory(
async def _enforce_rate_limit_with_redis(
*,
key: str,
limit: int,
window_seconds: int,
) -> None:
client = await get_or_init_redis_client()
current = await client.eval(_REDIS_LIMIT_SCRIPT, 1, key, window_seconds)
if int(current) > limit:
raise HTTPException(status_code=429, detail="Too many requests")
async def _enforce_rate_limit_in_memory(
*,
key: str,
limit: int,
window_seconds: int,
) -> None:
global _CALL_COUNT
now = monotonic()
with _LOCK:
async with _LOCK:
bucket = _BUCKETS.setdefault(key, deque())
_LAST_SEEN[key] = now
cutoff = now - float(window_seconds)
while bucket and bucket[0] <= cutoff:
bucket.popleft()
if len(bucket) >= limit:
raise HTTPException(status_code=429, detail="Too many requests")
bucket.append(now)
_CALL_COUNT += 1
if _CALL_COUNT % _CLEANUP_INTERVAL == 0:
_cleanup_stale_buckets(now)
def _cleanup_stale_buckets(now: float) -> None:
stale_keys = [
key
for key, last_seen in _LAST_SEEN.items()
if key not in _BUCKETS or (not _BUCKETS[key] and now - last_seen > 3600)
]
for key in stale_keys:
_BUCKETS.pop(key, None)
_LAST_SEEN.pop(key, None)
def reset_rate_limit_state() -> None:
with _LOCK:
_BUCKETS.clear()
_LAST_SEEN.clear()
global _CALL_COUNT
_CALL_COUNT = 0
+44 -54
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, Response
from fastapi import APIRouter, Depends, Request, Response
from fastapi import HTTPException
from core.auth.models import CurrentUser
@@ -11,7 +11,6 @@ from v1.auth.dependencies import get_auth_service
from v1.users.dependencies import get_current_user
from v1.auth.schemas import (
PasswordResetConfirmRequest,
PasswordResetRequest,
SessionCreateRequest,
SessionDeleteRequest,
SessionRefreshRequest,
@@ -44,28 +43,45 @@ async def create_verification(
return await service.create_verification(payload)
@router.post("/verifications/verify", response_model=SessionResponse)
async def verify_verification(
@router.post("/verify", response_model=SessionResponse)
async def verify(
payload: VerificationVerifyRequest,
request: Request,
service: AuthService = Depends(get_auth_service),
) -> SessionResponse:
) -> SessionResponse | Response:
scope = "signup_verify" if payload.type == "signup" else "password_reset_confirm"
limit = 10
window_seconds = 600
await enforce_rate_limit(
scope="signup_verify",
identifier=payload.email,
limit=10,
window_seconds=600,
scope=scope,
identifier=f"{payload.email.lower()}:{_client_ip(request)}",
limit=limit,
window_seconds=window_seconds,
)
if payload.type == "signup":
return await service.verify_verification(payload)
if payload.new_password is None:
raise HTTPException(status_code=422, detail="Invalid request")
await service.confirm_password_reset(
PasswordResetConfirmRequest(
email=payload.email,
token=payload.token,
new_password=payload.new_password,
)
)
return Response(status_code=204)
@router.post("/verifications/resend", status_code=204)
async def resend_verification(
@router.post("/resend", status_code=204)
async def resend(
payload: VerificationResendRequest,
request: Request,
service: AuthService = Depends(get_auth_service),
) -> Response:
scope = "signup_resend" if payload.type == "signup" else "password_reset_request"
await enforce_rate_limit(
scope="signup_resend",
identifier=payload.email,
scope=scope,
identifier=f"{payload.email.lower()}:{_client_ip(request)}",
limit=5,
window_seconds=60,
)
@@ -90,11 +106,12 @@ async def create_session(
@router.post("/sessions/refresh", response_model=SessionResponse)
async def refresh_session(
payload: SessionRefreshRequest,
request: Request,
service: AuthService = Depends(get_auth_service),
) -> SessionResponse:
await enforce_rate_limit(
scope="refresh",
identifier=payload.refresh_token,
identifier=_client_ip(request),
limit=10,
window_seconds=60,
)
@@ -104,11 +121,12 @@ async def refresh_session(
@router.delete("/sessions", status_code=204)
async def delete_session(
payload: SessionDeleteRequest,
request: Request,
service: AuthService = Depends(get_auth_service),
) -> Response:
await enforce_rate_limit(
scope="logout",
identifier=payload.refresh_token,
identifier=_client_ip(request),
limit=10,
window_seconds=60,
)
@@ -116,42 +134,14 @@ async def delete_session(
return Response(status_code=204)
@router.get("/users", response_model=UserByEmailResponse)
async def get_user_by_email(
email: str,
current_user: Annotated[CurrentUser, Depends(get_current_user)],
service: AuthService = Depends(get_auth_service),
) -> UserByEmailResponse:
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)
@router.post("/password-reset", status_code=204)
async def request_password_reset(
payload: PasswordResetRequest,
service: AuthService = Depends(get_auth_service),
) -> Response:
await enforce_rate_limit(
scope="password_reset_request",
identifier=payload.email,
limit=5,
window_seconds=60,
)
await service.request_password_reset(payload)
return Response(status_code=204)
@router.post("/password-reset/confirm", status_code=204)
async def confirm_password_reset(
payload: PasswordResetConfirmRequest,
service: AuthService = Depends(get_auth_service),
) -> Response:
await enforce_rate_limit(
scope="password_reset_confirm",
identifier=payload.email,
limit=10,
window_seconds=600,
)
await service.confirm_password_reset(payload)
return Response(status_code=204)
def _client_ip(request: Request) -> str:
forwarded_for = request.headers.get("x-forwarded-for", "")
if forwarded_for:
first = forwarded_for.split(",")[0].strip()
if first:
return first
real_ip = request.headers.get("x-real-ip", "").strip()
if real_ip:
return real_ip
host = request.client.host if request.client else ""
return host or "unknown"
+23 -4
View File
@@ -1,6 +1,11 @@
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, EmailStr, Field
from typing import Literal
from pydantic import BaseModel, ConfigDict, EmailStr, Field, model_validator
SUPABASE_PASSWORD_MIN_LENGTH = 6
OtpType = Literal["signup", "recovery"]
class VerificationCreateRequest(BaseModel):
@@ -8,7 +13,7 @@ class VerificationCreateRequest(BaseModel):
username: str = Field(min_length=3, max_length=30)
email: EmailStr
password: str = Field(min_length=6)
password: str = Field(min_length=SUPABASE_PASSWORD_MIN_LENGTH)
redirect_to: str | None = None
invite_code: str | None = Field(
default=None,
@@ -20,16 +25,30 @@ class VerificationCreateRequest(BaseModel):
class VerificationResendRequest(BaseModel):
email: EmailStr
type: OtpType = "signup"
redirect_to: str | None = None
class VerificationVerifyRequest(BaseModel):
type: OtpType = "signup"
email: EmailStr
token: str = Field(pattern=r"^\d{6}$")
new_password: str | None = Field(
default=None, min_length=SUPABASE_PASSWORD_MIN_LENGTH
)
@model_validator(mode="after")
def validate_type_payload(self) -> "VerificationVerifyRequest":
if self.type == "recovery" and self.new_password is None:
raise ValueError("new_password is required when type is recovery")
if self.type == "signup" and self.new_password is not None:
raise ValueError("new_password is only allowed when type is recovery")
return self
class SessionCreateRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=6)
password: str = Field(min_length=SUPABASE_PASSWORD_MIN_LENGTH)
class SessionRefreshRequest(BaseModel):
@@ -72,4 +91,4 @@ class PasswordResetRequest(BaseModel):
class PasswordResetConfirmRequest(BaseModel):
email: EmailStr
token: str = Field(pattern=r"^\d{6}$")
new_password: str = Field(min_length=6)
new_password: str = Field(min_length=SUPABASE_PASSWORD_MIN_LENGTH)
-7
View File
@@ -8,7 +8,6 @@ from v1.auth.schemas import (
SessionCreateRequest,
SessionRefreshRequest,
SessionResponse,
UserByEmailResponse,
VerificationCreateRequest,
VerificationCreateResponse,
VerificationResendRequest,
@@ -39,9 +38,6 @@ class AuthServiceGateway(Protocol):
async def delete_session(self, refresh_token: str | None) -> None:
raise NotImplementedError
async def get_user_by_email(self, email: str) -> UserByEmailResponse:
raise NotImplementedError
async def request_password_reset(self, request: PasswordResetRequest) -> None:
raise NotImplementedError
@@ -79,9 +75,6 @@ class AuthService:
async def delete_session(self, refresh_token: str | None) -> None:
await self._gateway.delete_session(refresh_token)
async def get_user_by_email(self, email: str) -> UserByEmailResponse:
return await self._gateway.get_user_by_email(email)
async def request_password_reset(self, request: PasswordResetRequest) -> None:
await self._gateway.request_password_reset(request)
-1
View File
@@ -1 +0,0 @@
from __future__ import annotations
-95
View File
@@ -1,95 +0,0 @@
from __future__ import annotations
from typing import Annotated
from uuid import UUID
import jwt
from fastapi import Depends, Header, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from core.config.settings import config
from core.db import get_db
from core.logging import get_logger
from core.auth.models import CurrentUser
from v1.profile.repository import SQLAlchemyProfileRepository
from v1.profile.service import ProfileService
logger = get_logger("v1.profile.dependencies")
def get_current_user(authorization: str | None = Header(default=None)) -> CurrentUser:
if not authorization:
logger.warning("JWT validation failed: missing authorization header")
raise HTTPException(status_code=401, detail="Unauthorized")
scheme, _, token = authorization.partition(" ")
if scheme.lower() != "bearer" or not token:
logger.warning("JWT validation failed: invalid authorization scheme")
raise HTTPException(status_code=401, detail="Unauthorized")
secret = config.supabase.jwt_secret
if not secret:
logger.error("JWT validation failed: secret not configured")
raise HTTPException(status_code=503, detail="JWT secret not configured")
supabase_url = config.supabase.public_url.rstrip("/")
expected_issuer = f"{supabase_url}/auth/v1"
try:
payload = jwt.decode(
token,
secret,
algorithms=["HS256"],
audience="authenticated",
issuer=expected_issuer,
options={
"verify_aud": True,
"verify_iss": True,
"verify_exp": True,
"require": ["sub", "aud", "iss", "exp"],
},
)
except jwt.ExpiredSignatureError:
logger.warning("JWT validation failed: token expired")
raise HTTPException(status_code=401, detail="Unauthorized")
except jwt.InvalidAudienceError:
logger.warning("JWT validation failed: invalid audience")
raise HTTPException(status_code=401, detail="Unauthorized")
except jwt.InvalidIssuerError:
logger.warning("JWT validation failed: invalid issuer")
raise HTTPException(status_code=401, detail="Unauthorized")
except jwt.InvalidSignatureError:
logger.warning("JWT validation failed: invalid signature")
raise HTTPException(status_code=401, detail="Unauthorized")
except jwt.DecodeError:
logger.warning("JWT validation failed: malformed token")
raise HTTPException(status_code=401, detail="Unauthorized")
except jwt.PyJWTError as exc:
logger.warning(
"JWT validation failed: unknown error", error_type=type(exc).__name__
)
raise HTTPException(status_code=401, detail="Unauthorized") from exc
subject = payload.get("sub")
if not isinstance(subject, str) or not subject:
logger.warning("JWT validation failed: missing or invalid subject claim")
raise HTTPException(status_code=401, detail="Unauthorized")
try:
user_id = UUID(subject)
except ValueError:
logger.warning("JWT validation failed: invalid UUID in subject")
raise HTTPException(status_code=401, detail="Unauthorized")
logger.debug("JWT validation successful", user_id=str(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(
session: Annotated[AsyncSession, Depends(get_db)],
user: Annotated[CurrentUser, Depends(get_current_user)],
) -> ProfileService:
repository = SQLAlchemyProfileRepository(session)
return ProfileService(repository=repository, session=session, current_user=user)
-81
View File
@@ -1,81 +0,0 @@
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
from core.logging import get_logger
from models.profile import Profile
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
logger = get_logger("v1.profile.repository")
class ProfileRepository(Protocol):
"""Protocol defining the profile repository interface."""
async def get_by_user_id(self, user_id: UUID) -> Profile | None:
"""Get profile by user ID."""
...
async def get_by_username(self, username: str) -> Profile | None:
"""Get profile by username."""
...
async def update_by_user_id(
self, user_id: UUID, update_data: dict[str, str | None]
) -> Profile | None:
"""Update profile by user ID. Returns updated profile or None if not found."""
...
class SQLAlchemyProfileRepository(BaseRepository[Profile]):
"""SQLAlchemy implementation of ProfileRepository.
Note: This repository only performs CRUD operations.
- No commit (only flush) - service layer handles transactions
- No auth logic - service layer handles authorization
- No HTTP exceptions - returns None or raises SQLAlchemyError
"""
def __init__(self, session: AsyncSession) -> None:
super().__init__(session, Profile)
async def get_by_user_id(self, user_id: UUID) -> Profile | None:
try:
return await self.get_by_id(user_id)
except SQLAlchemyError:
logger.exception("Profile lookup failed", user_id=str(user_id))
raise
async def get_by_username(self, username: str) -> Profile | None:
try:
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
async def update_by_user_id(
self, user_id: UUID, update_data: dict[str, str | None]
) -> Profile | None:
if not update_data:
return await self.get_by_user_id(user_id)
try:
return await self.update_by_id(user_id, update_data)
except SQLAlchemyError:
logger.exception("Profile update failed", user_id=str(user_id))
raise
-36
View File
@@ -1,36 +0,0 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, Path
from v1.profile.dependencies import get_profile_service
from v1.profile.schemas import ProfileResponse, ProfileUpdateRequest
from v1.profile.service import ProfileService
router = APIRouter(prefix="/profile", tags=["profile"])
@router.get("/me", response_model=ProfileResponse)
async def get_me(
service: Annotated[ProfileService, Depends(get_profile_service)],
) -> ProfileResponse:
return await service.get_me()
@router.patch("/me", response_model=ProfileResponse)
async def update_me(
payload: ProfileUpdateRequest,
service: Annotated[ProfileService, Depends(get_profile_service)],
) -> ProfileResponse:
return await service.update_me(payload)
@router.get("/{username}", response_model=ProfileResponse)
async def get_by_username(
username: Annotated[
str, Path(min_length=3, max_length=30, pattern="^[a-zA-Z0-9_]+$")
],
service: Annotated[ProfileService, Depends(get_profile_service)],
) -> ProfileResponse:
return await service.get_by_username(username)
-41
View File
@@ -1,41 +0,0 @@
from __future__ import annotations
from pydantic import (
AnyHttpUrl,
BaseModel,
ConfigDict,
Field,
field_validator,
model_validator,
)
class ProfileResponse(BaseModel):
id: str
username: str
avatar_url: str | None = None
bio: str | None = None
class ProfileUpdateRequest(BaseModel):
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)
@field_validator("avatar_url", mode="before")
@classmethod
def validate_avatar_url(cls, v: str | None) -> str | None:
if v is None:
return None
parsed = AnyHttpUrl(v)
if parsed.scheme not in ("http", "https"):
raise ValueError("avatar_url must use http or https scheme")
return str(parsed)
@model_validator(mode="after")
def require_one_field(self) -> "ProfileUpdateRequest":
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
-103
View File
@@ -1,103 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from fastapi import HTTPException
from sqlalchemy.exc import SQLAlchemyError
from core.auth.models import CurrentUser
from core.db.base_service import BaseService
from core.logging import get_logger
from v1.profile.repository import ProfileRepository
from v1.profile.schemas import ProfileResponse, ProfileUpdateRequest
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
logger = get_logger("v1.profile.service")
class ProfileService(BaseService):
"""Profile service handling business logic and transactions.
Responsibilities:
- Authorization checks
- Transaction boundary (commit/rollback)
- Converting ORM models to response schemas
"""
_repository: ProfileRepository
_session: AsyncSession
def __init__(
self,
repository: ProfileRepository,
session: AsyncSession,
current_user: CurrentUser | None,
) -> None:
super().__init__(current_user=current_user)
self._repository = repository
self._session = session
async def get_me(self) -> ProfileResponse:
user_id = self.require_user_id()
try:
profile = await self._repository.get_by_user_id(user_id)
except SQLAlchemyError:
raise HTTPException(status_code=503, detail="Profile store unavailable")
if profile is None:
raise HTTPException(status_code=404, detail="Profile not found")
return ProfileResponse(
id=str(profile.id),
username=profile.username,
avatar_url=profile.avatar_url,
bio=profile.bio,
)
async def update_me(self, update: ProfileUpdateRequest) -> ProfileResponse:
user_id = self.require_user_id()
update_data: dict[str, str | None] = {
key: value
for key, value in {
"username": update.username,
"avatar_url": update.avatar_url,
"bio": update.bio,
}.items()
if value is not None
}
if not update_data:
raise HTTPException(status_code=400, detail="No fields to update")
try:
profile = await self._repository.update_by_user_id(user_id, update_data)
await self._session.commit()
except SQLAlchemyError:
await self._session.rollback()
raise HTTPException(status_code=503, detail="Profile store unavailable")
if profile is None:
raise HTTPException(status_code=404, detail="Profile not found")
return ProfileResponse(
id=str(profile.id),
username=profile.username,
avatar_url=profile.avatar_url,
bio=profile.bio,
)
async def get_by_username(self, username: str) -> ProfileResponse:
try:
profile = await self._repository.get_by_username(username)
except SQLAlchemyError:
raise HTTPException(status_code=503, detail="Profile store unavailable")
if profile is None:
raise HTTPException(status_code=404, detail="Profile not found")
return ProfileResponse(
id=str(profile.id),
username=profile.username,
avatar_url=profile.avatar_url,
bio=profile.bio,
)
+1 -1
View File
@@ -119,7 +119,7 @@ def test_auth_flow_e2e() -> None:
assert verification.status == 202
verify = request_context.post(
"/api/v1/auth/verifications/verify",
"/api/v1/auth/verify",
data=json.dumps(
{
"email": "user@example.com",
+82 -112
View File
@@ -138,7 +138,7 @@ def test_signup_verify_returns_token_response() -> None:
client = TestClient(app)
try:
response = client.post(
"/api/v1/auth/verifications/verify",
"/api/v1/auth/verify",
json={"email": "user@example.com", "token": "123456"},
)
assert response.status_code == 200
@@ -166,8 +166,8 @@ def test_signup_resend_returns_generic_message() -> None:
client = TestClient(app)
try:
response = client.post(
"/api/v1/auth/verifications/resend",
json={"email": "user@example.com"},
"/api/v1/auth/resend",
json={"type": "recovery", "email": "user@example.com"},
)
assert response.status_code == 204
assert response.content == b""
@@ -191,7 +191,7 @@ def test_signup_verify_invalid_token_returns_problem_details() -> None:
client = TestClient(app)
try:
response = client.post(
"/api/v1/auth/verifications/verify",
"/api/v1/auth/verify",
json={"email": "user@example.com", "token": "000000"},
)
assert response.status_code == 401
@@ -230,7 +230,7 @@ def test_signup_start_existing_email_returns_problem_details() -> None:
assert response.status_code == 422
assert response.headers["content-type"].startswith("application/problem+json")
body = response.json()
assert body["title"] == "Unprocessable Content"
assert body["title"] == "Unprocessable Entity"
assert body["status"] == 422
assert body["detail"] == "Invalid signup request"
finally:
@@ -254,13 +254,13 @@ def test_signup_verify_rate_limited_after_too_many_attempts() -> None:
try:
for _ in range(10):
ok = client.post(
"/api/v1/auth/verifications/verify",
"/api/v1/auth/verify",
json={"email": "user@example.com", "token": "123456"},
)
assert ok.status_code == 200
blocked = client.post(
"/api/v1/auth/verifications/verify",
"/api/v1/auth/verify",
json={"email": "user@example.com", "token": "123456"},
)
assert blocked.status_code == 429
@@ -286,13 +286,13 @@ def test_signup_resend_rate_limited_after_too_many_attempts() -> None:
try:
for _ in range(5):
ok = client.post(
"/api/v1/auth/verifications/resend",
"/api/v1/auth/resend",
json={"email": "user@example.com"},
)
assert ok.status_code == 204
blocked = client.post(
"/api/v1/auth/verifications/resend",
"/api/v1/auth/resend",
json={"email": "user@example.com"},
)
assert blocked.status_code == 429
@@ -493,6 +493,37 @@ def test_refresh_rate_limited_after_too_many_attempts() -> None:
app.dependency_overrides = {}
def test_refresh_rate_limit_not_bypassed_by_changing_refresh_token() -> None:
user = AuthUser(id="user-1", email="user@example.com")
token_response = SessionResponse(
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:
for index in range(10):
blocked = client.post(
"/api/v1/auth/sessions/refresh",
json={"refresh_token": f"invalid-{index}"},
)
assert blocked.status_code == 401
blocked = client.post(
"/api/v1/auth/sessions/refresh",
json={"refresh_token": "invalid-extra"},
)
assert blocked.status_code == 429
finally:
app.dependency_overrides = {}
def test_logout_rate_limited_after_too_many_attempts() -> None:
user = AuthUser(id="user-1", email="user@example.com")
token_response = SessionResponse(
@@ -529,6 +560,39 @@ def test_logout_rate_limited_after_too_many_attempts() -> None:
app.dependency_overrides = {}
def test_logout_rate_limit_not_bypassed_by_changing_refresh_token() -> None:
user = AuthUser(id="user-1", email="user@example.com")
token_response = SessionResponse(
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:
for index in range(10):
ok = client.request(
"DELETE",
"/api/v1/auth/sessions",
json={"refresh_token": f"refresh-{index}"},
)
assert ok.status_code == 204
blocked = client.request(
"DELETE",
"/api/v1/auth/sessions",
json={"refresh_token": "refresh-extra"},
)
assert blocked.status_code == 429
finally:
app.dependency_overrides = {}
def test_signup_start_validation_error_returns_problem_details() -> None:
user = AuthUser(id="user-1", email="user@example.com")
token_response = SessionResponse(
@@ -548,7 +612,7 @@ def test_signup_start_validation_error_returns_problem_details() -> None:
assert response.status_code == 422
assert response.headers["content-type"].startswith("application/problem+json")
body = response.json()
assert body["title"] == "Unprocessable Content"
assert body["title"] == "Unprocessable Entity"
assert body["status"] == 422
assert body["detail"] == "Invalid request"
finally:
@@ -577,110 +641,13 @@ def test_signup_start_missing_username_returns_problem_details() -> None:
assert response.status_code == 422
assert response.headers["content-type"].startswith("application/problem+json")
body = response.json()
assert body["title"] == "Unprocessable Content"
assert body["title"] == "Unprocessable Entity"
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 = SessionResponse(
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",
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 = SessionResponse(
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",
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 = SessionResponse(
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",
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 = {}
def test_password_reset_request_returns_204() -> None:
user = AuthUser(id="user-1", email="user@example.com")
token_response = SessionResponse(
@@ -697,7 +664,7 @@ def test_password_reset_request_returns_204() -> None:
client = TestClient(app)
try:
response = client.post(
"/api/v1/auth/password-reset",
"/api/v1/auth/resend",
json={"email": "user@example.com"},
)
assert response.status_code == 204
@@ -721,8 +688,9 @@ def test_password_reset_confirm_returns_204() -> None:
client = TestClient(app)
try:
response = client.post(
"/api/v1/auth/password-reset/confirm",
"/api/v1/auth/verify",
json={
"type": "recovery",
"email": "user@example.com",
"token": "123456",
"new_password": "newpassword123",
@@ -749,8 +717,9 @@ def test_password_reset_confirm_invalid_token_returns_401() -> None:
client = TestClient(app)
try:
response = client.post(
"/api/v1/auth/password-reset/confirm",
"/api/v1/auth/verify",
json={
"type": "recovery",
"email": "user@example.com",
"token": "000000",
"new_password": "newpassword123",
@@ -781,8 +750,9 @@ def test_password_reset_confirm_weak_password_returns_422() -> None:
client = TestClient(app)
try:
response = client.post(
"/api/v1/auth/password-reset/confirm",
"/api/v1/auth/verify",
json={
"type": "recovery",
"email": "user@example.com",
"token": "123456",
"new_password": "123",
@@ -14,6 +14,11 @@ def test_social_prefixed_supabase_env_populates_settings(
monkeypatch.setenv("SOCIAL_SUPABASE__ANON_KEY", "anon-key")
monkeypatch.setenv("SOCIAL_SUPABASE__SERVICE_ROLE_KEY", "service-key")
monkeypatch.setenv("SOCIAL_SUPABASE__JWT_SECRET", "jwt-secret")
monkeypatch.setenv("SOCIAL_SUPABASE__SITE_URL", "https://app.example.com")
monkeypatch.setenv(
"SOCIAL_SUPABASE__ADDITIONAL_REDIRECT_URLS",
'["https://a.example.com", "https://b.example.com/path"]',
)
monkeypatch.setenv("SOCIAL_DATABASE__HOST", "db")
monkeypatch.setenv("SOCIAL_DATABASE__PORT", "5432")
monkeypatch.setenv("SOCIAL_DATABASE__NAME", "app")
@@ -26,10 +31,16 @@ def test_social_prefixed_supabase_env_populates_settings(
assert settings.supabase.anon_key == "anon-key"
assert settings.supabase.service_role_key == "service-key"
assert settings.supabase.jwt_secret == "jwt-secret"
assert settings.supabase.site_url == "https://app.example.com"
assert settings.supabase.additional_redirect_urls == [
"https://a.example.com",
"https://b.example.com/path",
]
supabase_settings = settings.model_dump()["supabase"]
assert supabase_settings["public_url"] == "https://public.example:8443"
assert supabase_settings["anon_key"] == "anon-key"
assert supabase_settings["service_role_key"] == "service-key"
assert supabase_settings["jwt_secret"] == "jwt-secret"
assert supabase_settings["site_url"] == "https://app.example.com"
assert settings.database_url == "postgresql+asyncpg://user:pass@db:5432/app"
@@ -7,7 +7,11 @@ import pytest
from fastapi import HTTPException
from v1.auth.gateway import SupabaseAuthGateway
from v1.auth.schemas import PasswordResetConfirmRequest, PasswordResetRequest
from v1.auth.schemas import (
PasswordResetConfirmRequest,
PasswordResetRequest,
VerificationResendRequest,
)
class TestSupabaseAuthGateway:
@@ -56,6 +60,22 @@ class TestSupabaseAuthGateway:
options={"redirect_to": "http://localhost:3000/reset-password"},
)
@pytest.mark.asyncio
async def test_request_password_reset_rejects_redirect_outside_allowlist(
self, gateway: tuple[SupabaseAuthGateway, MagicMock, MagicMock]
) -> None:
sut, _, _ = gateway
request = PasswordResetRequest(
email="test@example.com",
redirect_to="https://evil.example/reset",
)
with pytest.raises(HTTPException) as exc_info:
await sut.request_password_reset(request)
assert exc_info.value.status_code == 422
assert exc_info.value.detail == "Invalid redirect URL"
@pytest.mark.asyncio
async def test_request_password_reset_swallows_auth_error(
self, gateway: tuple[SupabaseAuthGateway, MagicMock, MagicMock]
@@ -165,3 +185,24 @@ class TestSupabaseAuthGateway:
assert exc_info.value.status_code == 401
assert exc_info.value.detail == "Invalid or expired verification code"
@pytest.mark.asyncio
async def test_recovery_resend_calls_reset_password_email(
self, gateway: tuple[SupabaseAuthGateway, MagicMock, MagicMock]
) -> None:
sut, mock_client, _ = gateway
mock_reset_email = MagicMock()
mock_client.auth.reset_password_email = mock_reset_email
await sut.resend_verification(
VerificationResendRequest(
type="recovery",
email="test@example.com",
redirect_to="http://localhost:3000/reset-password",
)
)
mock_reset_email.assert_called_once_with(
"test@example.com",
options={"redirect_to": "http://localhost:3000/reset-password"},
)
@@ -33,6 +33,25 @@ def test_signup_verify_requires_six_digit_token() -> None:
VerificationVerifyRequest(email="user@example.com", token="abc123")
def test_signup_verify_disallows_new_password() -> None:
with pytest.raises(ValidationError):
VerificationVerifyRequest(
type="signup",
email="user@example.com",
token="123456",
new_password="secret123",
)
def test_recovery_verify_requires_new_password() -> None:
with pytest.raises(ValidationError):
VerificationVerifyRequest(
type="recovery",
email="user@example.com",
token="123456",
)
def test_signup_resend_requires_valid_email() -> None:
with pytest.raises(ValidationError):
VerificationResendRequest(email="invalid")
+20 -50
View File
@@ -5,6 +5,8 @@ import pytest
import v1.auth.gateway as auth_gateway_module
from v1.auth.schemas import (
AuthUser,
PasswordResetConfirmRequest,
PasswordResetRequest,
SessionCreateRequest,
SessionRefreshRequest,
SessionResponse,
@@ -44,40 +46,15 @@ class FakeGateway(AuthServiceGateway):
return None
async def get_user_by_email(self, email: str) -> UserByEmailResponse:
return UserByEmailResponse(
id="user-1",
email=email,
created_at="2026-02-24T00:00:00Z",
email_confirmed_at=None,
)
raise NotImplementedError
async def request_password_reset(self, request: PasswordResetRequest) -> None:
raise NotImplementedError
@pytest.mark.asyncio
async def test_signup_maps_response() -> None:
user = AuthUser(id="user-1", email="user@example.com")
token_response = SessionResponse(
access_token="access",
refresh_token="refresh",
expires_in=3600,
token_type="bearer",
user=user,
)
service = AuthService(gateway=FakeGateway(token_response))
start_result = await service.create_verification(
VerificationCreateRequest(
username="demo", email="user@example.com", password="secret123"
)
)
assert start_result.email == "user@example.com"
result = await service.verify_verification(
VerificationVerifyRequest(email="user@example.com", token="123456")
)
assert result.access_token == "access"
assert result.refresh_token == "refresh"
assert result.user.id == "user-1"
async def confirm_password_reset(
self, request: PasswordResetConfirmRequest
) -> None:
raise NotImplementedError
class LogoutAssertingGateway(AuthServiceGateway):
@@ -109,6 +86,14 @@ class LogoutAssertingGateway(AuthServiceGateway):
async def get_user_by_email(self, email: str) -> UserByEmailResponse:
raise NotImplementedError
async def request_password_reset(self, request: PasswordResetRequest) -> None:
raise NotImplementedError
async def confirm_password_reset(
self, request: PasswordResetConfirmRequest
) -> None:
raise NotImplementedError
@pytest.mark.asyncio
async def test_logout_forwards_refresh_token() -> None:
@@ -117,23 +102,6 @@ async def test_logout_forwards_refresh_token() -> None:
await service.delete_session("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 = SessionResponse(
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_signup_resend_returns_none() -> None:
user = AuthUser(id="user-1", email="user@example.com")
@@ -182,7 +150,9 @@ async def test_supabase_signup_passes_username_in_metadata(
class FakeClient:
auth = FakeSupabaseAuth()
monkeypatch.setattr(auth_gateway_module, "create_client", lambda *_: FakeClient())
monkeypatch.setattr(
auth_gateway_module.supabase_service, "get_client", lambda: FakeClient()
)
gateway = auth_gateway_module.SupabaseAuthGateway()
await gateway.create_verification(
@@ -1,285 +0,0 @@
from __future__ import annotations
import time
from typing import Any
from uuid import UUID
import jwt
import pytest
from fastapi import HTTPException
from core.auth.models import CurrentUser
from v1.profile.dependencies import get_current_user
class TestGetCurrentUser:
"""Tests for JWT validation in get_current_user dependency."""
@pytest.fixture
def jwt_secret(self) -> str:
return "super-secret-jwt-token-with-at-least-32-characters"
@pytest.fixture
def valid_user_id(self) -> str:
return "00000000-0000-0000-0000-000000000123"
@pytest.fixture
def valid_payload(self, valid_user_id: str) -> dict[str, Any]:
"""Valid JWT payload with all required claims."""
now = int(time.time())
return {
"sub": valid_user_id,
"aud": "authenticated",
"iss": "http://localhost:8001/auth/v1",
"exp": now + 3600, # 1 hour from now
"iat": now,
}
def _create_token(self, payload: dict[str, Any], secret: str) -> str:
return jwt.encode(payload, secret, algorithm="HS256")
def test_valid_token_returns_current_user(
self,
jwt_secret: str,
valid_payload: dict[str, Any],
valid_user_id: str,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Valid JWT with correct aud/iss/exp should return CurrentUser."""
monkeypatch.setattr(
"v1.profile.dependencies.config.supabase.jwt_secret", jwt_secret
)
monkeypatch.setattr(
"v1.profile.dependencies.config.supabase.public_scheme",
"http",
)
monkeypatch.setattr(
"v1.profile.dependencies.config.supabase.public_host",
"localhost",
)
monkeypatch.setattr(
"v1.profile.dependencies.config.supabase.kong_http_port",
8001,
)
token = self._create_token(valid_payload, jwt_secret)
authorization = f"Bearer {token}"
result = get_current_user(authorization=authorization)
assert isinstance(result, CurrentUser)
assert result.id == UUID(valid_user_id)
def test_missing_authorization_raises_401(self) -> None:
"""Missing Authorization header should raise 401."""
with pytest.raises(HTTPException) as exc_info:
get_current_user(authorization=None)
assert exc_info.value.status_code == 401
assert exc_info.value.detail == "Unauthorized"
def test_invalid_scheme_raises_401(self) -> None:
"""Non-Bearer scheme should raise 401."""
with pytest.raises(HTTPException) as exc_info:
get_current_user(authorization="Basic dXNlcjpwYXNz")
assert exc_info.value.status_code == 401
def test_expired_token_raises_401(
self,
jwt_secret: str,
valid_payload: dict[str, Any],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Expired JWT should raise 401."""
monkeypatch.setattr(
"v1.profile.dependencies.config.supabase.jwt_secret", jwt_secret
)
monkeypatch.setattr(
"v1.profile.dependencies.config.supabase.public_scheme",
"http",
)
monkeypatch.setattr(
"v1.profile.dependencies.config.supabase.public_host",
"localhost",
)
monkeypatch.setattr(
"v1.profile.dependencies.config.supabase.kong_http_port",
8001,
)
valid_payload["exp"] = int(time.time()) - 3600 # 1 hour ago
token = self._create_token(valid_payload, jwt_secret)
with pytest.raises(HTTPException) as exc_info:
get_current_user(authorization=f"Bearer {token}")
assert exc_info.value.status_code == 401
def test_invalid_audience_raises_401(
self,
jwt_secret: str,
valid_payload: dict[str, Any],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""JWT with wrong audience should raise 401."""
monkeypatch.setattr(
"v1.profile.dependencies.config.supabase.jwt_secret", jwt_secret
)
monkeypatch.setattr(
"v1.profile.dependencies.config.supabase.public_scheme",
"http",
)
monkeypatch.setattr(
"v1.profile.dependencies.config.supabase.public_host",
"localhost",
)
monkeypatch.setattr(
"v1.profile.dependencies.config.supabase.kong_http_port",
8001,
)
valid_payload["aud"] = "wrong-audience"
token = self._create_token(valid_payload, jwt_secret)
with pytest.raises(HTTPException) as exc_info:
get_current_user(authorization=f"Bearer {token}")
assert exc_info.value.status_code == 401
def test_invalid_issuer_raises_401(
self,
jwt_secret: str,
valid_payload: dict[str, Any],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""JWT with wrong issuer should raise 401."""
monkeypatch.setattr(
"v1.profile.dependencies.config.supabase.jwt_secret", jwt_secret
)
monkeypatch.setattr(
"v1.profile.dependencies.config.supabase.public_scheme",
"http",
)
monkeypatch.setattr(
"v1.profile.dependencies.config.supabase.public_host",
"localhost",
)
monkeypatch.setattr(
"v1.profile.dependencies.config.supabase.kong_http_port",
8001,
)
valid_payload["iss"] = "http://malicious-site.com/auth/v1"
token = self._create_token(valid_payload, jwt_secret)
with pytest.raises(HTTPException) as exc_info:
get_current_user(authorization=f"Bearer {token}")
assert exc_info.value.status_code == 401
def test_missing_subject_raises_401(
self,
jwt_secret: str,
valid_payload: dict[str, Any],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""JWT without 'sub' claim should raise 401."""
monkeypatch.setattr(
"v1.profile.dependencies.config.supabase.jwt_secret", jwt_secret
)
monkeypatch.setattr(
"v1.profile.dependencies.config.supabase.public_scheme",
"http",
)
monkeypatch.setattr(
"v1.profile.dependencies.config.supabase.public_host",
"localhost",
)
monkeypatch.setattr(
"v1.profile.dependencies.config.supabase.kong_http_port",
8001,
)
del valid_payload["sub"]
token = self._create_token(valid_payload, jwt_secret)
with pytest.raises(HTTPException) as exc_info:
get_current_user(authorization=f"Bearer {token}")
assert exc_info.value.status_code == 401
def test_wrong_secret_raises_401(
self,
jwt_secret: str,
valid_payload: dict[str, Any],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""JWT signed with wrong secret should raise 401."""
monkeypatch.setattr(
"v1.profile.dependencies.config.supabase.jwt_secret", jwt_secret
)
monkeypatch.setattr(
"v1.profile.dependencies.config.supabase.public_scheme",
"http",
)
monkeypatch.setattr(
"v1.profile.dependencies.config.supabase.public_host",
"localhost",
)
monkeypatch.setattr(
"v1.profile.dependencies.config.supabase.kong_http_port",
8001,
)
token = self._create_token(
valid_payload, "wrong-secret-key-that-is-long-enough"
)
with pytest.raises(HTTPException) as exc_info:
get_current_user(authorization=f"Bearer {token}")
assert exc_info.value.status_code == 401
def test_jwt_secret_not_configured_raises_503(
self, valid_payload: dict[str, Any], monkeypatch: pytest.MonkeyPatch
) -> None:
"""Missing JWT secret in config should raise 503."""
monkeypatch.setattr("v1.profile.dependencies.config.supabase.jwt_secret", None)
with pytest.raises(HTTPException) as exc_info:
get_current_user(authorization="Bearer some-token")
assert exc_info.value.status_code == 503
assert exc_info.value.detail == "JWT secret not configured"
def test_invalid_uuid_in_subject_raises_401(
self,
jwt_secret: str,
valid_payload: dict[str, Any],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""JWT with non-UUID 'sub' claim should raise 401."""
monkeypatch.setattr(
"v1.profile.dependencies.config.supabase.jwt_secret", jwt_secret
)
monkeypatch.setattr(
"v1.profile.dependencies.config.supabase.public_scheme",
"http",
)
monkeypatch.setattr(
"v1.profile.dependencies.config.supabase.public_host",
"localhost",
)
monkeypatch.setattr(
"v1.profile.dependencies.config.supabase.kong_http_port",
8001,
)
valid_payload["sub"] = "not-a-valid-uuid"
token = self._create_token(valid_payload, jwt_secret)
with pytest.raises(HTTPException) as exc_info:
get_current_user(authorization=f"Bearer {token}")
assert exc_info.value.status_code == 401
@@ -1,170 +0,0 @@
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
from uuid import UUID
import pytest
from fastapi import HTTPException
from core.auth.models import CurrentUser
from models.profile import Profile
from v1.profile.repository import ProfileRepository
from v1.profile.schemas import ProfileUpdateRequest
from v1.profile.service import ProfileService
def _create_mock_profile(
user_id: UUID = UUID("00000000-0000-0000-0000-000000000001"),
username: str = "demo",
avatar_url: str | None = None,
bio: str | None = None,
) -> Profile:
"""Create a mock Profile ORM object."""
profile = MagicMock(spec=Profile)
profile.id = user_id
profile.username = username
profile.avatar_url = avatar_url
profile.bio = bio
return profile
class FakeRepo:
"""Fake repository for testing that conforms to ProfileRepository protocol."""
def __init__(self, profile: Profile | None) -> None:
self._profile = profile
async def get_by_user_id(self, user_id: UUID) -> Profile | None:
if self._profile and user_id == self._profile.id:
return self._profile
return None
async def get_by_username(self, username: str) -> Profile | None:
if self._profile and username == self._profile.username:
return self._profile
return None
async def update_by_user_id(
self, user_id: UUID, update_data: dict[str, str | None]
) -> Profile | None:
if not self._profile or user_id != self._profile.id:
return None
# Apply updates to mock
for key, value in update_data.items():
if hasattr(self._profile, key):
setattr(self._profile, key, value)
return self._profile
# Verify FakeRepo implements the protocol
_repo_check: ProfileRepository = FakeRepo(None)
@pytest.fixture
def mock_session() -> AsyncMock:
"""Create a mock AsyncSession."""
session = AsyncMock()
session.commit = AsyncMock()
session.rollback = AsyncMock()
return session
@pytest.mark.asyncio
async def test_get_me_returns_profile(mock_session: AsyncMock) -> None:
user_id = UUID("00000000-0000-0000-0000-000000000001")
profile = _create_mock_profile(user_id=user_id, username="demo")
user = CurrentUser(id=user_id)
service = ProfileService(
repository=FakeRepo(profile),
session=mock_session,
current_user=user,
)
result = await service.get_me()
assert result.username == "demo"
assert result.id == str(user_id)
@pytest.mark.asyncio
async def test_get_me_not_found_raises_404(mock_session: AsyncMock) -> None:
user_id = UUID("00000000-0000-0000-0000-000000000001")
user = CurrentUser(id=user_id)
service = ProfileService(
repository=FakeRepo(None),
session=mock_session,
current_user=user,
)
with pytest.raises(HTTPException) as exc_info:
await service.get_me()
assert exc_info.value.status_code == 404
@pytest.mark.asyncio
async def test_update_me_updates_fields(mock_session: AsyncMock) -> None:
user_id = UUID("00000000-0000-0000-0000-000000000001")
profile = _create_mock_profile(user_id=user_id, username="demo")
user = CurrentUser(id=user_id)
service = ProfileService(
repository=FakeRepo(profile),
session=mock_session,
current_user=user,
)
result = await service.update_me(ProfileUpdateRequest(username="updated"))
assert result.username == "updated"
mock_session.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_update_me_no_fields_raises_400(mock_session: AsyncMock) -> None:
user_id = UUID("00000000-0000-0000-0000-000000000001")
profile = _create_mock_profile(user_id=user_id)
user = CurrentUser(id=user_id)
service = ProfileService(
repository=FakeRepo(profile),
session=mock_session,
current_user=user,
)
# Create a request with all None values by bypassing validation
update = MagicMock(spec=ProfileUpdateRequest)
update.username = None
update.avatar_url = None
update.bio = None
with pytest.raises(HTTPException) as exc_info:
await service.update_me(update)
assert exc_info.value.status_code == 400
@pytest.mark.asyncio
async def test_get_by_username_returns_profile(mock_session: AsyncMock) -> None:
profile = _create_mock_profile(username="demo")
service = ProfileService(
repository=FakeRepo(profile),
session=mock_session,
current_user=CurrentUser(id=UUID("00000000-0000-0000-0000-000000000001")),
)
result = await service.get_by_username("demo")
assert result.username == "demo"
@pytest.mark.asyncio
async def test_get_by_username_not_found_raises_404(mock_session: AsyncMock) -> None:
service = ProfileService(
repository=FakeRepo(None),
session=mock_session,
current_user=CurrentUser(id=UUID("00000000-0000-0000-0000-000000000001")),
)
with pytest.raises(HTTPException) as exc_info:
await service.get_by_username("unknown")
assert exc_info.value.status_code == 404
@@ -1,65 +0,0 @@
from __future__ import annotations
import pytest
from pydantic import ValidationError
from v1.profile.schemas import ProfileResponse, ProfileUpdateRequest
def test_profile_response_maps_fields() -> None:
response = ProfileResponse(
id="user-1",
username="demo",
avatar_url=None,
bio=None,
)
assert response.id == "user-1"
assert response.username == "demo"
def test_profile_update_requires_one_field() -> None:
with pytest.raises(ValidationError):
ProfileUpdateRequest()
def test_profile_update_accepts_valid_https_url() -> None:
request = ProfileUpdateRequest(avatar_url="https://example.com/avatar.png")
assert request.avatar_url == "https://example.com/avatar.png"
def test_profile_update_accepts_valid_http_url() -> None:
request = ProfileUpdateRequest(
avatar_url="http://localhost:8001/storage/avatar.png"
)
assert request.avatar_url == "http://localhost:8001/storage/avatar.png"
def test_profile_update_rejects_invalid_url() -> None:
with pytest.raises(ValidationError) as exc_info:
ProfileUpdateRequest(avatar_url="not-a-valid-url")
errors = exc_info.value.errors()
assert len(errors) == 1
assert "avatar_url" in str(errors[0]["loc"])
def test_profile_update_rejects_javascript_url() -> None:
with pytest.raises(ValidationError):
ProfileUpdateRequest(avatar_url="javascript:alert('xss')")
def test_profile_update_rejects_data_url() -> None:
with pytest.raises(ValidationError):
ProfileUpdateRequest(avatar_url="data:text/html,<script>alert('xss')</script>")
def test_profile_update_accepts_none_avatar_url_with_other_field() -> None:
request = ProfileUpdateRequest(username="tester", avatar_url=None)
assert request.avatar_url is None
assert request.username == "tester"
def test_profile_update_rejects_display_name_field() -> None:
with pytest.raises(ValidationError):
ProfileUpdateRequest.model_validate({"display_name": "legacy"})
-368
View File
@@ -1,368 +0,0 @@
# Agent Runtime Bugs - 2026-03-05
## Bug #1: ~~LLM Provider 配置缺失~~ [已修复]
### 状态
**已修复** - Provider 配置已正确设置为 `dashscope`
### 原始问题
Agent runtime 执行失败,litellm 报错缺少 provider 配置。
---
## Bug #1.1: ~~模型定价映射缺失~~ [已修复]
### 状态
**已修复** - 用户已修复模型定价问题
### 原始问题
litellm 缺少 `qwen3.5-flash` 的定价映射,导致成本计算失败。
### 错误信息
```
Exception: This model isn't mapped yet. model=dashscope/qwen3.5-flash, custom_llm_provider=dashscope.
Add it here - https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json.
```
### 根本原因
- Provider 配置已正确(`dashscope`
- LLM API 调用成功(耗时约 7 秒)
- litellm 在 `completion_cost()` 阶段查找模型定价信息失败
- `qwen3.5-flash` 模型未在 litellm 的定价数据库中注册
### 调用栈
```
backend/src/core/agent/infrastructure/litellm/usage_tracker.py:26
└─> completion_cost(completion_response=response)
└─> get_model_info(model="dashscope/qwen3.5-flash")
└─> ValueError: This model isn't mapped yet
```
### 复现步骤
1. 重启服务: `infra/scripts/app.sh stop && infra/scripts/app.sh start`
2. 运行诊断: `AGENT_LIVE_INTEGRATION=1 uv run pytest backend/tests/integration/v1/agent/test_sse_flow_live.py -m live -v`
### 影响范围
- LLM 调用成功,但无法提取 token 使用量和成本
- Agent 任务状态标记为失败
- Session 无法正常完成
### 相关日志
**文件**: `logs/worker-default.log`
**时间戳**: 2026-03-05T07:01:23 - 07:01:30
**Session ID**: b36156e8-c175-4c9f-bc5b-7c6f1542c1d4
**Task ID**: db27c0df-a8cc-4879-a945-c317b4b75538
**关键日志序列**:
1. `15:01:23` - Task received
2. `15:01:23` - LiteLLM provider=dashscope (✓ 配置正确)
3. `15:01:30` - Wrapper: Completed Call (✓ API 调用成功)
4. `15:01:30` - Exception: model not mapped (✗ 成本提取失败)
### 建议修复方案
**方案 1: 跳过成本计算 (快速方案)**
```python
# backend/src/core/agent/infrastructure/litellm/usage_tracker.py
try:
cost = completion_cost(completion_response=response)
except Exception:
cost = 0.0 # 或记录 warning 并跳过
```
**方案 2: 手动注册模型定价 (推荐)**
在 litellm 配置中添加模型定价信息:
```python
# 在应用启动时注册模型
from litellm import register_model
register_model({
"dashscope/qwen3.5-flash": {
"max_tokens": 8192,
"input_cost_per_token": 0.0000004, # 示例价格,需查询实际价格
"output_cost_per_token": 0.0000012,
}
})
```
**方案 3: 使用已知模型别名**
`qwen3.5-flash` 映射到 litellm 已知的 qwen 模型:
- `qwen-turbo`
- `qwen-plus`
- `qwen-max`
### 验证方法
修复后运行:
```bash
AGENT_LIVE_INTEGRATION=1 uv run pytest backend/tests/integration/v1/agent/test_sse_flow_live.py -m live -v
```
预期:
- 看到 `RUN_STARTED``RUN_FINISHED` 事件
- 无 "model not mapped" 错误
- Session 状态为 `completed`
---
## Bug #2: Live E2E 测试超时
### 状态
**已解决** - 随 Bug #1#1.1 的修复而解决
### 严重程度
~~**HIGH** - 阻塞 CI/CD 流程~~ **已解决**
### 问题描述
`test_sse_flow_live.py` 测试在 120 秒后超时,未完成执行。
### 根本原因
- **阶段 1**: 由 Bug #1 引起(LLM Provider 配置错误)- **已修复**
- **阶段 2**: 由 Bug #1.1 引起(模型定价映射缺失)- **已修复**
- Agent 任务失败后,SSE 事件流无法发送 `RUN_FINISHED` 事件
- 测试等待完整事件序列导致超时
### 解决方案
Bug #1#1.1 修复后,测试应能正常完成。
---
### 复现步骤
```bash
cd .worktrees/feature-agent-runtime-closed-loop
AGENT_LIVE_INTEGRATION=1 uv run pytest backend/tests/integration/v1/agent/test_sse_flow_live.py -m live -v
```
### 预期行为
- 测试在合理时间内完成(< 30 秒)
- 返回 PASS 或明确的 FAIL 状态
### 实际行为
- 超过 120 秒后超时
- 无任何测试输出
### 依赖关系
- 依赖 Bug #1 的修复
- 修复后应自动解决
### 临时方案
- 增加超时时间(不推荐,掩盖真实问题)
- 添加更详细的日志输出定位卡住位置
---
## 测试环境信息
### 系统状态
- **Worktree**: `.worktrees/feature-agent-runtime-closed-loop`
- **Python**: 3.13.5
- **启动时间**: 2026-03-05 14:30 (UTC+8)
- **运行时服务**: Web + Worker (tmux session: social-dev)
### 服务状态
```
✓ Web 服务: http://localhost:5775 (健康检查通过)
✓ Worker-default: Celery ready
✓ Redis: Connected
✓ LLM Provider 配置: dashscope (已修复)
✓ LLM API 调用: 成功 (7 秒响应时间)
✗ 成本计算: 失败 (模型未映射)
```
### 数据库状态
- Session 创建: 成功
- Message 持久化: 未知(任务失败)
- 实际 DB 查询: 未执行(因任务失败)
---
## 后续行动
### 立即行动
1. [x] ~~修复 Bug #1~~ - LLM Provider 配置 (已由用户修复)
- ✓ Provider 已正确设置为 dashscope
- ✓ LLM API 调用成功
2. [ ] **修复 Bug #1.1** - 模型定价映射
- [ ] 选择修复方案(推荐方案 2: 手动注册定价)
- [ ] 在应用启动时添加模型注册代码
- [ ] 重启服务验证
3. [ ] **验证修复**
- [ ] 运行 `test_sse_flow_live.py`
- [ ] 确认事件流完整(RUN_STARTED → RUN_FINISHED
- [ ] 检查 DB 留痕
### 次要行动
3. [ ] **修复 Bug #3** - 端口文档
- 更新 runbook
- 统一端口引用
4. [ ] **增强测试**
- 添加超时处理
- 改进错误消息
- 添加配置验证检查
---
## 调试笔记
### 已执行命令
```bash
# 第一次测试 (Provider 未配置)
# 1. 启动服务
infra/scripts/app.sh start
# 2. 检查健康
curl http://localhost:5775/health # 成功
# 3. 运行 live E2E (超时)
AGENT_LIVE_INTEGRATION=1 uv run pytest backend/tests/integration/v1/agent/test_sse_flow_live.py -m live -v
AGENT_LIVE_INTEGRATION=1 uv run pytest backend/tests/integration/v1/agent/test_sse_flow_live.py -m live -v
# 超时
AGENT_LIVE_INTEGRATION=1 uv run pytest backend/tests/integration/v1/agent/test_sse_flow_live.py -m live -v # 失败 (LLM Provider 错误)
# 5. 检查日志
tail -f logs/worker-default.log # 发现根本原因
# 6. 停止服务
infra/scripts/app.sh stop
# 第二次测试 (Provider 已修复,定价缺失)
# 7. 重启服务
infra/scripts/app.sh stop && infra/scripts/app.sh start
# 8. 检查健康
curl http://localhost:5775/health # 成功
# 9. 运行诊断脚本
AGENT_LIVE_INTEGRATION=1 uv run pytest backend/tests/integration/v1/agent/test_sse_flow_live.py -m live -v # 失败 (模型定价未映射)
# 10. 检查日志
tail -f logs/worker-default.log # 发现新错误: 模型未映射
```
### 关键发现时间线
- 14:30 - 启动服务
- 14:31 - Live E2E 超时
- 14:34 - SSE flow 失败
- 14:35 - 检查日志发现 LLM Provider 错误
- 14:36 - 定位根本原因
- 14:37 - 停止服务,记录 bug
### 未验证项
- [ ] 数据库中是否有部分写入的 session/message
- [ ] Redis 中是否有残留的任务状态
- [ ] 其他 worker 队列是否正常
---
## 相关资源
### 日志文件
- `logs/web.log` - Web 服务日志
- `logs/worker-default.log` - Worker 日志(包含错误栈)
- `logs/worker-critical.log` - 关键任务队列
- `logs/worker-bulk.log` - 批量任务队列
### 配置文件
- `.env` - 环境变量(符号链接到主项目)
- `backend/src/core/config.py` - 配置加载
- `backend/src/core/agent/infrastructure/litellm/client.py` - LLM 客户端
### 相关代码
- `backend/src/core/agent/infrastructure/crewai/runtime.py:57` - execute 方法
- `backend/src/core/agent/infrastructure/litellm/client.py:9` - run_completion
- `backend/src/core/agent/infrastructure/queue/tasks.py:125` - run_agent_task
---
## 成功测试记录 (2026-03-05 15:30)
### 测试环境
- **时间**: 2026-03-05 15:30 (UTC+8)
- **Worktree**: `.worktrees/feature-agent-runtime-closed-loop`
- **服务状态**: 所有服务正常运行
### 测试执行
**命令**:
```bash
AGENT_LIVE_INTEGRATION=1 uv run pytest backend/tests/integration/v1/agent/test_sse_flow_live.py -m live -v
```
**结果**: ✅ **成功**
### 关键日志证据
**文件**: `logs/worker-default.log`
**时间序列**:
```
15:30:32.829 - Task received
└─> session_id: 63582adf-6167-48d3-964b-4fe8d680e5c5
└─> user_input: "你好,请介绍一下你自己"
15:30:32.892 - LiteLLM provider=dashscope ✓
└─> model= qwen3.5-flash
└─> provider = dashscope
15:30:41.635 - Wrapper: Completed Call ✓
└─> 耗时: ~9 秒
└─> LLM API 调用成功
15:30:41.666 - Task succeeded ✓
└─> persisted: True
└─> state_snapshot: {'status': 'running', 'pending_tool_call_id': '...'}
└─> events: [TEXT_MESSAGE_START, TEXT_MESSAGE_CONTENT, TEXT_MESSAGE_END]
└─> runtime: 8.836s
```
### 验证项
- [x] 服务启动成功
- [x] 健康检查通过 (`/health`)
- [x] LLM Provider 配置正确 (`dashscope`)
- [x] LLM API 调用成功 (9 秒响应)
- [x] 成本计算成功 (无定价映射错误)
- [x] Session 创建并持久化
- [x] 事件流生成 (TEXT_MESSAGE_START/CONTENT/END)
- [x] Agent 任务状态正常 (`running`)
### 与之前的对比
| 项目 | 之前状态 | 当前状态 |
|------|---------|---------|
| Provider 配置 | ❌ 缺失 | ✅ dashscope |
| LLM 调用 | ❌ 失败 | ✅ 成功 (9s) |
| 成本计算 | ❌ 定价映射缺失 | ✅ 成功 |
| Session 持久化 | ❌ 失败 | ✅ persisted=True |
| 事件流 | ❌ 无 | ✅ 3 个事件 |
### 结论
**所有关键 bug 已修复,agent runtime 闭环测试通过!**
---
## 总结
### 修复进度
-**Bug #1**: LLM Provider 配置缺失 - **已修复**
- 用户已将 provider 配置为 `dashscope`
- LLM API 调用现在可以成功执行
-**Bug #1.1**: 模型定价映射缺失 - **当前阻塞项**
- litellm 缺少 `qwen3.5-flash` 的定价信息
- 需要手动注册或跳过成本计算
### 核心问题
**当前阻塞**: litellm 无法计算 `dashscope/qwen3.5-flash` 的使用成本
### 预计修复时间
- **方案 1 (快速)**: 5 分钟 - 跳过成本计算
- **方案 2 (推荐)**: 15 分钟 - 手动注册模型定价
### 测试覆盖
修复后需重新运行完整测试套件:
```bash
AGENT_LIVE_INTEGRATION=1 uv run pytest backend/tests/integration/v1/agent/test_sse_flow_live.py -m live -v
AGENT_LIVE_INTEGRATION=1 uv run pytest backend/tests/integration/v1/agent/test_sse_flow_live.py -m live -v
```
+24 -105
View File
@@ -44,14 +44,16 @@
---
### POST /auth/verifications/resend
### POST /auth/resend
重发验证码。
重发验证码(统一端点,支持注册/找回密码)
**Request:**
```json
{
"email": "string (email)"
"type": "signup | recovery (default: signup)",
"email": "string (email)",
"redirect_to": "string? (仅 recovery 可选)"
}
```
@@ -63,19 +65,20 @@
---
### POST /auth/verifications/verify
### POST /auth/verify
验证码校验。
验证码校验(统一端点,按 `type` 区分场景)
**Request:**
**Request (signup):**
```json
{
"type": "signup",
"email": "string (email)",
"token": "string (6 digits)"
}
```
**Response:** 200 OK
**Response (signup):** 200 OK
```json
{
"access_token": "string",
@@ -89,6 +92,18 @@
}
```
**Request (recovery):**
```json
{
"type": "recovery",
"email": "string (email)",
"token": "string (6 digits)",
"new_password": "string (min 6 chars)"
}
```
**Response (recovery):** 204 No Content
**Errors:**
- 401: 验证码无效或已过期
- 422: 请求参数无效
@@ -157,6 +172,7 @@
**Errors:**
- 401: 无效的 refresh token
- 422: 请求参数无效
- 429: 请求过于频繁
---
@@ -175,47 +191,6 @@
**Errors:**
- 422: 请求参数无效
---
### POST /auth/password-reset
发送密码重置验证码。
**Request:**
```json
{
"email": "string (email)",
"redirect_to": "string? (optional)"
}
```
**Response:** 204 No Content
**Errors:**
- 422: 请求参数无效
- 429: 请求过于频繁
---
### POST /auth/password-reset/confirm
验证 recovery 验证码并完成改密。
**Request:**
```json
{
"email": "string (email)",
"token": "string (6 digits)",
"new_password": "string (min 6 chars)"
}
```
**Response:** 204 No Content
**Errors:**
- 401: 验证码无效或已过期
- 422: 请求参数无效
- 429: 请求过于频繁
---
@@ -397,60 +372,6 @@
---
## Profile
### GET /profile/me
获取当前用户信息(需要认证)。
**Response:** 200 OK
```json
{
"id": "string",
"username": "string",
"avatar_url": "string?",
"bio": "string?"
}
```
**Errors:**
- 401: 未认证
---
### PATCH /profile/me
更新当前用户信息(需要认证)。
**Request:**
```json
{
"username": "string? (3-30 chars)",
"avatar_url": "string? (URL)",
"bio": "string? (max 200 chars)"
}
```
**Response:** 200 OK
**Errors:**
- 401: 未认证
- 422: 请求参数无效
---
### GET /profile/{username}
按用户名查询用户公开信息(需要认证)。
**Response:** 200 OK
**Errors:**
- 401: 未认证
- 404: 用户不存在
---
## Inbox Messages
### GET /inbox/messages
@@ -521,8 +442,6 @@
## Users
> **Note:** `/users/me` 与 `/profile/me` 功能重叠(历史兼容)。推荐使用 `/profile/me`。
### GET /users/me
获取当前用户信息(需要认证)。
@@ -910,7 +829,7 @@ data: {"session_id":"..."}
"title": "Unauthorized",
"status": 401,
"detail": "验证码无效或已过期",
"instance": "/api/v1/auth/verifications/verify"
"instance": "/api/v1/auth/verify"
}
```
+5 -15
View File
@@ -121,7 +121,7 @@ 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 ps
# 核心接口 smoke
curl -sS -X POST "${WEB_BASE_URL}/api/v1/auth/login" \
curl -sS -X POST "${WEB_BASE_URL}/api/v1/auth/sessions" \
-H 'Content-Type: application/json' \
-d '{"email":"demo@example.com","password":"secret123"}'
```
@@ -137,24 +137,14 @@ curl -sS -X POST "${WEB_BASE_URL}/api/v1/auth/verifications" \
-d '{"username":"demo","email":"demo@example.com","password":"secret123"}'
# signup verify
curl -sS -X POST "${WEB_BASE_URL}/api/v1/auth/verifications/verify" \
curl -sS -X POST "${WEB_BASE_URL}/api/v1/auth/verify" \
-H 'Content-Type: application/json' \
-d '{"email":"demo@example.com","token":"123456"}'
-d '{"type":"signup","email":"demo@example.com","token":"123456"}'
# signup resend
curl -sS -X POST "${WEB_BASE_URL}/api/v1/auth/verifications/resend" \
curl -sS -X POST "${WEB_BASE_URL}/api/v1/auth/resend" \
-H 'Content-Type: application/json' \
-d '{"email":"demo@example.com"}'
# profile patch
curl -sS -X PATCH "${WEB_BASE_URL}/api/v1/profile/me" \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer <access_token>" \
-d '{"username":"demo2","bio":"hello"}'
# profile get
curl -sS "${WEB_BASE_URL}/api/v1/profile/me" \
-H "Authorization: Bearer <access_token>"
-d '{"type":"signup","email":"demo@example.com"}'
```
通过标准:接口返回符合预期的 2xx 或受控业务错误,无 5xx。