From 1f4caed6113fbbdf761c5455e665e8296f5ad0c5 Mon Sep 17 00:00:00 2001 From: Sreenand P K Date: Thu, 9 Jul 2026 21:53:25 +0530 Subject: [PATCH 1/2] chore: configured backend to frontend communication --- .env.example | 2 +- docker-compose.yml | 2 +- .../core/config.py | 1 + .../routers/auth.py | 52 +++++++++++++++++-- .../schemas/email_verification.py | 4 +- .../services/email_verification_service.py | 6 ++- tests/test_email_verification.py | 52 +++++++++++++++++-- 7 files changed, 107 insertions(+), 12 deletions(-) diff --git a/.env.example b/.env.example index 0221b7e..b20666b 100644 --- a/.env.example +++ b/.env.example @@ -27,7 +27,7 @@ AI_MODEL=gpt-4o # Model to use for coaching responses # ── CORS ─────────────────────────────────────────────────────────────────────── # Comma-separated list of allowed frontend origins -ALLOWED_ORIGINS=["http://localhost:3000","http://localhost:8000"] +ALLOWED_ORIGINS=["http://localhost:3000","http://localhost:5173","http://localhost:8000"] # ── Authentication & Security ────────────────────────────────────────────────── BCRYPT_ROUNDS=12 diff --git a/docker-compose.yml b/docker-compose.yml index a61d68b..ecb9b65 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,7 +22,7 @@ services: - OPENAI_API_KEY=${OPENAI_API_KEY:-} - AI_MODEL=gpt-4o - - ALLOWED_ORIGINS=["http://localhost:3000","http://localhost:8000"] + - ALLOWED_ORIGINS=["http://localhost:3000","http://localhost:5173","http://localhost:8000"] - RESEND_API_KEY=${RESEND_API_KEY} - EMAIL_FROM=${EMAIL_FROM} diff --git a/src/ai_trading_discipline_copilot/core/config.py b/src/ai_trading_discipline_copilot/core/config.py index 5bbf4fc..b53e386 100644 --- a/src/ai_trading_discipline_copilot/core/config.py +++ b/src/ai_trading_discipline_copilot/core/config.py @@ -72,6 +72,7 @@ class Settings(BaseSettings): # CORS allowed_origins: list[str] = [ "http://localhost:3000", + "http://localhost:5173", "http://localhost:8000", ] diff --git a/src/ai_trading_discipline_copilot/routers/auth.py b/src/ai_trading_discipline_copilot/routers/auth.py index f18b353..68b077d 100644 --- a/src/ai_trading_discipline_copilot/routers/auth.py +++ b/src/ai_trading_discipline_copilot/routers/auth.py @@ -15,7 +15,7 @@ ) from fastapi.responses import RedirectResponse from fastapi.security import OAuth2PasswordRequestForm -from sqlalchemy import select +from sqlalchemy import or_, select from sqlalchemy.ext.asyncio import AsyncSession from ai_trading_discipline_copilot.core.config import get_settings @@ -30,7 +30,7 @@ UnauthorizedException, ) from ai_trading_discipline_copilot.core.limiter import limiter -from ai_trading_discipline_copilot.core.security import decode_refresh_token +from ai_trading_discipline_copilot.core.security import decode_refresh_token, hash_refresh_token from ai_trading_discipline_copilot.models.user import User from ai_trading_discipline_copilot.schemas.email_verification import ( ResendVerificationRequest, @@ -576,18 +576,53 @@ async def reset_password( status_code=status.HTTP_200_OK, ) async def verify_email( + request: Request, + response: Response, request_data: VerifyEmailRequest, db: Annotated[AsyncSession, Depends(get_db)], ) -> VerifyEmailResponse: """Verify a user's email using a verification token.""" try: - await EmailVerificationService.verify_email(db, request_data.token) + user = await EmailVerificationService.verify_email(db, request_data.token) except UnauthorizedException as err: raise UnauthorizedException( "Invalid or expired email verification token" ) from err - return VerifyEmailResponse(message="Email verified successfully.") + # Automatically log the user in + ip_address = request.client.host if request.client else None + user_agent = request.headers.get("user-agent") + device_name = parse_device_name(user_agent) + + access_token, refresh_token, refresh_jti = AuthService._create_tokens(user) + + await RefreshTokenService.create_session( + db=db, + user=user, + token_hash=hash_refresh_token(refresh_token), + jti=refresh_jti, + ip_address=ip_address, + user_agent=user_agent, + device_name=device_name, + ) + + AuthService.set_refresh_cookie( + response=response, + refresh_token=refresh_token, + ) + + logger.info( + "User '%s' verified and automatically logged in. IP: %s, Device: %s", + user.username, + ip_address, + device_name, + ) + + return VerifyEmailResponse( + message="Email verified successfully.", + access_token=access_token, + token_type="bearer", + ) @router.post( @@ -606,7 +641,14 @@ async def resend_verification( Always returns a generic success response to prevent account enumeration. """ - result = await db.execute(select(User).where(User.email == request_data.email)) + result = await db.execute( + select(User).where( + or_( + User.email == request_data.username_or_email, + User.username == request_data.username_or_email, + ) + ) + ) user = result.scalar_one_or_none() if user and not user.is_verified: diff --git a/src/ai_trading_discipline_copilot/schemas/email_verification.py b/src/ai_trading_discipline_copilot/schemas/email_verification.py index f5af8e1..46df7ac 100644 --- a/src/ai_trading_discipline_copilot/schemas/email_verification.py +++ b/src/ai_trading_discipline_copilot/schemas/email_verification.py @@ -13,12 +13,14 @@ class VerifyEmailResponse(BaseModel): """Response for email verification.""" message: str + access_token: str | None = None + token_type: str | None = None class ResendVerificationRequest(BaseModel): """Request to resend verification email.""" - email: EmailStr + username_or_email: str class ResendVerificationResponse(BaseModel): diff --git a/src/ai_trading_discipline_copilot/services/email_verification_service.py b/src/ai_trading_discipline_copilot/services/email_verification_service.py index 8176e14..2d6d799 100644 --- a/src/ai_trading_discipline_copilot/services/email_verification_service.py +++ b/src/ai_trading_discipline_copilot/services/email_verification_service.py @@ -145,7 +145,7 @@ async def validate_token( async def verify_email( db: AsyncSession, token: str, - ) -> None: + ) -> User: """Verify the user's email using the provided verification token. Validates the token, loads the user, sets is_verified=True on the user, @@ -155,6 +155,9 @@ async def verify_email( db: The database session. token: The plain-text verification token. + Returns: + The verified User object. + Raises: UnauthorizedException: If the token is invalid or expired. """ @@ -172,6 +175,7 @@ async def verify_email( "Successfully verified email for user ID: %s", user.id, ) + return user @staticmethod async def cleanup_expired_tokens( diff --git a/tests/test_email_verification.py b/tests/test_email_verification.py index be0f803..316797f 100644 --- a/tests/test_email_verification.py +++ b/tests/test_email_verification.py @@ -93,6 +93,9 @@ async def test_verify_email_success( ) assert response.status_code == 200 assert response.json()["message"] == "Email verified successfully." + assert "access_token" in response.json() + assert response.json()["token_type"] == "bearer" + assert "refresh_token" in response.cookies # Check database changes await db_session.refresh(unverified_user) @@ -231,7 +234,7 @@ async def test_resend_verification_success( ) as mock_send: response = await client.post( "/auth/resend-verification", - json={"email": unverified_user.email}, + json={"username_or_email": unverified_user.email}, ) assert response.status_code == 200 assert response.json()["message"] == "Verification email sent." @@ -266,7 +269,7 @@ async def test_resend_verification_already_verified( ) as mock_send: response = await client.post( "/auth/resend-verification", - json={"email": unverified_user.email}, + json={"username_or_email": unverified_user.email}, ) assert response.status_code == 200 assert response.json()["message"] == "Verification email sent." @@ -285,7 +288,7 @@ async def test_resend_verification_non_existing_email( ) as mock_send: response = await client.post( "/auth/resend-verification", - json={"email": "notfound@example.com"}, + json={"username_or_email": "notfound@example.com"}, ) assert response.status_code == 200 assert response.json()["message"] == "Verification email sent." @@ -294,6 +297,25 @@ async def test_resend_verification_non_existing_email( mock_send.assert_not_called() +@pytest.mark.anyio +async def test_resend_verification_by_username( + client: AsyncClient, + db_session: AsyncSession, + unverified_user: User, +) -> None: + """Test resending verification link using username instead of email.""" + with patch( + "ai_trading_discipline_copilot.services.email_service.EmailService.send_verification_email" + ) as mock_send: + response = await client.post( + "/auth/resend-verification", + json={"username_or_email": unverified_user.username}, + ) + assert response.status_code == 200 + assert response.json()["message"] == "Verification email sent." + mock_send.assert_called_once() + + @pytest.mark.anyio async def test_cleanup_expired_tokens( db_session: AsyncSession, @@ -383,3 +405,27 @@ async def test_register_verification_email_failure_logged( log_args = mock_log.call_args[0] assert "Failed to send email [type=verification]" in log_args[0] + +@pytest.mark.anyio +async def test_email_verification_service_direct( + db_session: AsyncSession, +) -> None: + """Test EmailVerificationService.verify_email directly in Python to verify logic and hit coverage.""" + from ai_trading_discipline_copilot.core.security import hash_password + user = User( + username="directuser", + email="direct@example.com", + hashed_password=hash_password("Pass123!"), + is_verified=False, + ) + db_session.add(user) + await db_session.commit() + await db_session.refresh(user) + + plain = await EmailVerificationService.create_verification_token( + db_session, user + ) + verified_user = await EmailVerificationService.verify_email(db_session, plain) + assert verified_user.id == user.id + assert verified_user.is_verified is True + From dbce814750ba3cdc0d7bc613b67eae5725dcd516 Mon Sep 17 00:00:00 2001 From: Sreenand P K Date: Fri, 10 Jul 2026 11:39:03 +0530 Subject: [PATCH 2/2] feat: make email verification auto-login atomic and redirect to hash route --- .../routers/auth.py | 69 +++++++++++-------- 1 file changed, 40 insertions(+), 29 deletions(-) diff --git a/src/ai_trading_discipline_copilot/routers/auth.py b/src/ai_trading_discipline_copilot/routers/auth.py index 68b077d..4822ee2 100644 --- a/src/ai_trading_discipline_copilot/routers/auth.py +++ b/src/ai_trading_discipline_copilot/routers/auth.py @@ -590,39 +590,50 @@ async def verify_email( ) from err # Automatically log the user in - ip_address = request.client.host if request.client else None - user_agent = request.headers.get("user-agent") - device_name = parse_device_name(user_agent) + try: + ip_address = request.client.host if request.client else None + user_agent = request.headers.get("user-agent") + device_name = parse_device_name(user_agent) - access_token, refresh_token, refresh_jti = AuthService._create_tokens(user) + access_token, refresh_token, refresh_jti = AuthService._create_tokens(user) - await RefreshTokenService.create_session( - db=db, - user=user, - token_hash=hash_refresh_token(refresh_token), - jti=refresh_jti, - ip_address=ip_address, - user_agent=user_agent, - device_name=device_name, - ) + await RefreshTokenService.create_session( + db=db, + user=user, + token_hash=hash_refresh_token(refresh_token), + jti=refresh_jti, + ip_address=ip_address, + user_agent=user_agent, + device_name=device_name, + ) - AuthService.set_refresh_cookie( - response=response, - refresh_token=refresh_token, - ) + AuthService.set_refresh_cookie( + response=response, + refresh_token=refresh_token, + ) - logger.info( - "User '%s' verified and automatically logged in. IP: %s, Device: %s", - user.username, - ip_address, - device_name, - ) + logger.info( + "User '%s' verified and automatically logged in. IP: %s, Device: %s", + user.username, + ip_address, + device_name, + ) - return VerifyEmailResponse( - message="Email verified successfully.", - access_token=access_token, - token_type="bearer", - ) + return VerifyEmailResponse( + message="Email verified successfully.", + access_token=access_token, + token_type="bearer", + ) + except Exception: + logger.exception( + "Email verified successfully for user '%s', but automatic login failed.", + user.username, + ) + return VerifyEmailResponse( + message="Email verified successfully.", + access_token=None, + token_type=None, + ) @router.post( @@ -770,7 +781,7 @@ async def google_callback( raise UnauthorizedException("Google email account is not verified") # 4. Perform login or registration, attaching cookies directly to RedirectResponse - redirect_url = f"{settings.frontend_url}/auth/callback" + redirect_url = f"{settings.frontend_url}/#/auth/callback" redirect_response = RedirectResponse(url=redirect_url) ip_address = request.client.host if request.client else None