diff --git a/README.md b/README.md index 3191c80..a41ffd2 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,17 @@ This will run `setup.py` to create a `cost_sharing` package +## Running the Application + +From the project root directory (where `setup.py` is located): + +```bash +python -m cost_sharing.app +``` + +The app will start on `http://localhost:8000` + + ## Testing Test can be run in the root of the project or from the `tests` folder. They cannot be run in `src` or its subfolders. diff --git a/src/cost_sharing/app.py b/src/cost_sharing/app.py index 8156adb..b22f75a 100644 --- a/src/cost_sharing/app.py +++ b/src/cost_sharing/app.py @@ -1,18 +1,195 @@ -from flask import Flask +import os +import sys +import functools +from flask import Flask, request, jsonify, g, render_template +from dotenv import load_dotenv +from cost_sharing.oauth_handler import ( + OAuthHandler, OAuthCodeError, OAuthVerificationError, + TokenExpiredError, TokenInvalidError +) +from cost_sharing.storage import InMemoryCostStorage +from cost_sharing.cost_sharing import CostSharing +from cost_sharing.exceptions import UserNotFoundError -def create_app(): + +# Ignore "too-many-statements" because this function is going to be long! +def create_app(oauth_handler, application): # pylint: disable=R0915 + """ + Create and configure Flask application. + + Args: + oauth_handler: OAuthHandler instance for OAuth operations + application: CostSharing application layer instance + + Returns: + Configured Flask application + """ app = Flask(__name__) + def require_auth(f): + """ + Decorator to require JWT authentication for a route. + + Extracts and validates JWT token from Authorization header. + Stores user_id in Flask's g object for use in the route handler. + Returns 401 Unauthorized if authentication fails. + """ + @functools.wraps(f) + def decorated_function(*args, **kwargs): + # Extract Authorization header + auth_header = request.headers.get('Authorization') + if not auth_header: + return jsonify({ + "error": "Unauthorized", + "message": "Authentication required" + }), 401 + + # Check if header starts with "Bearer " + if not auth_header.startswith('Bearer '): + return jsonify({ + "error": "Unauthorized", + "message": "Authentication required" + }), 401 + + # Extract token + token = auth_header[7:] # Remove "Bearer " prefix + + try: + # Validate token and get user_id + user_id = oauth_handler.validate_jwt_token(token) + # Store user_id in g for use in route handler + g.user_id = user_id + except (TokenExpiredError, TokenInvalidError): + return jsonify({ + "error": "Unauthorized", + "message": "Authentication required" + }), 401 + + # Call the original route function + return f(*args, **kwargs) + + return decorated_function + @app.route('/') def index(): - return "Hello, World!" + """Serve the demo page.""" + return render_template('index.html') + + @app.route('/auth/login', methods=['GET']) + def auth_login(): + """ + Get Google OAuth authorization URL. + + Returns the URL that the frontend should redirect to for Google OAuth login. + """ + authorization_url, state = oauth_handler.get_authorization_url() + return jsonify({ + "url": authorization_url, + "state": state + }), 200 + + @app.route('/auth/callback', methods=['GET']) + def auth_callback(): + """ + Handle OAuth callback from Google. + + Exchanges authorization code for user info, creates/gets user, and returns JWT token. + Called by JavaScript fetch after Google redirects to main page with code. + """ + # Extract code from query parameters + code = request.args.get('code') + if not code: + return jsonify({ + "error": "Validation failed", + "message": "code parameter is required" + }), 400 + + try: + # Exchange OAuth code for user information + user_info = oauth_handler.exchange_code_for_user_info(code) + email = user_info['email'] + name = user_info['name'] + + # Get or create user in the application + user = application.get_or_create_user(email, name) + + # Create JWT token for the user + token = oauth_handler.create_jwt_token(user.id) + + # Return token and user information as JSON + return jsonify({ + "token": token, + "user": { + "id": user.id, + "email": user.email, + "name": user.name + } + }), 200 + + except OAuthCodeError: + return jsonify({ + "error": "Validation failed", + "message": "Invalid or expired authorization code" + }), 400 + + except OAuthVerificationError: + return jsonify({ + "error": "Unauthorized", + "message": "OAuth verification failed" + }), 401 + + @app.route('/auth/me', methods=['GET']) + @require_auth + def auth_me(): + """ + Get current authenticated user information. + + Requires valid JWT token in Authorization header. + Returns user information (id, email, name). + """ + try: + # Get user_id from g (set by require_auth decorator) + user_id = g.user_id + + # Get user from application layer + user = application.get_user_by_id(user_id) + + # Return user information + return jsonify({ + "id": user.id, + "email": user.email, + "name": user.name + }), 200 + + except UserNotFoundError: + return jsonify({ + "error": "Resource not found", + "message": "User not found" + }), 404 return app # This is "main" for the gunicorn launch and cannot be tested directly def launch(): # pragma: no cover - return create_app() + """Launch the Flask application with environment configuration.""" + # Load environment variables from .env file + load_dotenv() + + # Validate required environment variables + for var in ['BASE_URL', 'GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET', 'JWT_SECRET']: + if not os.getenv(var): + print(f"Error: Missing required environment variable: {var}", file=sys.stderr) + sys.exit(1) + + oauth_handler = OAuthHandler( + base_url=os.getenv('BASE_URL'), + google_client_id=os.getenv('GOOGLE_CLIENT_ID'), + google_client_secret=os.getenv('GOOGLE_CLIENT_SECRET'), + jwt_secret=os.getenv('JWT_SECRET') + ) + + return create_app(oauth_handler, CostSharing(InMemoryCostStorage())) # This is "main" for the local launch and can be tested directly diff --git a/src/cost_sharing/oauth_handler.py b/src/cost_sharing/oauth_handler.py index 17371a1..32c7aa9 100644 --- a/src/cost_sharing/oauth_handler.py +++ b/src/cost_sharing/oauth_handler.py @@ -39,7 +39,7 @@ def __init__(self, base_url, google_client_id, google_client_secret, jwt_secret) google_client_secret: Google OAuth client secret jwt_secret: Secret key for JWT token signing """ - self.redirect_uri = f"{base_url}/auth/callback" + self.redirect_uri = f"{base_url}/" self.google_client_id = google_client_id self.google_client_secret = google_client_secret self.jwt_secret = jwt_secret diff --git a/src/cost_sharing/static/css/style.css b/src/cost_sharing/static/css/style.css new file mode 100644 index 0000000..88433ab --- /dev/null +++ b/src/cost_sharing/static/css/style.css @@ -0,0 +1,130 @@ +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + max-width: 600px; + margin: 50px auto; + padding: 20px; + background-color: #f5f5f5; +} + +.container { + background: white; + border-radius: 8px; + padding: 30px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +h1 { + margin-top: 0; + color: #333; +} + +.status { + padding: 12px; + border-radius: 4px; + margin-bottom: 20px; + font-weight: 500; +} + +.status.logged-in { + background-color: #d4edda; + color: #155724; + border: 1px solid #c3e6cb; +} + +.status.logged-out { + background-color: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; +} + +.user-info { + background-color: #f8f9fa; + padding: 20px; + border-radius: 4px; + margin-bottom: 20px; +} + +.user-info h2 { + margin-top: 0; + font-size: 1.2em; + color: #495057; +} + +.user-info p { + margin: 8px 0; + color: #6c757d; +} + +.user-info strong { + color: #212529; +} + +.token-display { + margin-top: 15px; + padding-top: 15px; + border-top: 1px solid #dee2e6; +} + +.token-display summary { + cursor: pointer; + color: #6c757d; + font-size: 0.9em; + user-select: none; +} + +.token-display summary:hover { + color: #495057; +} + +.token-display code { + display: block; + margin-top: 8px; + padding: 8px; + background-color: #e9ecef; + border-radius: 4px; + font-size: 0.85em; + word-break: break-all; + color: #212529; +} + +button { + background-color: #007bff; + color: white; + border: none; + padding: 10px 20px; + border-radius: 4px; + cursor: pointer; + font-size: 1em; + margin-right: 10px; + margin-bottom: 10px; +} + +button:hover { + background-color: #0056b3; +} + +button:active { + background-color: #004085; +} + +button.logout { + background-color: #dc3545; +} + +button.logout:hover { + background-color: #c82333; +} + +.error { + background-color: #f8d7da; + color: #721c24; + padding: 12px; + border-radius: 4px; + margin-bottom: 20px; + border: 1px solid #f5c6cb; +} + +.hidden { + display: none; +} + diff --git a/src/cost_sharing/static/js/script.js b/src/cost_sharing/static/js/script.js new file mode 100644 index 0000000..9c6f531 --- /dev/null +++ b/src/cost_sharing/static/js/script.js @@ -0,0 +1,150 @@ +const TOKEN_KEY = 'cost_sharing_token'; +const API_BASE = ''; + +let currentToken = null; + +function init() { + // Check for token in localStorage + currentToken = localStorage.getItem(TOKEN_KEY); + + // Check if we're handling an OAuth callback + const urlParams = new URLSearchParams(window.location.search); + const code = urlParams.get('code'); + + if (code) { + handleCallback(code); + } else if (currentToken) { + showLoggedInState(); + fetchUserInfo(); + } else { + showLoggedOutState(); + } +} + +function handleCallback(code) { + fetch(`${API_BASE}/auth/callback?code=${encodeURIComponent(code)}`) + .then(response => response.json()) + .then(data => { + if (data.token) { + // Store token + localStorage.setItem(TOKEN_KEY, data.token); + currentToken = data.token; + + // Clean URL (remove query params) + window.history.replaceState({}, document.title, window.location.pathname); + + // Show logged in state + showLoggedInState(); + fetchUserInfo(); + } else { + showError(data.message || 'Authentication failed'); + showLoggedOutState(); + } + }) + .catch(error => { + console.error('Error:', error); + showError('Failed to complete authentication'); + showLoggedOutState(); + }); +} + +function login() { + fetch(`${API_BASE}/auth/login`) + .then(response => response.json()) + .then(data => { + if (data.url) { + // Redirect to Google OAuth + window.location.href = data.url; + } else { + showError('Failed to get login URL'); + } + }) + .catch(error => { + console.error('Error:', error); + showError('Failed to initiate login'); + }); +} + +function fetchUserInfo() { + if (!currentToken) { + return; + } + + fetch(`${API_BASE}/auth/me`, { + headers: { + 'Authorization': `Bearer ${currentToken}` + } + }) + .then(response => { + if (!response.ok) { + if (response.status === 401) { + // Token invalid, clear it + logout(); + throw new Error('Authentication failed'); + } + return response.json().then(data => { + throw new Error(data.message || 'Failed to fetch user info'); + }); + } + return response.json(); + }) + .then(user => { + displayUserInfo(user); + }) + .catch(error => { + console.error('Error:', error); + showError(error.message || 'Failed to fetch user info'); + }); +} + +function logout() { + localStorage.removeItem(TOKEN_KEY); + currentToken = null; + showLoggedOutState(); +} + +function displayUserInfo(user) { + document.getElementById('user-id').textContent = user.id; + document.getElementById('user-email').textContent = user.email; + document.getElementById('user-name').textContent = user.name; + document.getElementById('user-token').textContent = currentToken; +} + +function showLoggedInState() { + document.getElementById('status').textContent = '✓ Logged In'; + document.getElementById('status').className = 'status logged-in'; + document.getElementById('login-section').classList.add('hidden'); + document.getElementById('user-section').classList.remove('hidden'); +} + +function showLoggedOutState() { + document.getElementById('status').textContent = 'Not Logged In'; + document.getElementById('status').className = 'status logged-out'; + document.getElementById('login-section').classList.remove('hidden'); + document.getElementById('user-section').classList.add('hidden'); +} + +function showError(message) { + let errorDiv = document.getElementById('error'); + if (!errorDiv) { + errorDiv = document.createElement('div'); + errorDiv.id = 'error'; + errorDiv.className = 'error'; + document.querySelector('.container').insertBefore(errorDiv, document.querySelector('.container').firstChild); + } + errorDiv.textContent = message; + errorDiv.classList.remove('hidden'); + + // Hide error after 5 seconds + setTimeout(() => { + errorDiv.classList.add('hidden'); + }, 5000); +} + +// Initialize when DOM is loaded +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); +} else { + init(); +} + diff --git a/src/cost_sharing/templates/index.html b/src/cost_sharing/templates/index.html new file mode 100644 index 0000000..6030909 --- /dev/null +++ b/src/cost_sharing/templates/index.html @@ -0,0 +1,38 @@ + + + + + + Cost Sharing Demo + + + +
+

