diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index 0d0bacb7..a9182087 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -1,7 +1,11 @@ from fastapi import APIRouter, Request, HTTPException, Query, Depends from fastapi.responses import HTMLResponse from app.database.supabase.client import get_supabase_client -from app.services.auth.verification import find_user_by_session_and_verify, get_verification_session_info +from app.services.auth.verification import ( + find_user_by_session_and_verify, + get_verification_session_info, + validate_oauth_state, +) from app.services.github.user.profiling import profile_user_from_github from typing import Optional import logging @@ -21,6 +25,7 @@ async def auth_callback( request: Request, code: Optional[str] = Query(None), session: Optional[str] = Query(None), + state: Optional[str] = Query(None), app_instance: "DevRAIApplication" = Depends(get_app_instance), ): """ @@ -37,6 +42,12 @@ async def auth_callback( logger.error("Missing session ID in callback") return _error_response("Missing session ID. Please try the /verify_github command again.") + # Validate the OAuth state parameter to protect against login CSRF (RFC 6749). + # The state is bound to the verification session when the OAuth URL is created. + if not validate_oauth_state(session, state): + logger.error(f"OAuth state validation failed for session ID: {session}") + return _error_response("Invalid or missing security token. Please run the /verify_github command again.") + # Check if session is valid and not expired session_info = await get_verification_session_info(session) if not session_info: diff --git a/backend/app/services/auth/verification.py b/backend/app/services/auth/verification.py index cbfa156c..239e05b1 100644 --- a/backend/app/services/auth/verification.py +++ b/backend/app/services/auth/verification.py @@ -1,4 +1,5 @@ import uuid +import secrets from datetime import datetime, timedelta from typing import Optional, Dict, Tuple from app.database.supabase.client import get_supabase_client @@ -7,8 +8,8 @@ logger = logging.getLogger(__name__) -# session_id -> (discord_id, expiry_time) -_verification_sessions: Dict[str, Tuple[str, datetime]] = {} +# session_id -> (discord_id, expiry_time, oauth_state) +_verification_sessions: Dict[str, Tuple[str, datetime, str]] = {} SESSION_EXPIRY_MINUTES = 5 @@ -18,21 +19,25 @@ def _cleanup_expired_sessions(): """ current_time = datetime.now() expired_sessions = [ - session_id for session_id, (discord_id, expiry_time) in _verification_sessions.items() + session_id for session_id, (discord_id, expiry_time, _state) in _verification_sessions.items() if current_time > expiry_time ] for session_id in expired_sessions: - discord_id, _ = _verification_sessions[session_id] + discord_id, _, _ = _verification_sessions[session_id] del _verification_sessions[session_id] logger.info(f"Cleaned up expired verification session {session_id} for Discord user {discord_id}") if expired_sessions: logger.info(f"Cleaned up {len(expired_sessions)} expired verification sessions") -async def create_verification_session(discord_id: str) -> Optional[str]: +async def create_verification_session(discord_id: str) -> Optional[Tuple[str, str]]: """ - Create a verification session with expiry and return session ID. + Create a verification session with expiry and return (session_id, oauth_state). + + The OAuth ``state`` is a cryptographically-random token bound to the session. + It must be threaded through the OAuth authorize URL and validated in the + callback to protect against login CSRF (RFC 6749, Section 10.12). """ supabase = get_supabase_client() @@ -40,6 +45,7 @@ async def create_verification_session(discord_id: str) -> Optional[str]: token = str(uuid.uuid4()) session_id = str(uuid.uuid4()) + oauth_state = secrets.token_urlsafe(32) expiry_time = datetime.now() + timedelta(minutes=SESSION_EXPIRY_MINUTES) try: @@ -50,16 +56,44 @@ async def create_verification_session(discord_id: str) -> Optional[str]: }).eq("discord_id", discord_id).execute() if update_res.data: - _verification_sessions[session_id] = (discord_id, expiry_time) + _verification_sessions[session_id] = (discord_id, expiry_time, oauth_state) logger.info( f"Created verification session {session_id} for Discord user {discord_id}, expires at {expiry_time}") - return session_id + return session_id, oauth_state logger.error(f"Failed to set verification token for Discord ID: {discord_id}. User not found.") return None except Exception as e: logger.error(f"Error creating verification session for Discord ID {discord_id}: {str(e)}") return None +def validate_oauth_state(session_id: str, state: Optional[str]) -> bool: + """ + Validate the OAuth ``state`` returned in the callback against the value + bound to the verification session. + + Uses a constant-time comparison and rejects missing/expired sessions or any + mismatch. Does not consume the session (the session itself is consumed by + :func:`find_user_by_session_and_verify`). + """ + _cleanup_expired_sessions() + + if not state: + logger.warning(f"OAuth state missing in callback for session ID: {session_id}") + return False + + session_data = _verification_sessions.get(session_id) + if not session_data: + logger.warning(f"No verification session found while validating state for session ID: {session_id}") + return False + + _discord_id, _expiry_time, expected_state = session_data + + if not secrets.compare_digest(state, expected_state): + logger.warning(f"OAuth state mismatch for session ID: {session_id}") + return False + + return True + async def find_user_by_session_and_verify( session_id: str, github_id: str, github_username: str, email: Optional[str] ) -> Optional[User]: @@ -77,7 +111,7 @@ async def find_user_by_session_and_verify( logger.warning(f"No verification session found for session ID: {session_id}") return None - discord_id, expiry_time = session_data + discord_id, _expiry_time, _oauth_state = session_data current_time = datetime.now().isoformat() user_res = await supabase.table("users").select("*").eq( @@ -163,7 +197,7 @@ async def get_verification_session_info(session_id: str) -> Optional[Dict[str, s if not session_data: return None - discord_id, expiry_time = session_data + discord_id, expiry_time, _oauth_state = session_data if datetime.now() > expiry_time: del _verification_sessions[session_id] diff --git a/backend/integrations/discord/bot.py b/backend/integrations/discord/bot.py index dbb7c3a4..c637d950 100644 --- a/backend/integrations/discord/bot.py +++ b/backend/integrations/discord/bot.py @@ -4,6 +4,7 @@ from typing import Dict, Any, Optional from app.core.orchestration.queue_manager import AsyncQueueManager, QueuePriority from app.classification.classification_router import ClassificationRouter +from integrations.discord.retry import run_with_retry logger = logging.getLogger(__name__) @@ -38,7 +39,7 @@ async def on_ready(self): logger.info(f'Enhanced Discord bot logged in as {self.user}') print(f'Bot is ready! Logged in as {self.user}') try: - synced = await self.tree.sync() + synced = await run_with_retry(self.tree.sync) print(f"Synced {len(synced)} slash command(s)") except Exception as e: print(f"Failed to sync slash commands: {e}") @@ -102,7 +103,7 @@ async def _handle_devrel_message(self, message, triage_result: Dict[str, Any]): if thread_id: thread = self.get_channel(int(thread_id)) if thread: - await thread.send("I'm processing your request, please hold on...") + await run_with_retry(thread.send, "I'm processing your request, please hold on...") # ------------------------------------ except Exception as e: @@ -121,9 +122,9 @@ async def _get_or_create_thread(self, message, user_id: str) -> Optional[str]: # This part only runs if it's not a follow-up message in an active thread. if isinstance(message.channel, discord.TextChannel): thread_name = f"DevRel Chat - {message.author.display_name}" - thread = await message.create_thread(name=thread_name, auto_archive_duration=60) + thread = await run_with_retry(message.create_thread, name=thread_name, auto_archive_duration=60) self.active_threads[user_id] = str(thread.id) - await thread.send(f"Hello {message.author.mention}! I've created this thread to help you. How can I assist?") + await run_with_retry(thread.send, f"Hello {message.author.mention}! I've created this thread to help you. How can I assist?") return str(thread.id) except Exception as e: logger.error(f"Failed to create thread: {e}") @@ -138,7 +139,7 @@ async def _handle_agent_response(self, response_data: Dict[str, Any]): thread = self.get_channel(int(thread_id)) if thread: for i in range(0, len(response_text), 2000): - await thread.send(response_text[i:i+2000]) + await run_with_retry(thread.send, response_text[i:i+2000]) else: logger.error(f"Thread {thread_id} not found for agent response") except Exception as e: diff --git a/backend/integrations/discord/cogs.py b/backend/integrations/discord/cogs.py index 64fd0b7f..747bcd47 100644 --- a/backend/integrations/discord/cogs.py +++ b/backend/integrations/discord/cogs.py @@ -142,12 +142,13 @@ async def verify_github(self, interaction: discord.Interaction): await interaction.followup.send(embed=embed, ephemeral=True) return - session_id = await create_verification_session(str(interaction.user.id)) - if not session_id: + session = await create_verification_session(str(interaction.user.id)) + if not session: raise Exception("Failed to create verification session.") + session_id, oauth_state = session callback_url = f"{settings.backend_url}/v1/auth/callback?session={session_id}" - auth_url_data = await login_with_github(redirect_to=callback_url) + auth_url_data = await login_with_github(redirect_to=callback_url, state=oauth_state) auth_url = auth_url_data.get("url") if not auth_url: raise Exception("Failed to generate OAuth URL.") @@ -485,8 +486,8 @@ async def _send_onboarding_flow(self, user: discord.abc.User) -> str: if show_oauth_button: # Create verification session - session_id = await create_verification_session(str(user.id)) - if not session_id: + session = await create_verification_session(str(user.id)) + if not session: try: await user.send("I couldn't start verification right now. You can use /verify_github anytime.") await user.send(build_encourage_verification_message(reminder_count=1)) @@ -496,10 +497,11 @@ async def _send_onboarding_flow(self, user: discord.abc.User) -> str: except Exception as e: logger.exception(f"Failed to send session failure fallback DM to user {user.id}: {e}") return "session_unavailable" + session_id, oauth_state = session # Generate GitHub OAuth URL via Supabase callback_url = f"{settings.backend_url}/v1/auth/callback?session={session_id}" - auth = await login_with_github(redirect_to=callback_url) + auth = await login_with_github(redirect_to=callback_url, state=oauth_state) auth_url = auth.get("url") if not auth_url: try: diff --git a/backend/integrations/discord/retry.py b/backend/integrations/discord/retry.py new file mode 100644 index 00000000..805a4d1a --- /dev/null +++ b/backend/integrations/discord/retry.py @@ -0,0 +1,194 @@ +import asyncio +import logging +import random +import time +from functools import wraps +from typing import Any, Callable, Coroutine, TypeVar + +import aiohttp +import discord + +logger = logging.getLogger(__name__) + +T = TypeVar("T") + + +def get_discord_retry_after(exc: Exception) -> float | None: + """ + Extracts the recommended retry-after delay (in seconds) from a Discord exception. + + Looks at: + - The `retry_after` attribute of `discord.errors.RateLimited`. + - HTTP response headers (X-RateLimit-Reset-After, Retry-After, X-RateLimit-Reset). + - The JSON response body if present in the exception text. + """ + # 1. Check if the exception has a direct retry_after attribute (e.g. discord.errors.RateLimited) + if hasattr(exc, "retry_after"): + try: + return float(exc.retry_after) + except (ValueError, TypeError): + pass + + # 2. Check if it's an HTTPException with response headers or parsed data + if isinstance(exc, discord.HTTPException): + if hasattr(exc, "response") and exc.response is not None: + headers = exc.response.headers + + # Check X-RateLimit-Reset-After (Discord specific, decimal seconds) + reset_after = headers.get("X-RateLimit-Reset-After") + if reset_after is not None: + try: + return float(reset_after) + except (ValueError, TypeError): + pass + + # Check standard HTTP Retry-After header + retry_after = headers.get("Retry-After") + if retry_after is not None: + try: + return float(retry_after) + except (ValueError, TypeError): + pass + + # Check X-RateLimit-Reset (epoch timestamp) + reset_epoch = headers.get("X-RateLimit-Reset") + if reset_epoch is not None: + try: + delay = float(reset_epoch) - time.time() + if delay > 0: + return delay + except (ValueError, TypeError): + pass + + # Try parsing from exception body text (JSON) + if hasattr(exc, "text") and isinstance(exc.text, str): + import json + + try: + data = json.loads(exc.text) + if isinstance(data, dict) and "retry_after" in data: + return float(data["retry_after"]) + except Exception: + pass + + return None + + +def calculate_backoff(attempt: int, base_delay: float, max_delay: float) -> float: + """ + Calculates exponential backoff delay with full jitter to avoid thundering herds. + """ + temp = min(max_delay, base_delay * (2**attempt)) + return random.uniform(0.1, temp) + + +def is_retriable_exception(exc: Exception) -> bool: + """ + Determines if an exception is transient and should be retried. + + Retriable exceptions: + - Exceptions with a `retry_after` attribute (e.g. RateLimited) + - discord.HTTPException with status 429 (Rate Limited) or 5xx (Server Error) + - discord.DiscordServerError + - aiohttp.ClientError (transient network issues) + - asyncio.TimeoutError (request timeouts) + """ + if hasattr(exc, "retry_after"): + return True + + if isinstance(exc, discord.DiscordServerError): + return True + + if isinstance(exc, discord.HTTPException): + # 429 is Rate Limit, 5xx is server-side errors + if exc.status == 429 or (exc.status is not None and exc.status >= 500): + return True + + if isinstance(exc, (aiohttp.ClientError, asyncio.TimeoutError)): + return True + + return False + + +def discord_retry( + max_retries: int = 5, + base_delay: float = 1.5, + max_delay: float = 60.0, +): + """ + Decorator that applies exponential backoff and Discord-aware rate-limit retries + to asynchronous functions/methods making Discord API requests. + + - If a rate limit (HTTP 429) is hit, it parses the retry-after value from Discord headers + or exception details and sleeps for that exact duration. + If no explicit rate limit duration is found, it uses exponential backoff. + - If a 5xx or transient network error occurs, it applies exponential backoff with full jitter. + - If max_retries is reached, the last exception is re-raised. + """ + + def decorator(func: Callable[..., Coroutine[Any, Any, T]]) -> Callable[..., Coroutine[Any, Any, T]]: + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> T: + attempt = 0 + while True: + try: + return await func(*args, **kwargs) + except Exception as exc: + if not is_retriable_exception(exc): + raise exc + + attempt += 1 + if attempt > max_retries: + logger.error( + f"Max retries ({max_retries}) reached for Discord API call {func.__name__}. Re-raising exception: {exc}" + ) + raise exc + + # Check for explicit Discord rate-limit sleep instruction + retry_after = get_discord_retry_after(exc) + if retry_after is not None: + sleep_time = retry_after + # Add a tiny buffer to avoid edge-case 429 loops (e.g. 100ms) + sleep_time += 0.1 + logger.warning( + f"Discord API Rate Limited (429) in {func.__name__}. " + f"Respecting header: sleeping for {sleep_time:.3f}s. " + f"Attempt {attempt}/{max_retries}." + ) + else: + sleep_time = calculate_backoff(attempt, base_delay, max_delay) + logger.warning( + f"Transient error ({type(exc).__name__}: {exc}) in {func.__name__}. " + f"Applying exponential backoff: sleeping for {sleep_time:.3f}s. " + f"Attempt {attempt}/{max_retries}." + ) + + await asyncio.sleep(sleep_time) + + return wrapper + + return decorator + + +async def run_with_retry( + coro_func: Callable[..., Coroutine[Any, Any, T]], + *args: Any, + max_retries: int = 5, + base_delay: float = 1.5, + max_delay: float = 60.0, + **kwargs: Any, +) -> T: + """ + Wrapper function that executes any arbitrary async callable making a Discord API request, + applying exponential backoff and rate-limit handling. + + Example: + await run_with_retry(thread.send, "Hello world!") + """ + + # We can reuse the decorated version of an anonymous wrapper function + @discord_retry(max_retries=max_retries, base_delay=base_delay, max_delay=max_delay) + async def _execute(): + return await coro_func(*args, **kwargs) + + return await _execute() diff --git a/tests/test_discord_retry.py b/tests/test_discord_retry.py new file mode 100644 index 00000000..9ee4bbeb --- /dev/null +++ b/tests/test_discord_retry.py @@ -0,0 +1,198 @@ +import asyncio +import json +import time +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +import discord +import aiohttp + +from integrations.discord.retry import ( + calculate_backoff, + discord_retry, + get_discord_retry_after, + is_retriable_exception, + run_with_retry, +) + + +def test_get_discord_retry_after_from_attribute(): + """Test extracting retry_after from an exception attribute (like RateLimited).""" + exc = Exception("Rate limited") + exc.retry_after = 2.5 + assert get_discord_retry_after(exc) == 2.5 + + +def test_get_discord_retry_after_from_headers(): + """Test extracting retry_after from HTTPException response headers.""" + # Mock response and headers + response = MagicMock(spec=aiohttp.ClientResponse) + response.headers = { + "X-RateLimit-Reset-After": "1.75", + "Retry-After": "3.0", + } + + # HTTPException mock + exc = discord.HTTPException(response, "Too Many Requests") + assert get_discord_retry_after(exc) == 1.75 + + +def test_get_discord_retry_after_from_retry_after_header(): + """Test extracting retry_after when only Retry-After header is present.""" + response = MagicMock(spec=aiohttp.ClientResponse) + response.headers = { + "Retry-After": "4.2", + } + + exc = discord.HTTPException(response, "Too Many Requests") + assert get_discord_retry_after(exc) == 4.2 + + +def test_get_discord_retry_after_from_json_body(): + """Test extracting retry_after from the exception text body.""" + response = MagicMock(spec=aiohttp.ClientResponse) + response.headers = {} + + exc = discord.HTTPException(response, "Too Many Requests") + exc.text = json.dumps({"retry_after": 0.350}) + assert get_discord_retry_after(exc) == 0.350 + + +def test_get_discord_retry_after_none(): + """Test that None is returned if no rate limit headers/attributes exist.""" + exc = ValueError("Some other error") + assert get_discord_retry_after(exc) is None + + +def test_calculate_backoff(): + """Test calculation of exponential backoff with jitter.""" + for attempt in range(1, 4): + delay = calculate_backoff(attempt, base_delay=1.0, max_delay=10.0) + assert 0.1 <= delay <= 10.0 + + +def test_is_retriable_exception(): + """Test logic for identifying transient/retriable errors.""" + # 1. DiscordServerError + response = MagicMock(spec=aiohttp.ClientResponse) + server_err = discord.DiscordServerError(response, "Internal Server Error") + assert is_retriable_exception(server_err) is True + + # 2. HTTPException with 429 + response_429 = MagicMock(spec=aiohttp.ClientResponse) + response_429.status = 429 + exc_429 = discord.HTTPException(response_429, "Too Many Requests") + assert is_retriable_exception(exc_429) is True + + # 3. HTTPException with 502 + response_502 = MagicMock(spec=aiohttp.ClientResponse) + response_502.status = 502 + exc_502 = discord.HTTPException(response_502, "Bad Gateway") + assert is_retriable_exception(exc_502) is True + + # 4. HTTPException with 403 (Forbidden - NOT retriable) + response_403 = MagicMock(spec=aiohttp.ClientResponse) + response_403.status = 403 + exc_403 = discord.HTTPException(response_403, "Forbidden") + assert is_retriable_exception(exc_403) is False + + # 5. ClientError and TimeoutError + assert is_retriable_exception(aiohttp.ClientError("Network drop")) is True + assert is_retriable_exception(asyncio.TimeoutError()) is True + + +@pytest.mark.asyncio +async def test_discord_retry_success_first_try(): + """Test decorator works directly when no exception occurs.""" + mock_func = AsyncMock(return_value="success") + + decorated = discord_retry(max_retries=3, base_delay=0.1)(mock_func) + result = await decorated("arg1", kwarg1="val") + + assert result == "success" + mock_func.assert_called_once_with("arg1", kwarg1="val") + + +@pytest.mark.asyncio +async def test_discord_retry_success_after_rate_limit(): + """Test decorator retries after hitting rate limit exception, parsing headers.""" + # Create mock exception with retry_after attribute + rate_limit_exc = Exception("Rate limited") + rate_limit_exc.retry_after = 0.05 + + # Async function that fails first time then succeeds + call_count = 0 + + @discord_retry(max_retries=3, base_delay=0.1) + async def sample_func(): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise rate_limit_exc + return "finally_success" + + with patch("asyncio.sleep") as mock_sleep: + result = await sample_func() + + assert result == "finally_success" + assert call_count == 2 + mock_sleep.assert_called_once() + args, _ = mock_sleep.call_args + assert args[0] == pytest.approx(0.15) + + +@pytest.mark.asyncio +async def test_discord_retry_success_after_transient_error(): + """Test decorator retries after hitting a transient 502 error.""" + response = MagicMock(spec=aiohttp.ClientResponse) + response.status = 502 + transient_exc = discord.HTTPException(response, "Bad Gateway") + + call_count = 0 + + @discord_retry(max_retries=3, base_delay=0.1) + async def sample_func(): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise transient_exc + return "success" + + with patch("asyncio.sleep") as mock_sleep: + result = await sample_func() + + assert result == "success" + assert call_count == 2 + mock_sleep.assert_called_once() + # Verify it slept some float value + args, _ = mock_sleep.call_args + assert args[0] > 0 + + +@pytest.mark.asyncio +async def test_discord_retry_max_retries_exceeded(): + """Test decorator re-raises exception when max_retries limit is exceeded.""" + response = MagicMock(spec=aiohttp.ClientResponse) + response.status = 502 + exc = discord.HTTPException(response, "Bad Gateway") + + @discord_retry(max_retries=2, base_delay=0.01) + async def sample_func(): + raise exc + + with patch("asyncio.sleep") as mock_sleep: + with pytest.raises(discord.HTTPException) as err: + await sample_func() + + assert err.value.status == 502 + assert mock_sleep.call_count == 2 + + +@pytest.mark.asyncio +async def test_run_with_retry_wrapper(): + """Test the run_with_retry function wrapper.""" + mock_func = AsyncMock(return_value="wrapper_success") + + result = await run_with_retry(mock_func, "arg", max_retries=2, base_delay=0.01) + assert result == "wrapper_success" + mock_func.assert_called_once_with("arg") diff --git a/tests/test_oauth_verification.py b/tests/test_oauth_verification.py new file mode 100644 index 00000000..bdfa0fea --- /dev/null +++ b/tests/test_oauth_verification.py @@ -0,0 +1,94 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from datetime import datetime, timedelta +from app.services.auth.verification import ( + create_verification_session, + validate_oauth_state, + find_user_by_session_and_verify, + get_verification_session_info, + _verification_sessions +) + +@pytest.fixture(autouse=True) +def clean_sessions(): + _verification_sessions.clear() + yield + _verification_sessions.clear() + +@pytest.mark.asyncio +async def test_create_verification_session_success(): + mock_supabase = MagicMock() + mock_execute = AsyncMock() + mock_execute.data = [{"id": "user-123"}] + + mock_table = MagicMock() + mock_update = MagicMock() + mock_eq = MagicMock() + + mock_supabase.table.return_value = mock_table + mock_table.update.return_value = mock_update + mock_update.eq.return_value = mock_eq + mock_eq.execute = mock_execute + + with patch("app.services.auth.verification.get_supabase_client", return_value=mock_supabase): + result = await create_verification_session("discord-id-123") + assert result is not None + session_id, oauth_state = result + assert len(session_id) > 0 + assert len(oauth_state) > 0 + + # Verify it was stored in memory sessions + assert session_id in _verification_sessions + discord_id, expiry, state = _verification_sessions[session_id] + assert discord_id == "discord-id-123" + assert state == oauth_state + +@pytest.mark.asyncio +async def test_validate_oauth_state(): + mock_supabase = MagicMock() + mock_execute = AsyncMock() + mock_execute.data = [{"id": "user-123"}] + mock_supabase.table.return_value.update.return_value.eq.return_value.execute = mock_execute + + with patch("app.services.auth.verification.get_supabase_client", return_value=mock_supabase): + result = await create_verification_session("discord-id-123") + assert result is not None + session_id, oauth_state = result + + # Validation with correct state + assert validate_oauth_state(session_id, oauth_state) is True + + # Validation with incorrect state + assert validate_oauth_state(session_id, "wrong-state") is False + + # Validation with missing state + assert validate_oauth_state(session_id, None) is False + + # Validation with non-existent session + assert validate_oauth_state("wrong-session-id", oauth_state) is False + +@pytest.mark.asyncio +async def test_get_verification_session_info(): + mock_supabase = MagicMock() + mock_execute = AsyncMock() + mock_execute.data = [{"id": "user-123"}] + mock_supabase.table.return_value.update.return_value.eq.return_value.execute = mock_execute + + with patch("app.services.auth.verification.get_supabase_client", return_value=mock_supabase): + result = await create_verification_session("discord-id-123") + assert result is not None + session_id, _ = result + + info = await get_verification_session_info(session_id) + assert info is not None + assert info["discord_id"] == "discord-id-123" + + # Test expired session + _verification_sessions[session_id] = ( + "discord-id-123", + datetime.now() - timedelta(minutes=1), + "state" + ) + expired_info = await get_verification_session_info(session_id) + assert expired_info is None + assert session_id not in _verification_sessions