Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 24 additions & 30 deletions src/ai_trading_discipline_copilot/routers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,24 @@ def parse_device_name(user_agent: str | None) -> str:


async def run_cleanup_task() -> None:
"""Background task to clean up expired sessions using a fresh DB session."""
"""Clean up expired sessions and tokens using a fresh DB session."""
from ai_trading_discipline_copilot.core.database import AsyncSessionFactory

async with AsyncSessionFactory() as db:
await RefreshTokenService.cleanup_expired_sessions(db)
try:
await RefreshTokenService.cleanup_expired_sessions(db)
except Exception:
logger.exception("Failed to clean up expired refresh token sessions")

try:
await EmailVerificationService.cleanup_expired_tokens(db)
except Exception:
logger.exception("Failed to clean up expired email verification tokens")

try:
await PasswordResetService.cleanup_expired_tokens(db)
except Exception:
logger.exception("Failed to clean up expired password reset tokens")


@router.post(
Expand Down Expand Up @@ -500,9 +513,7 @@ async def forgot_password(
and sends a reset email in the background. Always returns a generic success
message to prevent account enumeration.
"""
result = await db.execute(
select(User).where(User.email == request_data.email)
)
result = await db.execute(select(User).where(User.email == request_data.email))
user = result.scalar_one_or_none()

if user:
Expand All @@ -518,8 +529,7 @@ async def forgot_password(

return ForgotPasswordResponse(
message=(
"If an account with that email exists, "
"a password reset link has been sent."
"If an account with that email exists, a password reset link has been sent."
)
)

Expand All @@ -544,13 +554,9 @@ async def reset_password(
new_password=request_data.new_password,
)
except UnauthorizedException as err:
raise UnauthorizedException(
"Invalid or expired password reset token"
) from err
raise UnauthorizedException("Invalid or expired password reset token") from err

return ResetPasswordResponse(
message="Password reset successful."
)
return ResetPasswordResponse(message="Password reset successful.")


@router.post(
Expand All @@ -570,9 +576,7 @@ async def verify_email(
"Invalid or expired email verification token"
) from err

return VerifyEmailResponse(
message="Email verified successfully."
)
return VerifyEmailResponse(message="Email verified successfully.")


@router.post(
Expand All @@ -589,27 +593,17 @@ 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(User.email == request_data.email))
user = result.scalar_one_or_none()

if user and not user.is_verified:
plain_token = await EmailVerificationService.create_verification_token(
db, user
)
verification_url = (
f"{settings.frontend_url}/verify-email?token={plain_token}"
)
plain_token = await EmailVerificationService.create_verification_token(db, user)
verification_url = f"{settings.frontend_url}/verify-email?token={plain_token}"
background_tasks.add_task(
send_verification_email_task,
user_id=user.id,
email=user.email,
verification_url=verification_url,
)

return ResendVerificationResponse(
message="Verification email sent."
)


return ResendVerificationResponse(message="Verification email sent.")
17 changes: 10 additions & 7 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from collections.abc import AsyncGenerator, Generator
from typing import Any

import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import text
from sqlalchemy import event, text
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
Expand All @@ -14,6 +15,7 @@
from ai_trading_discipline_copilot.core.database import Base
from ai_trading_discipline_copilot.core.dependencies import get_db
from ai_trading_discipline_copilot.main import app
from ai_trading_discipline_copilot.models.user import User

settings = get_settings()

Expand Down Expand Up @@ -116,18 +118,19 @@ async def override_get_db() -> AsyncGenerator[AsyncSession]:
def mock_resend_emails() -> Generator[None]:
"""Globally mock resend email calls during tests to prevent API key errors."""
from unittest.mock import patch
with patch("resend.Emails.send") as mock_send, patch("resend.Emails.send_async") as mock_send_async:
yield mock_send

with (
patch("resend.Emails.send") as _mock_send,
patch("resend.Emails.send_async") as _mock_send_async,
):
yield


# Event listener to set is_verified=True on User instantiation if not explicitly passed.
# This prevents existing tests from failing since email verification wasn't present.
from sqlalchemy import event
from ai_trading_discipline_copilot.models.user import User


@event.listens_for(User, "init")
def receive_init(target, args, kwargs) -> None:
def receive_init(target: Any, args: Any, kwargs: dict[str, Any]) -> None:
if "is_verified" not in kwargs:
kwargs["is_verified"] = True