Cost Sharing Demo

+
Loading...
+ +
+ +
+ + +
+ + + + diff --git a/tests/cost_sharing_mock.py b/tests/cost_sharing_mock.py new file mode 100644 index 0000000..3100aa2 --- /dev/null +++ b/tests/cost_sharing_mock.py @@ -0,0 +1,112 @@ +"""Mock implementation of CostSharing for testing""" + +from cost_sharing.models import User + + +class CostSharingMock: + """ + Mock implementation of CostSharing for testing. + + This mock allows you to configure behavior for testing different scenarios. + Use the configuration methods to set up the desired behavior before passing + the mock to create_app(). + """ + + def __init__(self): + """Initialize the mock.""" + # Configuration for get_or_create_user + self._get_or_create_result = None + self._get_or_create_exception = None + + # Configuration for get_user_by_id + self._get_user_by_id_result = None + self._get_user_by_id_exception = None + + def get_or_create_user_returns(self, user_id, email, name): + """ + Configure get_or_create_user to return successfully. + + Args: + user_id: User ID to return + email: Email to return + name: Name to return + """ + self._get_or_create_result = User(id=user_id, email=email, name=name) + self._get_or_create_exception = None + + def get_or_create_user_raises(self, exception): + """ + Configure get_or_create_user to raise an exception. + + Args: + exception: Exception instance to raise + """ + self._get_or_create_result = None + self._get_or_create_exception = exception + + def get_user_by_id_returns(self, user_id, email, name): + """ + Configure get_user_by_id to return successfully. + + Args: + user_id: User ID to return + email: Email to return + name: Name to return + """ + self._get_user_by_id_result = User(id=user_id, email=email, name=name) + self._get_user_by_id_exception = None + + def get_user_by_id_raises(self, exception): + """ + Configure get_user_by_id to raise an exception. + + Args: + exception: Exception instance to raise (UserNotFoundError) + """ + self._get_user_by_id_result = None + self._get_user_by_id_exception = exception + + def get_or_create_user(self, email, name): # pylint: disable=W0613 + """ + Get existing user or create new user (mock implementation). + + Args: + email: User's email (ignored, uses configured behavior) + name: User's name (ignored, uses configured behavior) + + Returns: + User object from configured result + + Raises: + Exception: If configured to raise an exception + """ + if self._get_or_create_exception is not None: + raise self._get_or_create_exception + + if self._get_or_create_result is None: + # Default behavior if not configured + return User(id=1, email="default@example.com", name="Default User") + + return self._get_or_create_result + + def get_user_by_id(self, user_id): # pylint: disable=W0613 + """ + Get user by their ID (mock implementation). + + Args: + user_id: User ID (ignored, uses configured behavior) + + Returns: + User object from configured result + + Raises: + UserNotFoundError: If configured to raise UserNotFoundError + """ + if self._get_user_by_id_exception is not None: + raise self._get_user_by_id_exception + + if self._get_user_by_id_result is None: + # Default behavior if not configured + return User(id=1, email="default@example.com", name="Default User") + + return self._get_user_by_id_result diff --git a/tests/oauth_handler_mock.py b/tests/oauth_handler_mock.py new file mode 100644 index 0000000..cba9833 --- /dev/null +++ b/tests/oauth_handler_mock.py @@ -0,0 +1,129 @@ +"""Mock implementation of OAuthHandler for testing""" + +class OAuthHandlerMock: + """ + Mock implementation of OAuthHandler for testing. + + This mock allows you to configure behavior for testing different scenarios. + Use the configuration methods to set up the desired behavior before passing + the mock to create_app(). + """ + + def __init__(self): + """Initialize the mock.""" + # Configuration for exchange_code_for_user_info + self._exchange_code_result = None + self._exchange_code_exception = None + + # Configuration for validate_jwt_token + self._validate_token_result = None + self._validate_token_exception = None + + def exchange_code_returns(self, email, name): + """ + Configure exchange_code_for_user_info to return successfully. + + Args: + email: Email to return + name: Name to return + """ + self._exchange_code_result = {"email": email, "name": name} + self._exchange_code_exception = None + + def exchange_code_raises(self, exception): + """ + Configure exchange_code_for_user_info to raise an exception. + + Args: + exception: Exception instance to raise (OAuthCodeError or OAuthVerificationError) + """ + self._exchange_code_result = None + self._exchange_code_exception = exception + + def validate_token_returns(self, user_id): + """ + Configure validate_jwt_token to return successfully. + + Args: + user_id: User ID to return + """ + self._validate_token_result = user_id + self._validate_token_exception = None + + def validate_token_raises(self, exception): + """ + Configure validate_jwt_token to raise an exception. + + Args: + exception: Exception instance to raise (TokenExpiredError or TokenInvalidError) + """ + self._validate_token_result = None + self._validate_token_exception = exception + + def get_authorization_url(self): + """ + Get Google OAuth authorization URL (mock implementation). + + Returns: + Tuple of (authorization_url, state) with dummy values + """ + return ("https://accounts.google.com/o/oauth2/auth?dummy=true", "dummy-state-123") + + def exchange_code_for_user_info(self, oauth_code): # pylint: disable=W0613 + """ + Exchange OAuth authorization code for user information (mock implementation). + + Args: + oauth_code: Authorization code (ignored, uses configured behavior) + + Returns: + Dict with keys: email, name + + Raises: + OAuthCodeError: If configured to raise OAuthCodeError + OAuthVerificationError: If configured to raise OAuthVerificationError + """ + if self._exchange_code_exception is not None: + raise self._exchange_code_exception + + if self._exchange_code_result is None: + # Default behavior if not configured + return {"email": "default@example.com", "name": "Default User"} + + return self._exchange_code_result + + def create_jwt_token(self, user_id, expiration_days=7): # pylint: disable=W0613 + """ + Create a JWT token for authenticated user (mock implementation). + + Args: + user_id: User ID to encode in token + expiration_days: Number of days until token expires (ignored in mock) + + Returns: + Dummy JWT token string + """ + return f"dummy-jwt-token-for-user-{user_id}" + + def validate_jwt_token(self, token): # pylint: disable=W0613 + """ + Validate JWT token and extract user ID (mock implementation). + + Args: + token: JWT token string (ignored, uses configured behavior) + + Returns: + user_id from configured result + + Raises: + TokenExpiredError: If configured to raise TokenExpiredError + TokenInvalidError: If configured to raise TokenInvalidError + """ + if self._validate_token_exception is not None: + raise self._validate_token_exception + + if self._validate_token_result is None: + # Default behavior if not configured + return 1 + + return self._validate_token_result diff --git a/tests/test_app.py b/tests/test_app.py index fbd68f1..14d02a6 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,14 +1,213 @@ - import pytest +from oauth_handler_mock import OAuthHandlerMock +from cost_sharing_mock import CostSharingMock from cost_sharing.app import create_app +from cost_sharing.oauth_handler import ( + OAuthCodeError, OAuthVerificationError, + TokenExpiredError, TokenInvalidError +) +from cost_sharing.exceptions import UserNotFoundError @pytest.fixture(name='client') def create_client(): - app = create_app() + """Create Flask test client with mocked dependencies.""" + # Create mocks for dependencies + oauth_handler = OAuthHandlerMock() + application = CostSharingMock() + + app = create_app(oauth_handler, application) + return app.test_client() + + +@pytest.fixture(name='oauth_handler') +def create_oauth_handler(): + """Create OAuth handler mock for test configuration.""" + return OAuthHandlerMock() + + +@pytest.fixture(name='application') +def create_application(): + """Create application mock for test configuration.""" + return CostSharingMock() + + +@pytest.fixture(name='configured_client') +def create_configured_client(oauth_handler, application): + """Create Flask test client with configured mocks.""" + app = create_app(oauth_handler, application) return app.test_client() + def test_index(client): + """Test that index route returns the demo page HTML.""" response = client.get('/') assert response.status_code == 200 - assert response.data.decode('utf-8') == "Hello, World!" + html = response.data.decode('utf-8') + assert 'Cost Sharing Demo' in html + assert 'style.css' in html + assert 'script.js' in html + + +def test_auth_callback_success(configured_client, oauth_handler, application): + """Test successful OAuth callback.""" + # Configure mocks + oauth_handler.exchange_code_returns("test@example.com", "Test User") + application.get_or_create_user_returns(1, "test@example.com", "Test User") + + # Make request + response = configured_client.get('/auth/callback?code=test123') + + # Verify response + assert response.status_code == 200 + data = response.get_json() + assert data['token'] == "dummy-jwt-token-for-user-1" + assert data['user']['id'] == 1 + assert data['user']['email'] == "test@example.com" + assert data['user']['name'] == "Test User" + + +def test_auth_callback_missing_code(configured_client): + """Test OAuth callback with missing code parameter.""" + response = configured_client.get('/auth/callback') + + assert response.status_code == 400 + data = response.get_json() + assert data['error'] == "Validation failed" + assert data['message'] == "code parameter is required" + + +def test_auth_callback_invalid_code(configured_client, oauth_handler): + """Test OAuth callback with invalid authorization code.""" + # Configure mocks + oauth_handler.exchange_code_raises(OAuthCodeError("Invalid code")) + + # Make request + response = configured_client.get('/auth/callback?code=invalid') + + # Verify response + assert response.status_code == 400 + data = response.get_json() + assert data['error'] == "Validation failed" + assert data['message'] == "Invalid or expired authorization code" + + +def test_auth_callback_verification_error(configured_client, oauth_handler): + """Test OAuth callback with verification error.""" + # Configure mocks + oauth_handler.exchange_code_raises(OAuthVerificationError("Verification failed")) + + # Make request + response = configured_client.get('/auth/callback?code=test123') + + # Verify response + assert response.status_code == 401 + data = response.get_json() + assert data['error'] == "Unauthorized" + assert data['message'] == "OAuth verification failed" + + +def test_auth_me_success(configured_client, oauth_handler, application): + """Test successful /auth/me request.""" + # Configure mocks + oauth_handler.validate_token_returns(1) + application.get_user_by_id_returns(1, "test@example.com", "Test User") + + # Make request with Authorization header + response = configured_client.get( + '/auth/me', + headers={'Authorization': 'Bearer valid-token-123'} + ) + + # Verify response + assert response.status_code == 200 + data = response.get_json() + assert data['id'] == 1 + assert data['email'] == "test@example.com" + assert data['name'] == "Test User" + + +def test_auth_me_missing_header(configured_client): + """Test /auth/me with missing Authorization header.""" + response = configured_client.get('/auth/me') + + assert response.status_code == 401 + data = response.get_json() + assert data['error'] == "Unauthorized" + assert data['message'] == "Authentication required" + + +def test_auth_me_invalid_header_format(configured_client): + """Test /auth/me with invalid Authorization header format.""" + # Missing "Bearer " prefix + response = configured_client.get( + '/auth/me', + headers={'Authorization': 'invalid-token-123'} + ) + + assert response.status_code == 401 + data = response.get_json() + assert data['error'] == "Unauthorized" + assert data['message'] == "Authentication required" + + +def test_auth_me_expired_token(configured_client, oauth_handler): + """Test /auth/me with expired token.""" + # Configure mock to raise TokenExpiredError + oauth_handler.validate_token_raises(TokenExpiredError("Token expired")) + + response = configured_client.get( + '/auth/me', + headers={'Authorization': 'Bearer expired-token'} + ) + + assert response.status_code == 401 + data = response.get_json() + assert data['error'] == "Unauthorized" + assert data['message'] == "Authentication required" + + +def test_auth_me_invalid_token(configured_client, oauth_handler): + """Test /auth/me with invalid token.""" + # Configure mock to raise TokenInvalidError + oauth_handler.validate_token_raises(TokenInvalidError("Invalid token")) + + response = configured_client.get( + '/auth/me', + headers={'Authorization': 'Bearer invalid-token'} + ) + + assert response.status_code == 401 + data = response.get_json() + assert data['error'] == "Unauthorized" + assert data['message'] == "Authentication required" + + +def test_auth_me_user_not_found(configured_client, oauth_handler, application): + """Test /auth/me when user doesn't exist.""" + # Configure mocks + oauth_handler.validate_token_returns(999) # Valid token but user doesn't exist + application.get_user_by_id_raises(UserNotFoundError("User not found")) + + response = configured_client.get( + '/auth/me', + headers={'Authorization': 'Bearer valid-token'} + ) + + assert response.status_code == 404 + data = response.get_json() + assert data['error'] == "Resource not found" + assert data['message'] == "User not found" + + +def test_auth_login_success(configured_client): + """Test /auth/login returns authorization URL.""" + # OAuthHandlerMock.get_authorization_url() returns dummy values + response = configured_client.get('/auth/login') + + assert response.status_code == 200 + data = response.get_json() + assert 'url' in data + assert 'state' in data + assert data['url'] == "https://accounts.google.com/o/oauth2/auth?dummy=true" + assert data['state'] == "dummy-state-123" diff --git a/tests/test_oauth_handler.py b/tests/test_oauth_handler.py index a666995..70e0c26 100644 --- a/tests/test_oauth_handler.py +++ b/tests/test_oauth_handler.py @@ -121,4 +121,4 @@ def test_redirect_uri_computed_from_base_url(): jwt_secret="test-jwt-secret" ) - assert test_handler.redirect_uri == "https://example.com/auth/callback" + assert test_handler.redirect_uri == "https://example.com/"