diff --git a/apps/lib/features/auth/data/auth_api.dart b/apps/lib/features/auth/data/auth_api.dart index 95d7db3..201ceb6 100644 --- a/apps/lib/features/auth/data/auth_api.dart +++ b/apps/lib/features/auth/data/auth_api.dart @@ -21,14 +21,17 @@ class AuthApi { Future 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 resendVerification(SignupResendRequest request) async { - await _client.post('$_prefix/verifications/resend', data: request.toJson()); + await _client.post( + '$_prefix/resend', + data: {'type': 'signup', ...request.toJson()}, + ); } Future createSession(LoginRequest request) async { @@ -52,7 +55,10 @@ class AuthApi { } Future requestPasswordReset(String email) async { - await _client.post('$_prefix/password-reset', data: {'email': email}); + await _client.post( + '$_prefix/resend', + data: {'type': 'recovery', 'email': email}, + ); } Future 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, + }, ); } } diff --git a/backend/src/core/config/settings.py b/backend/src/core/config/settings.py index 718fc9e..2268adb 100644 --- a/backend/src/core/config/settings.py +++ b/backend/src/core/config/settings.py @@ -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: diff --git a/backend/src/v1/auth/gateway.py b/backend/src/v1/auth/gateway.py index 521bc7f..065b125 100644 --- a/backend/src/v1/auth/gateway.py +++ b/backend/src/v1/auth/gateway.py @@ -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) diff --git a/backend/src/v1/auth/rate_limit.py b/backend/src/v1/auth/rate_limit.py index 680c706..76c2bf2 100644 --- a/backend/src/v1/auth/rate_limit.py +++ b/backend/src/v1/auth/rate_limit.py @@ -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 diff --git a/backend/src/v1/auth/router.py b/backend/src/v1/auth/router.py index d3367ac..ab987b0 100644 --- a/backend/src/v1/auth/router.py +++ b/backend/src/v1/auth/router.py @@ -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" diff --git a/backend/src/v1/auth/schemas.py b/backend/src/v1/auth/schemas.py index d11b7aa..20371f9 100644 --- a/backend/src/v1/auth/schemas.py +++ b/backend/src/v1/auth/schemas.py @@ -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) diff --git a/backend/src/v1/auth/service.py b/backend/src/v1/auth/service.py index 53ff320..5d356ca 100644 --- a/backend/src/v1/auth/service.py +++ b/backend/src/v1/auth/service.py @@ -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) diff --git a/backend/src/v1/profile/__init__.py b/backend/src/v1/profile/__init__.py deleted file mode 100644 index 9d48db4..0000000 --- a/backend/src/v1/profile/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from __future__ import annotations diff --git a/backend/src/v1/profile/dependencies.py b/backend/src/v1/profile/dependencies.py deleted file mode 100644 index 8a4536f..0000000 --- a/backend/src/v1/profile/dependencies.py +++ /dev/null @@ -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) diff --git a/backend/src/v1/profile/repository.py b/backend/src/v1/profile/repository.py deleted file mode 100644 index 253027f..0000000 --- a/backend/src/v1/profile/repository.py +++ /dev/null @@ -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 diff --git a/backend/src/v1/profile/router.py b/backend/src/v1/profile/router.py deleted file mode 100644 index 89e3ea4..0000000 --- a/backend/src/v1/profile/router.py +++ /dev/null @@ -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) diff --git a/backend/src/v1/profile/schemas.py b/backend/src/v1/profile/schemas.py deleted file mode 100644 index 4bb025b..0000000 --- a/backend/src/v1/profile/schemas.py +++ /dev/null @@ -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 diff --git a/backend/src/v1/profile/service.py b/backend/src/v1/profile/service.py deleted file mode 100644 index 410aa4d..0000000 --- a/backend/src/v1/profile/service.py +++ /dev/null @@ -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, - ) diff --git a/backend/tests/e2e/test_auth_flow.py b/backend/tests/e2e/test_auth_flow.py index 3eab3b6..fb9a840 100644 --- a/backend/tests/e2e/test_auth_flow.py +++ b/backend/tests/e2e/test_auth_flow.py @@ -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", diff --git a/backend/tests/integration/test_auth_routes.py b/backend/tests/integration/test_auth_routes.py index b42b6b0..5d8ceed 100644 --- a/backend/tests/integration/test_auth_routes.py +++ b/backend/tests/integration/test_auth_routes.py @@ -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", diff --git a/backend/tests/unit/test_settings_supabase_env.py b/backend/tests/unit/test_settings_supabase_env.py index 7e5d755..f5c620b 100644 --- a/backend/tests/unit/test_settings_supabase_env.py +++ b/backend/tests/unit/test_settings_supabase_env.py @@ -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" diff --git a/backend/tests/unit/v1/auth/test_auth_gateway.py b/backend/tests/unit/v1/auth/test_auth_gateway.py index e53be50..a66ae97 100644 --- a/backend/tests/unit/v1/auth/test_auth_gateway.py +++ b/backend/tests/unit/v1/auth/test_auth_gateway.py @@ -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"}, + ) diff --git a/backend/tests/unit/v1/auth/test_auth_models.py b/backend/tests/unit/v1/auth/test_auth_models.py index 6f15305..5e0ad38 100644 --- a/backend/tests/unit/v1/auth/test_auth_models.py +++ b/backend/tests/unit/v1/auth/test_auth_models.py @@ -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") diff --git a/backend/tests/unit/v1/auth/test_auth_service.py b/backend/tests/unit/v1/auth/test_auth_service.py index 1ae86ba..2809a49 100644 --- a/backend/tests/unit/v1/auth/test_auth_service.py +++ b/backend/tests/unit/v1/auth/test_auth_service.py @@ -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( diff --git a/backend/tests/unit/v1/profile/test_profile_dependencies.py b/backend/tests/unit/v1/profile/test_profile_dependencies.py deleted file mode 100644 index 3b4c0e5..0000000 --- a/backend/tests/unit/v1/profile/test_profile_dependencies.py +++ /dev/null @@ -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 diff --git a/backend/tests/unit/v1/profile/test_profile_service.py b/backend/tests/unit/v1/profile/test_profile_service.py deleted file mode 100644 index 6ed714c..0000000 --- a/backend/tests/unit/v1/profile/test_profile_service.py +++ /dev/null @@ -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 diff --git a/backend/tests/unit/v1/profile/test_schemas.py b/backend/tests/unit/v1/profile/test_schemas.py deleted file mode 100644 index 92ea17c..0000000 --- a/backend/tests/unit/v1/profile/test_schemas.py +++ /dev/null @@ -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,") - - -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"}) diff --git a/docs/bugs/2026-03-05-agent-runtime-bugs.md b/docs/bugs/2026-03-05-agent-runtime-bugs.md deleted file mode 100644 index a56c350..0000000 --- a/docs/bugs/2026-03-05-agent-runtime-bugs.md +++ /dev/null @@ -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 -``` diff --git a/docs/runtime/runtime-route.md b/docs/runtime/runtime-route.md index 87c89a4..0b0c84a 100644 --- a/docs/runtime/runtime-route.md +++ b/docs/runtime/runtime-route.md @@ -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" } ``` diff --git a/docs/runtime/runtime-runbook.md b/docs/runtime/runtime-runbook.md index 9f4e222..8a4f8bd 100644 --- a/docs/runtime/runtime-runbook.md +++ b/docs/runtime/runtime-runbook.md @@ -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 " \ - -d '{"username":"demo2","bio":"hello"}' - -# profile get -curl -sS "${WEB_BASE_URL}/api/v1/profile/me" \ - -H "Authorization: Bearer " + -d '{"type":"signup","email":"demo@example.com"}' ``` 通过标准:接口返回符合预期的 2xx 或受控业务错误,无 5xx。