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:
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
_BUCKETS.clear()
|
||||
_LAST_SEEN.clear()
|
||||
global _CALL_COUNT
|
||||
_CALL_COUNT = 0
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
return await service.verify_verification(payload)
|
||||
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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
from __future__ import annotations
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"})
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
Reference in New Issue
Block a user