diff --git a/deployment/cloud-run/debug_api_import.py b/deployment/cloud-run/debug_api_import.py index 9ceee410d..c9bab5585 100644 --- a/deployment/cloud-run/debug_api_import.py +++ b/deployment/cloud-run/debug_api_import.py @@ -17,7 +17,7 @@ print("✅ Flask imported successfully") except Exception as e: print(f"❌ Flask import failed: {e}") - sys.exit(1) + raise RuntimeError(f"Flask import failed: {e}") from e try: print("2. Importing Flask-RESTX...") @@ -25,7 +25,7 @@ print("✅ Flask-RESTX imported successfully") except Exception as e: print(f"❌ Flask-RESTX import failed: {e}") - sys.exit(1) + raise RuntimeError(f"Flask-RESTX import failed: {e}") from e try: print("3. Creating Flask app...") @@ -33,7 +33,7 @@ print("✅ Flask app created successfully") except Exception as e: print(f"❌ Flask app creation failed: {e}") - sys.exit(1) + raise RuntimeError(f"Flask app creation failed: {e}") from e try: print("4. Creating API object...") @@ -47,7 +47,7 @@ print(f"API object: {api}") except Exception as e: print(f"❌ API creation failed: {e}") - sys.exit(1) + raise RuntimeError(f"API creation failed: {e}") from e try: print("5. Testing API decorator...") @@ -59,7 +59,7 @@ def test_handler(error): print(f"❌ API decorator test failed: {e}") print(f"API type at this point: {type(api)}") print(f"API value at this point: {api}") - sys.exit(1) + raise RuntimeError(f"API decorator test failed: {e}") from e try: print("6. Testing namespace creation...") @@ -68,7 +68,7 @@ def test_handler(error): print("✅ Namespace test successful") except Exception as e: print(f"❌ Namespace test failed: {e}") - sys.exit(1) + raise RuntimeError(f"Namespace test failed: {e}") from e print("🎉 All tests passed! The issue is not with basic Flask-RESTX functionality.") diff --git a/deployment/cloud-run/debug_errorhandler.py b/deployment/cloud-run/debug_errorhandler.py index 1e78cfe2f..8c377a5e8 100644 --- a/deployment/cloud-run/debug_errorhandler.py +++ b/deployment/cloud-run/debug_errorhandler.py @@ -17,7 +17,7 @@ print("✅ Imports successful") except Exception as e: print(f"❌ Import failed: {e}") - sys.exit(1) + raise RuntimeError(f"Import failed: {e}") from e try: app = Flask(__name__) @@ -30,7 +30,7 @@ print("✅ API object created successfully") except Exception as e: print(f"❌ API creation failed: {e}") - sys.exit(1) + raise RuntimeError(f"API creation failed: {e}") from e # Let's inspect the API object in detail print(f"\n🔍 API object details:") diff --git a/deployment/cloud-run/debug_errorhandler_detailed.py b/deployment/cloud-run/debug_errorhandler_detailed.py index 2aecdcb8d..f02d184bf 100644 --- a/deployment/cloud-run/debug_errorhandler_detailed.py +++ b/deployment/cloud-run/debug_errorhandler_detailed.py @@ -3,8 +3,11 @@ Detailed debug script to understand the errorhandler issue """ +# ruff: noqa: T201 + import os -os.environ['ADMIN_API_KEY'] = 'test123' +if __name__ == '__main__': + os.environ.setdefault('ADMIN_API_KEY', 'test-admin-key-123') print("🔍 Starting detailed errorhandler debug...") @@ -14,7 +17,7 @@ print("✅ Imports successful") except Exception as e: print(f"❌ Import failed: {e}") - exit(1) + raise RuntimeError(f"Import failed: {e}") from e try: app = Flask(__name__) @@ -22,7 +25,7 @@ print("✅ API object created") except Exception as e: print(f"❌ API creation failed: {e}") - exit(1) + raise RuntimeError(f"API creation failed: {e}") from e # Let's inspect the API object in detail print(f"\n🔍 API object details:") @@ -56,7 +59,7 @@ print(f"Bound call result: {type(result2)} - {result2}") # Let's check if there's a difference - print(f"\nResults are the same: {result == result2}") + print(f"\nSame object: {result is result2}") except Exception as e: print(f"❌ errorhandler testing failed: {e}") @@ -70,9 +73,9 @@ # Let's check if there's a version issue try: - import flask_restx - print(f"\n🔍 Flask-RESTX version: {flask_restx.__version__}") - print(f"Flask version: {flask.__version__}") + from importlib.metadata import version + print(f"\n🔍 Flask-RESTX version: {version('flask-restx')}") + print(f"Flask version: {version('flask')}") except Exception as e: print(f"❌ Could not get versions: {e}") diff --git a/deployment/cloud-run/docs_blueprint.py b/deployment/cloud-run/docs_blueprint.py index 169a6a289..2124037fe 100644 --- a/deployment/cloud-run/docs_blueprint.py +++ b/deployment/cloud-run/docs_blueprint.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +from pathlib import Path from flask import Blueprint, Response, jsonify, render_template, g @@ -11,13 +12,24 @@ def serve_openapi_spec(): """Serve OpenAPI spec for Swagger UI with safe path validation.""" # Restrict spec path to a safe directory - allowed_dir = os.path.abspath(os.environ.get('OPENAPI_ALLOWED_DIR', '/app')) + allowed_dir = Path(os.environ.get('OPENAPI_ALLOWED_DIR', '/app')).resolve() spec_path = os.environ.get('OPENAPI_SPEC_PATH', '/app/openapi.yaml') - abs_spec_path = os.path.abspath(spec_path) + abs_spec_path = Path(spec_path).resolve() try: # Validate that the spec path is within the allowed directory - if os.path.commonpath([abs_spec_path, allowed_dir]) != allowed_dir: + # Use robust containment check compatible with older Python versions + try: + is_contained = abs_spec_path.is_relative_to(allowed_dir) + except AttributeError: + # Fallback for Python < 3.9 + try: + is_contained = (os.path.commonpath([str(allowed_dir), str(abs_spec_path)]) == \ + str(allowed_dir)) + except ValueError: + is_contained = False + + if abs_spec_path.parent != allowed_dir and not is_contained: return jsonify({'error': 'Invalid OpenAPI spec path'}), 400 with open(abs_spec_path, 'r', encoding='utf-8') as f: diff --git a/deployment/cloud-run/health_monitor.py b/deployment/cloud-run/health_monitor.py index 8f681a028..f98786295 100644 --- a/deployment/cloud-run/health_monitor.py +++ b/deployment/cloud-run/health_monitor.py @@ -4,7 +4,6 @@ """ import os -import sys import time import signal import logging @@ -60,7 +59,7 @@ def _graceful_shutdown(self, signum, frame): else: logger.info("Graceful shutdown completed successfully") - sys.exit(0) + raise SystemExit(0) def get_system_metrics(self) -> Dict[str, float]: """Get current system resource usage""" diff --git a/deployment/cloud-run/minimal_test.py b/deployment/cloud-run/minimal_test.py index dffdddac6..3902eca94 100644 --- a/deployment/cloud-run/minimal_test.py +++ b/deployment/cloud-run/minimal_test.py @@ -4,7 +4,7 @@ """ import os -os.environ['ADMIN_API_KEY'] = 'test123' +os.environ.setdefault('ADMIN_API_KEY', os.environ.get('TEST_ADMIN_API_KEY', 'test-admin-key-123')) print("🔍 Starting minimal API setup test...") @@ -15,7 +15,7 @@ print("✅ Imports successful") except Exception as e: print(f"❌ Imports failed: {e}") - exit(1) + raise RuntimeError(f"Imports failed: {e}") from e try: print("2. Creating Flask app...") @@ -23,7 +23,7 @@ print("✅ Flask app created") except Exception as e: print(f"❌ Flask app creation failed: {e}") - exit(1) + raise RuntimeError(f"Flask app creation failed: {e}") from e try: print("3. Creating API object...") @@ -36,7 +36,7 @@ print(f"✅ API object created: {type(api)}") except Exception as e: print(f"❌ API creation failed: {e}") - exit(1) + raise RuntimeError(f"API creation failed: {e}") from e try: print("4. Creating namespace...") @@ -45,7 +45,7 @@ print("✅ Namespace added") except Exception as e: print(f"❌ Namespace creation failed: {e}") - exit(1) + raise RuntimeError(f"Namespace creation failed: {e}") from e try: print("5. Creating model...") @@ -55,18 +55,20 @@ print("✅ Model created") except Exception as e: print(f"❌ Model creation failed: {e}") - exit(1) + raise RuntimeError(f"Model creation failed: {e}") from e try: print("6. Testing errorhandler...") - @api.errorhandler(429) + from werkzeug.exceptions import TooManyRequests + @api.errorhandler(TooManyRequests) def test_handler(error): + """Return a canned 429 for debug validation.""" return {"error": "test"}, 429 print("✅ Error handler created") except Exception as e: print(f"❌ Error handler creation failed: {e}") print(f"API type at this point: {type(api)}") print(f"API errorhandler type: {type(api.errorhandler)}") - exit(1) + raise RuntimeError(f"Error handler creation failed: {e}") from e print("🎉 All tests passed!") \ No newline at end of file diff --git a/deployment/cloud-run/secure_api_server.py b/deployment/cloud-run/secure_api_server.py index beca133e2..bce17dc08 100644 --- a/deployment/cloud-run/secure_api_server.py +++ b/deployment/cloud-run/secure_api_server.py @@ -13,6 +13,7 @@ import hmac from flask import Flask, request, jsonify, g from flask_restx import Api, Resource, fields, Namespace +from werkzeug.exceptions import TooManyRequests, InternalServerError, NotFound, MethodNotAllowed from functools import wraps # Import security modules @@ -26,23 +27,32 @@ ) # Configure logging for Cloud Run +LOG_LEVEL = os.environ.get("LOG_LEVEL", "DEBUG").upper() +log_level = getattr(logging, LOG_LEVEL, logging.DEBUG) logging.basicConfig( - level=logging.INFO, + level=log_level, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) logger = logging.getLogger(__name__) app = Flask(__name__) +# Add detailed logging for Flask-RESTX debugging only in development +is_development = os.environ.get("FLASK_ENV") == "development" or app.debug +if is_development: + werkzeug_logger = logging.getLogger('werkzeug') + werkzeug_logger.setLevel(logging.DEBUG) + # Add security headers add_security_headers(app) # Register root endpoint BEFORE Flask-RESTX initialization to avoid conflicts +logger.info("Registering root endpoint BEFORE Flask-RESTX initialization...") @app.route('/') def home(): # Changed from api_root to home to avoid conflict with Flask-RESTX's root - """Get API status and information""" + """Get API status and information.""" try: - logger.info(f"Root endpoint accessed from {request.remote_addr}") + logger.info("Root endpoint accessed from %s", request.remote_addr) return jsonify({ 'service': 'SAMO Emotion Detection API', 'status': 'operational', @@ -52,30 +62,38 @@ def home(): # Changed from api_root to home to avoid conflict with Flask-RESTX' 'timestamp': time.time() }) except Exception as e: - logger.error(f"Root endpoint error for {request.remote_addr}: {str(e)}") + logger.error("Root endpoint error for %s: %s", request.remote_addr, str(e)) return create_error_response('Internal server error', 500) -# Initialize Flask-RESTX API without Swagger to avoid 500 errors -api = Api( - app, - version='2.0.0', - title='SAMO Emotion Detection API', - description='Secure, production-ready emotion detection API with comprehensive security features', - # Temporarily disable Swagger docs to avoid 500 errors - # doc='/docs', - authorizations={ - 'apikey': { - 'type': 'apiKey', - 'in': 'header', - 'name': 'X-API-Key' - } - }, - security='apikey' -) + +# Initialize Flask-RESTX API with optional Swagger docs +logger.info("Initializing Flask-RESTX API...") +swagger_enabled = os.environ.get('ENABLE_SWAGGER', 'false').lower() == 'true' +try: + api = Api( + app, + version='2.0.0', + title='SAMO Emotion Detection API', + description='Secure, production-ready emotion detection API with comprehensive security features', + doc='/docs' if swagger_enabled else None, + authorizations={ + 'apikey': { + 'type': 'apiKey', + 'in': 'header', + 'name': 'X-API-Key' + } + }, + security='apikey' + ) + logger.info("✅ Flask-RESTX API initialized successfully") +except Exception: + logger.exception("❌ Flask-RESTX API initialization failed") + raise # Create namespaces for better organization +logger.info("Creating namespaces...") main_ns = Namespace('api', description='Main API operations') # Removed leading slash to avoid double slashes -admin_ns = Namespace('/admin', description='Admin operations', authorizations={ +admin_ns = Namespace('admin', description='Admin operations', authorizations={ 'apikey': { 'type': 'apiKey', 'in': 'header', @@ -84,8 +102,10 @@ def home(): # Changed from api_root to home to avoid conflict with Flask-RESTX' }) # Add namespaces to API +logger.info("Adding namespaces to API...") api.add_namespace(main_ns) api.add_namespace(admin_ns) +logger.info("✅ Namespaces added successfully") # Define request/response models for Swagger text_input_model = api.model('TextInput', { @@ -185,7 +205,7 @@ def predict_emotion(text: str) -> dict: result = predict_emotions(text) # Add request ID for tracking - result['request_id'] = str(uuid.uuid4()) + result['request_id'] = getattr(g, 'request_id', str(uuid.uuid4())) return result @@ -257,7 +277,7 @@ class Health(Resource): @api.response(503, 'Service Unavailable', error_model) @api.response(500, 'Internal Server Error', error_model) def get(self): - """Get API health status""" + """Get API health status.""" try: logger.info(f"Health check from {request.remote_addr}") model_status = check_model_loaded() @@ -275,13 +295,13 @@ def get(self): logger.warning("Health check failed - model not ready") return create_error_response('Service unavailable - model not ready', 503) - except Exception as e: - logger.error(f"Health check error for {request.remote_addr}: {str(e)}") + except Exception: + logger.exception("Health check error for %s", request.remote_addr) return create_error_response('Internal server error', 500) @main_ns.route('/predict') class Predict(Resource): - @api.doc('post_predict', security='apikey') + @api.doc('post_predict', security=[{'apikey': []}]) @api.expect(text_input_model, validate=True) @api.response(200, 'Success', emotion_response_model) @api.response(400, 'Bad Request', error_model) @@ -295,7 +315,7 @@ def post(self): try: # Log rate limiting info for debugging log_rate_limit_info() - + # Get and validate input data = request.get_json() if not data or 'text' not in data: @@ -325,12 +345,12 @@ def post(self): return result except Exception as e: - logger.error(f"Prediction error for {request.remote_addr}: {str(e)}") + logger.exception("Prediction error for %s", request.remote_addr) return create_error_response('Internal server error', 500) @main_ns.route('/predict_batch') class PredictBatch(Resource): - @api.doc('post_predict_batch', security='apikey') + @api.doc('post_predict_batch', security=[{'apikey': []}]) @api.expect(batch_input_model, validate=True) @api.response(200, 'Success', batch_response_model) @api.response(400, 'Bad Request', error_model) @@ -366,7 +386,7 @@ def post(self): return create_error_response('Model not ready', 503) # Process each text - logger.info(f"Processing batch prediction request for {request.remote_addr} with {len(texts)} texts") + logger.info("Processing batch prediction request for %s with %d texts", request.remote_addr, len(texts)) results = [] for text in texts: if not text or not isinstance(text, str): @@ -383,7 +403,7 @@ def post(self): return {'results': results} except Exception as e: - logger.error(f"Batch prediction error for {request.remote_addr}: {str(e)}") + logger.exception("Batch prediction error for %s", request.remote_addr) return create_error_response('Internal server error', 500) @main_ns.route('/emotions') @@ -392,7 +412,7 @@ class Emotions(Resource): @api.response(200, 'Success') @api.response(500, 'Internal Server Error', error_model) def get(self): - """Get list of supported emotions""" + """Get list of supported emotions.""" try: logger.info(f"Emotions list requested from {request.remote_addr}") return { @@ -401,7 +421,7 @@ def get(self): 'timestamp': time.time() } except Exception as e: - logger.error(f"Emotions endpoint error for {request.remote_addr}: {str(e)}") + logger.exception("Emotions endpoint error for %s", request.remote_addr) return create_error_response('Internal server error', 500) # Admin endpoints @@ -413,15 +433,15 @@ class ModelStatus(Resource): @api.response(500, 'Internal Server Error', error_model) @require_api_key def get(self): - """Get detailed model status (admin only)""" + """Get detailed model status (admin only).""" try: # Get model status from shared utilities logger.info(f"Admin model status request from {request.remote_addr}") status = get_model_status() return status - except Exception as e: - logger.error(f"Model status error for {request.remote_addr}: {str(e)}") - return create_error_response('Internal server error', 500) + except Exception: + logger.exception("Model status error for %s", request.remote_addr) + return create_error_response('Internal server error', 500) @admin_ns.route('/security_status') class SecurityStatus(Resource): @@ -431,7 +451,7 @@ class SecurityStatus(Resource): @api.response(500, 'Internal Server Error', error_model) @require_api_key def get(self): - """Get security configuration status (admin only)""" + """Get security configuration status (admin only).""" try: logger.info(f"Admin security status request from {request.remote_addr}") return { @@ -442,42 +462,44 @@ def get(self): 'security_headers': True, 'timestamp': time.time() } - except Exception as e: - logger.error(f"Security status error for {request.remote_addr}: {str(e)}") - return create_error_response('Internal server error', 500) + except Exception: + logger.exception("Security status error for %s", request.remote_addr) + return create_error_response('Internal server error', 500) -# Error handlers for Flask-RESTX - using direct registration due to decorator compatibility issue -def rate_limit_exceeded(error): - """Handle rate limit exceeded errors""" + +# Error handlers for Flask-RESTX using proper decorators +@api.errorhandler(TooManyRequests) +def rate_limit_exceeded(error) -> tuple: + """Handle rate limit exceeded errors.""" logger.warning(f"Rate limit exceeded for {request.remote_addr}") return create_error_response('Rate limit exceeded - too many requests', 429) -def internal_error(error): - """Handle internal server errors""" - logger.error(f"Internal server error for {request.remote_addr}: {str(error)}") +@api.errorhandler(InternalServerError) +def internal_error(error) -> tuple: + """Handle internal server errors.""" + logger.exception("Internal server error for %s", request.remote_addr) return create_error_response('Internal server error', 500) -def not_found(error): - """Handle not found errors""" +@api.errorhandler(NotFound) +def not_found(_error) -> tuple: + """Handle not found errors.""" logger.warning(f"Endpoint not found for {request.remote_addr}: {request.url}") return create_error_response('Endpoint not found', 404) -def method_not_allowed(error): - """Handle method not allowed errors""" +@api.errorhandler(MethodNotAllowed) +def method_not_allowed(_error) -> tuple: + """Handle method not allowed errors.""" logger.warning(f"Method not allowed for {request.remote_addr}: {request.method} {request.url}") return create_error_response('Method not allowed', 405) -def handle_unexpected_error(error): - """Handle any unexpected errors""" - logger.error(f"Unexpected error for {request.remote_addr}: {str(error)}") +@api.errorhandler(Exception) +def handle_unexpected_error(error) -> tuple: + """Handle any unexpected errors.""" + logger.exception("Unexpected error for %s", request.remote_addr) return create_error_response('An unexpected error occurred', 500) -# Register error handlers directly -api.error_handlers[429] = rate_limit_exceeded -api.error_handlers[500] = internal_error -api.error_handlers[404] = not_found -api.error_handlers[405] = method_not_allowed -api.error_handlers[Exception] = handle_unexpected_error + +logger.info("✅ Error handlers registered with decorators") def initialize_model(): """Initialize the emotion detection model""" @@ -486,23 +508,30 @@ def initialize_model(): logger.info(f"📊 Configuration: MAX_INPUT_LENGTH={MAX_INPUT_LENGTH}, RATE_LIMIT={RATE_LIMIT_PER_MINUTE}/min") logger.info(f"🔐 Security: API key protection enabled, Admin API key configured") logger.info(f"🌐 Server: Port {PORT}, Model path: {MODEL_PATH}") - logger.info(f"🔄 Rate limiting: {RATE_LIMIT_PER_MINUTE} requests per minute") - + logger.info("🔄 Rate limiting: %s requests per minute", RATE_LIMIT_PER_MINUTE) + + # Log all registered routes for debugging (only in development/debug mode) + if is_development: + logger.info("Final route registration check:") + for rule in app.url_map.iter_rules(): + logger.info(" Route: %s -> %s (methods: %s)", + rule.rule, rule.endpoint, list(rule.methods)) + # Load the emotion detection model logger.info("🔄 Loading emotion detection model...") load_model() logger.info("✅ Model initialization completed successfully") logger.info("🚀 API server ready to handle requests") - - except Exception as e: - logger.error(f"❌ Failed to initialize API server: {str(e)}") + + except Exception: + logger.exception("❌ Failed to initialize API server") raise # Initialize model when the application starts if __name__ == '__main__': initialize_model() logger.info(f"🌐 Starting Flask development server on port {PORT}") - app.run(host='0.0.0.0', port=PORT, debug=False) + app.run(host='127.0.0.1', port=PORT, debug=False, use_reloader=False) else: # For production deployment - don't initialize during import # Model will be initialized when the app actually starts diff --git a/deployment/cloud-run/test_debug_server.py b/deployment/cloud-run/test_debug_server.py new file mode 100644 index 000000000..0a1d300f8 --- /dev/null +++ b/deployment/cloud-run/test_debug_server.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Debug test server to validate Flask-RESTX hypotheses.""" + +import os +import logging +from flask import Flask, request, jsonify +from flask_restx import Api, Resource, Namespace + +# Set up environment variables +os.environ.setdefault('ADMIN_API_KEY', os.environ.get('TEST_ADMIN_API_KEY', 'test-admin-key-123')) + +# Configure detailed logging +logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +app = Flask(__name__) + +# Test 1: Register root endpoint BEFORE Flask-RESTX initialization +logger.info("🔍 Test 1: Registering root endpoint BEFORE Flask-RESTX initialization...") +@app.route('/') +def home(): + """Get API status and information.""" + logger.info("Root endpoint accessed from %s", request.remote_addr) + return jsonify({ + 'service': 'Test API', + 'status': 'operational', + 'timestamp': 1234567890 + }) + +# Test 2: Initialize Flask-RESTX API +logger.info("🔍 Test 2: Initializing Flask-RESTX API...") +try: + api = Api( + app, + version='1.0.0', + title='Test API', + description='Debug test for Flask-RESTX issues', + doc='/docs' # Enable docs to test for 500 errors + ) + logger.info("✅ Flask-RESTX API initialized successfully") +except Exception as e: + logger.error("❌ Flask-RESTX API initialization failed: %s", str(e)) + raise RuntimeError(f"Flask-RESTX API initialization failed: {e}") from e + +# Test 3: Create namespaces - test with and without leading slashes +logger.info("🔍 Test 3: Creating namespaces...") +main_ns = Namespace('api', description='Main operations') # No leading slash +admin_ns = Namespace('admin', description='Admin operations') # No leading slash - fixed + +logger.info("🔍 Adding namespaces to API...") +api.add_namespace(main_ns) +api.add_namespace(admin_ns) +logger.info("✅ Namespaces added successfully") + +# Test 4: Register routes in namespaces +@main_ns.route('/health') +class Health(Resource): + """A Flask-RESTX resource for handling health status requests.""" + + @staticmethod + def get() -> dict: + """Return the health status of the service.""" + return {'status': 'healthy'} + +@admin_ns.route('/status') +class AdminStatus(Resource): + """A Flask-RESTX resource for handling admin status requests.""" + + @staticmethod + def get() -> dict: + """Return the admin status of the service.""" + return {'admin_status': 'ok'} + +# Test 5: Register error handlers +logger.info("🔍 Test 5: Registering error handlers...") + +@api.errorhandler(500) +def test_error_handler(error) -> tuple: + """Handle test errors and return error response.""" + logger.error("Test error handler: %s", str(error)) + return {'error': 'Test error'}, 500 + +@api.errorhandler(Exception) +def exception_error_handler(error) -> tuple: + """Handle general exceptions and return error response.""" + logger.error("Exception error handler: %s", str(error)) + return {'error': 'Exception occurred'}, 500 + +logger.info("✅ Error handlers registered with decorators") + +# Test 6: Log final route state +logger.info("🔍 Test 6: Final route registration check:") +for rule in app.url_map.iter_rules(): + logger.info(" Route: %s -> %s (methods: %s)", rule.rule, rule.endpoint, list(rule.methods)) + +if __name__ == '__main__': + logger.info("🚀 Starting debug test server...") + logger.info("Test endpoints:") + logger.info(" - GET / (root endpoint)") + logger.info(" - GET /docs (Swagger docs - check for 500 errors)") + logger.info(" - GET /api/health (namespace route)") + logger.info(" - GET /admin/status (admin namespace route)") + + app.run(host='127.0.0.1', port=5002, debug=False) + diff --git a/deployment/cloud-run/test_direct_errorhandler.py b/deployment/cloud-run/test_direct_errorhandler.py index 00f16200a..d15ccb124 100644 --- a/deployment/cloud-run/test_direct_errorhandler.py +++ b/deployment/cloud-run/test_direct_errorhandler.py @@ -4,61 +4,76 @@ """ import os -os.environ['ADMIN_API_KEY'] = 'test123' - -print("🔍 Testing direct error handler registration...") - -try: - from flask import Flask - from flask_restx import Api - print("✅ Imports successful") -except Exception as e: - print(f"❌ Import failed: {e}") - exit(1) - -try: - app = Flask(__name__) - api = Api(app, version='1.0.0', title='Test') - print("✅ API object created") -except Exception as e: - print(f"❌ API creation failed: {e}") - exit(1) - -# Let's try to register error handlers directly -try: - print("1. Testing direct error handler registration...") - - def rate_limit_handler(error): - return {"error": "Rate limit exceeded"}, 429 - - def internal_error_handler(error): - return {"error": "Internal server error"}, 500 - - # Try to register directly - api.error_handlers[429] = rate_limit_handler - api.error_handlers[500] = internal_error_handler - - print("✅ Direct registration successful") - print(f"Error handlers: {api.error_handlers}") - -except Exception as e: - print(f"❌ Direct registration failed: {e}") - -# Let's also try using the Flask app's error handler -try: - print("\n2. Testing Flask app error handler...") - - @app.errorhandler(429) - def flask_rate_limit_handler(error): - return {"error": "Rate limit exceeded"}, 429 - - @app.errorhandler(500) - def flask_internal_error_handler(error): - return {"error": "Internal server error"}, 500 - - print("✅ Flask app error handlers registered") - -except Exception as e: - print(f"❌ Flask app error handler failed: {e}") - -print("\n�� Test complete.") \ No newline at end of file +import logging + + +level_name = os.environ.get("LOG_LEVEL", "DEBUG").upper() +logging.basicConfig(level=getattr(logging, level_name, logging.DEBUG)) +logger = logging.getLogger(__name__) + +def _main() -> None: + logger.info("🔍 Testing direct error handler registration...") + + try: + from flask import Flask + from flask_restx import Api + logger.info("✅ Imports successful") + except Exception as e: + logger.exception("❌ Import failed") + raise RuntimeError(f"Import failed: {e}") from e + + try: + app = Flask(__name__) + api = Api(app, version='1.0.0', title='Test') + logger.info("✅ API object created") + except Exception as e: + logger.exception("❌ API creation failed") + raise RuntimeError(f"API creation failed: {e}") from e + + # Let's try to register error handlers with decorators + try: + logger.info("1. Testing error handler registration with decorators...") + + from werkzeug.exceptions import TooManyRequests + + @api.errorhandler(TooManyRequests) + def rate_limit_handler(error) -> tuple: + """Return JSON for 429 errors.""" + return {"error": "Rate limit exceeded"}, 429 + + @api.errorhandler(Exception) + def internal_error_handler(error) -> tuple: + """Return JSON with appropriate status for unhandled errors.""" + status = getattr(error, "code", 500) + return {"error": "Internal server error"}, status + + logger.info("✅ Decorator registration successful") + logger.info( + "Error handlers registered for: %s", + [getattr(k, "__name__", str(k)) for k in api.error_handlers.keys()], + ) + + except Exception as e: + logger.exception("❌ Decorator registration failed: %s", e) + + # Let's also try using the Flask app's error handler + try: + logger.info("2. Testing Flask app error handler...") + + @app.errorhandler(429) + def flask_rate_limit_handler(error): + return {"error": "Rate limit exceeded"}, 429 + + @app.errorhandler(500) + def flask_internal_error_handler(error): + return {"error": "Internal server error"}, 500 + + logger.info("✅ Flask app error handlers registered") + + except Exception as e: + logger.exception("❌ Flask app error handler failed: %s", e) + + logger.info("Test complete.") + +if __name__ == "__main__": + _main() \ No newline at end of file diff --git a/deployment/cloud-run/test_docs_error.py b/deployment/cloud-run/test_docs_error.py index ab387bab1..71ad8311b 100644 --- a/deployment/cloud-run/test_docs_error.py +++ b/deployment/cloud-run/test_docs_error.py @@ -3,39 +3,67 @@ Test script to investigate the Swagger docs 500 error """ +# ruff: noqa: T201 # allow print() in this debug script + import os import requests # Set required environment variables -os.environ['ADMIN_API_KEY'] = 'test-key-123' -os.environ['MAX_INPUT_LENGTH'] = '512' -os.environ['RATE_LIMIT_PER_MINUTE'] = '100' -os.environ['MODEL_PATH'] = '/app/model' -os.environ['PORT'] = '8082' # Different port +os.environ.setdefault('ADMIN_API_KEY', os.environ.get('TEST_ADMIN_API_KEY', 'test-admin-key-123')) +os.environ.setdefault('MAX_INPUT_LENGTH', '512') +os.environ.setdefault('RATE_LIMIT_PER_MINUTE', '100') +os.environ.setdefault('MODEL_PATH', '/app/model') +os.environ.setdefault('PORT', '8082') # Different port +os.environ.setdefault('ENABLE_SWAGGER', 'true') try: from secure_api_server import app - + print("✅ Successfully imported secure_api_server") - + # Start server in background import threading + import traceback + server_failed = threading.Event() + def run_server(): - app.run(host='0.0.0.0', port=8082, debug=False) - + """Run app server for Swagger-docs diagnostics.""" + try: + app.run(host='127.0.0.1', port=8082, debug=False, use_reloader=False) + except Exception as e: + print(f"❌ Server startup failed: {e}") + traceback.print_exc() + server_failed.set() + raise # Re-raise to make failure visible to test harness + server_thread = threading.Thread(target=run_server, daemon=True) server_thread.start() - - # Wait for server to start + + # Wait for server to be ready with polling import time print("🔄 Starting server...") - time.sleep(3) - - # Test docs endpoint specifically - base_url = "http://localhost:8082" - + max_attempts = 30 + base_url = f"http://127.0.0.1:{os.environ.get('PORT', '8082')}" + readiness_url = os.environ.get('READINESS_URL', f"{base_url}/") + for attempt in range(max_attempts): + try: + response = requests.get(readiness_url, timeout=1) + if response.status_code == 200: + print(f"✅ Server is ready! (attempt {attempt+1}/{max_attempts})") + break + except requests.exceptions.RequestException as ex: + print(f"⏳ Not ready yet (attempt {attempt+1}/{max_attempts}): {ex}") + if server_failed.is_set() or not server_thread.is_alive(): + raise RuntimeError("Server thread exited early; see traceback above") + time.sleep(0.1) + else: + print(f"❌ Server failed to start within timeout after {max_attempts} attempts hitting {readiness_url}") + raise RuntimeError("Server failed to start within timeout") + + # Test docs endpoint specifically (reuse base_url from above) + print("\n=== Testing Docs Endpoint ===") - + try: response = requests.get(f"{base_url}/docs", timeout=10) print(f"Status Code: {response.status_code}") diff --git a/deployment/cloud-run/test_minimal_import.py b/deployment/cloud-run/test_minimal_import.py index 1bd62f110..117e8693d 100644 --- a/deployment/cloud-run/test_minimal_import.py +++ b/deployment/cloud-run/test_minimal_import.py @@ -4,7 +4,7 @@ """ import os -os.environ['ADMIN_API_KEY'] = 'test123' +os.environ.setdefault('ADMIN_API_KEY', os.environ.get('TEST_ADMIN_API_KEY', 'test-admin-key-123')) print("🔍 Starting minimal import test...") @@ -15,7 +15,7 @@ print("✅ Basic imports successful") except Exception as e: print(f"❌ Basic imports failed: {e}") - exit(1) + raise RuntimeError(f"Basic imports failed: {e}") from e try: print("2. Creating Flask app...") @@ -23,7 +23,7 @@ print("✅ Flask app created") except Exception as e: print(f"❌ Flask app creation failed: {e}") - exit(1) + raise RuntimeError(f"Flask app creation failed: {e}") from e try: print("3. Creating API object...") @@ -31,7 +31,7 @@ print(f"✅ API object created: {type(api)}") except Exception as e: print(f"❌ API creation failed: {e}") - exit(1) + raise RuntimeError(f"API creation failed: {e}") from e try: print("4. Testing API methods...") @@ -41,15 +41,17 @@ print("✅ API methods check successful") except Exception as e: print(f"❌ API methods check failed: {e}") - exit(1) + raise RuntimeError(f"API methods check failed: {e}") from e try: print("5. Testing errorhandler call...") - result = api.errorhandler(429) - print(f"✅ errorhandler(429) call successful: {type(result)}") + from werkzeug.exceptions import TooManyRequests + result = api.errorhandler(TooManyRequests) + assert callable(result), "Expected a decorator (callable) from api.errorhandler" + print("✅ errorhandler(TooManyRequests) call returned a callable") except Exception as e: - print(f"❌ errorhandler(429) call failed: {e}") + print(f"❌ errorhandler(TooManyRequests) call failed: {e}") print(f"Error type: {type(e)}") - exit(1) + raise RuntimeError(f"errorhandler(TooManyRequests) call failed: {e}") from e print("🎉 All tests passed!") \ No newline at end of file diff --git a/deployment/cloud-run/test_routing_debug.py b/deployment/cloud-run/test_routing_debug.py index a7a53a252..26f3bc41d 100644 --- a/deployment/cloud-run/test_routing_debug.py +++ b/deployment/cloud-run/test_routing_debug.py @@ -3,82 +3,123 @@ Debug script to understand Flask-RESTX routing behavior """ -from flask import Flask, jsonify +from flask import Flask, jsonify, Response from flask_restx import Api, Resource, Namespace - -# Create Flask app -app = Flask(__name__) - -print("=== After Flask app creation ===") -print("App routes:", [rule.rule for rule in app.url_map.iter_rules()]) - -# Initialize Flask-RESTX API -api = Api( - app, - version='1.0.0', - title='Test API', - description='Minimal test to isolate routing issues', - doc='/docs' -) - -print("\n=== After API creation ===") -print("App routes:", [rule.rule for rule in app.url_map.iter_rules()]) - -# Create namespace -main_ns = Namespace('/api', description='Main operations') -api.add_namespace(main_ns) - -print("\n=== After adding namespace ===") -print("App routes:", [rule.rule for rule in app.url_map.iter_rules()]) - -# Test endpoint in namespace -@main_ns.route('/health') -class Health(Resource): - def get(self): - return {'status': 'healthy'} - -print("\n=== After adding namespace route ===") -print("App routes:", [rule.rule for rule in app.url_map.iter_rules()]) - -# Test direct Flask route -@app.route('/test') -def test(): - return jsonify({'message': 'Test route'}) - -print("\n=== After adding Flask route ===") -print("App routes:", [rule.rule for rule in app.url_map.iter_rules()]) - -# Now try to add root endpoint -print("\n=== Trying to add root endpoint ===") -try: - @app.route('/') - def root(): - return jsonify({'message': 'Root endpoint'}) - print("✅ Root endpoint added successfully") -except Exception as e: - print(f"❌ Failed to add root endpoint: {e}") - -print("\n=== Final state ===") -print("App routes:", [rule.rule for rule in app.url_map.iter_rules()]) - -# Check for endpoint name conflicts -endpoints = {} -for rule in app.url_map.iter_rules(): - if rule.endpoint in endpoints: - print(f"⚠️ CONFLICT: Endpoint '{rule.endpoint}' appears multiple times:") - print(f" - {endpoints[rule.endpoint]} -> {rule.rule}") - print(f" - {rule.endpoint} -> {rule.rule}") - else: - endpoints[rule.endpoint] = rule.rule - -print("\n=== All endpoints ===") -for endpoint, rule in endpoints.items(): - print(f"{endpoint} -> {rule}") - -# Check what Flask-RESTX created for the root route -print("\n=== Flask-RESTX root route details ===") -for rule in app.url_map.iter_rules(): - if rule.rule == '/': - print(f"Root route: {rule.rule} -> {rule.endpoint}") - print(f" Methods: {rule.methods}") - print(f" View function: {rule.endpoint}") \ No newline at end of file +import unittest + +class TestAPIRouting(unittest.TestCase): + """Test case for validating Flask-RESTX API routing behavior and endpoint conflicts.""" + + def setUp(self): + """Set up test fixtures and mock objects for API routing tests.""" + # Create Flask app + self.app = Flask(__name__) + + # Register root endpoint BEFORE Flask-RESTX initialization + @self.app.route('/') + def root() -> Response: + """Return the root endpoint message.""" + return jsonify({'message': 'Root endpoint'}) + + # Initialize Flask-RESTX API with real classes (no patching needed for route registration) + self.api = Api( + self.app, + version='1.0.0', + title='Test API', + description='Minimal test to isolate routing issues', + doc='/docs' + ) + + # Create namespace with real class + main_ns = Namespace('api', description='Main operations') + self.api.add_namespace(main_ns) + + # Test endpoint in namespace + @main_ns.route('/health') + class _Health(Resource): + """A Flask-RESTX resource for handling health check requests.""" + + @staticmethod + def get() -> dict: + """Return health status of the service.""" + return {'status': 'healthy'} + + # Test direct Flask route + @self.app.route('/test') + def test() -> Response: + """Test route that returns a simple JSON response.""" + return jsonify({'message': 'Test route'}) + + def test_routing_58(self): + """Test routing configuration and check for endpoint conflicts.""" + print("\n=== Final state ===") + print("App routes:", [rule.rule for rule in self.app.url_map.iter_rules()]) + + # Check for endpoint name conflicts + endpoints = {} + for rule in self.app.url_map.iter_rules(): + if rule.endpoint in endpoints: + print(f"⚠️ CONFLICT: Endpoint '{rule.endpoint}' appears multiple times:") + print(f" - {endpoints[rule.endpoint]} -> {rule.rule}") + print(f" - {rule.endpoint} -> {rule.rule}") + else: + endpoints[rule.endpoint] = rule.rule + + print("\n=== All endpoints ===") + for endpoint, rule in endpoints.items(): + print(f"{endpoint} -> {rule}") + + # Check what Flask-RESTX created for the root route + print("\n=== Flask-RESTX root route details ===") + for rule in self.app.url_map.iter_rules(): + if rule.rule == '/': + print(f"Root route: {rule.rule} -> {rule.endpoint}") + print(f" Methods: {rule.methods}") + print(f" View function: {rule.endpoint}") + + def test_routing_71(self): + """Test routing behavior for line 71.""" + raise NotImplementedError() + + def test_routing_82(self): + """Test routing behavior for line 82.""" + raise NotImplementedError() + + def test_routing_94(self): + """Test routing behavior for line 94.""" + raise NotImplementedError() + + def test_routing_111(self): + """Test routing behavior for line 111.""" + raise NotImplementedError() + + def test_routing_123(self): + """Test routing behavior for line 123.""" + raise NotImplementedError() + + def test_routing_139(self): + """Test routing behavior for line 139.""" + raise NotImplementedError() + + def test_routing_151(self): + """Test routing behavior for line 151.""" + raise NotImplementedError() + + def test_routing_161(self): + """Test routing behavior for line 161.""" + raise NotImplementedError() + + def test_routing_174(self): + """Test routing behavior for line 174.""" + raise NotImplementedError() + + def test_routing_187(self): + """Test routing behavior for line 187.""" + raise NotImplementedError() + + def test_routing_200(self): + """Test routing behavior for line 200.""" + raise NotImplementedError() + +if __name__ == '__main__': + unittest.main() diff --git a/deployment/cloud-run/test_routing_fixed.py b/deployment/cloud-run/test_routing_fixed.py index dc3e579f5..9dcf24d53 100644 --- a/deployment/cloud-run/test_routing_fixed.py +++ b/deployment/cloud-run/test_routing_fixed.py @@ -4,54 +4,30 @@ """ import os +import logging +from pathlib import Path + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) # Set required environment variables -os.environ['ADMIN_API_KEY'] = 'test-key-123' -os.environ['MAX_INPUT_LENGTH'] = '512' -os.environ['RATE_LIMIT_PER_MINUTE'] = '100' -os.environ['MODEL_PATH'] = '/app/model' -os.environ['PORT'] = '8080' +os.environ['ADMIN_API_KEY'] = ( + os.environ.get('ADMIN_API_KEY') + or os.environ.get('TEST_ADMIN_API_KEY') + or 'test-admin-key-123' +) +os.environ['MAX_INPUT_LENGTH'] = os.environ.get('MAX_INPUT_LENGTH') or '512' +os.environ['RATE_LIMIT_PER_MINUTE'] = os.environ.get('RATE_LIMIT_PER_MINUTE') or '100' +os.environ['MODEL_PATH'] = os.environ.get('MODEL_PATH') or '/app/model' +os.environ['PORT'] = os.environ.get('PORT') or '8080' try: + # Make import path robust + import sys + sys.path.insert(0, str(Path(__file__).parent.resolve())) from secure_api_server import app - print("✅ Successfully imported secure_api_server") - - print("\n=== All Routes ===") - for rule in app.url_map.iter_rules(): - print(f"{rule.rule} -> {rule.endpoint}") - - print("\n=== Testing specific endpoints ===") - - # Check if root endpoint exists - root_routes = [rule for rule in app.url_map.iter_rules() if rule.rule == '/'] - if root_routes: - print("✅ Root endpoint (/) exists") - for route in root_routes: - print(f" - {route.endpoint} (methods: {route.methods})") - else: - print("❌ Root endpoint (/) missing") - - # Check if health endpoint exists - health_routes = [rule for rule in app.url_map.iter_rules() if '/health' in rule.rule] - if health_routes: - print("✅ Health endpoint exists") - for route in health_routes: - print(f" - {route.rule} -> {route.endpoint}") - else: - print("❌ Health endpoint missing") - - # Check if docs endpoint exists - docs_routes = [rule for rule in app.url_map.iter_rules() if rule.rule == '/docs'] - if docs_routes: - print("✅ Docs endpoint (/docs) exists") - for route in docs_routes: - print(f" - {route.endpoint} (methods: {route.methods})") - else: - print("❌ Docs endpoint (/docs) missing") - - print("\n✅ Routing test completed successfully!") - + logger.info("Successfully imported secure_api_server") except Exception as e: - print(f"❌ Error testing routing: {e}") - import traceback - traceback.print_exc() \ No newline at end of file + logger.exception("❌ Failed to import secure_api_server: %s", e) + raise RuntimeError(f"Failed to import secure_api_server: {e}") from e + diff --git a/deployment/cloud-run/test_routing_minimal.py b/deployment/cloud-run/test_routing_minimal.py index 73f2ea03e..87795d5be 100644 --- a/deployment/cloud-run/test_routing_minimal.py +++ b/deployment/cloud-run/test_routing_minimal.py @@ -10,6 +10,18 @@ # Create Flask app app = Flask(__name__) +# Register root endpoint BEFORE Flask-RESTX initialization to avoid conflicts +@app.route('/') +def root(): + """Return the root endpoint message.""" + return jsonify({'message': 'Root endpoint'}) + +# Test direct Flask route BEFORE API setup +@app.route('/test_before') +def test_before(): + """Return a test message for routes added before API setup.""" + return jsonify({'message': 'This route was added before API setup'}) + # Initialize Flask-RESTX API api = Api( app, @@ -20,7 +32,7 @@ ) # Create namespace with a different path to avoid conflicts -main_ns = Namespace('/api', description='Main operations') # Changed from '/' to '/api' +main_ns = Namespace('api', description='Main operations') # No leading slash api.add_namespace(main_ns) # Test endpoint in namespace @@ -29,21 +41,11 @@ class Health(Resource): def get(self): return {'status': 'healthy'} -# Test direct Flask route BEFORE API setup -@app.route('/test_before') -def test_before(): - return jsonify({'message': 'This route was added before API setup'}) - # Test direct Flask route AFTER API setup @app.route('/test_after') def test_after(): return jsonify({'message': 'This route was added after API setup'}) -# Test root endpoint - this should work now -@app.route('/') -def root(): - return jsonify({'message': 'Root endpoint'}) - if __name__ == '__main__': print("=== Flask App Routes ===") for rule in app.url_map.iter_rules(): diff --git a/deployment/cloud-run/test_server_start.py b/deployment/cloud-run/test_server_start.py index 19eb6edd1..e9535d17c 100644 --- a/deployment/cloud-run/test_server_start.py +++ b/deployment/cloud-run/test_server_start.py @@ -8,11 +8,11 @@ import requests # Set required environment variables -os.environ['ADMIN_API_KEY'] = 'test-key-123' -os.environ['MAX_INPUT_LENGTH'] = '512' -os.environ['RATE_LIMIT_PER_MINUTE'] = '100' -os.environ['MODEL_PATH'] = '/app/model' -os.environ['PORT'] = '8081' # Different port to avoid conflicts +os.environ.setdefault('ADMIN_API_KEY', os.environ.get('TEST_ADMIN_API_KEY', 'test-admin-key-123')) +os.environ.setdefault('MAX_INPUT_LENGTH', '512') +os.environ.setdefault('RATE_LIMIT_PER_MINUTE', '100') +os.environ.setdefault('MODEL_PATH', '/app/model') +os.environ.setdefault('PORT', '8081') # Different port to avoid conflicts try: from secure_api_server import app @@ -22,7 +22,7 @@ # Start server in background import threading def run_server(): - app.run(host='0.0.0.0', port=8081, debug=False) + app.run(host='127.0.0.1', port=8081, debug=False) server_thread = threading.Thread(target=run_server, daemon=True) server_thread.start() diff --git a/deployment/cloud-run/test_swagger_debug.py b/deployment/cloud-run/test_swagger_debug.py index fdb5b3f40..a081e9c0f 100644 --- a/deployment/cloud-run/test_swagger_debug.py +++ b/deployment/cloud-run/test_swagger_debug.py @@ -10,6 +10,12 @@ # Create Flask app app = Flask(__name__) +# Register root endpoint BEFORE Flask-RESTX initialization to avoid conflicts +@app.route('/') +def api_root(): # Different function name to avoid conflict + """Return the root endpoint message.""" + return jsonify({'message': 'Root endpoint'}) + # Initialize Flask-RESTX API api = Api( app, @@ -20,7 +26,7 @@ ) # Create namespace -main_ns = Namespace('/api', description='Main operations') +main_ns = Namespace('api', description='Main operations') api.add_namespace(main_ns) # Test endpoint in namespace @@ -29,11 +35,6 @@ class Health(Resource): def get(self): return {'status': 'healthy'} -# Override the root route with a different endpoint name -@app.route('/') -def api_root(): # Different function name to avoid conflict - return jsonify({'message': 'Root endpoint'}) - if __name__ == '__main__': print("=== Routes ===") for rule in app.url_map.iter_rules(): diff --git a/deployment/cloud-run/test_swagger_debug_detailed.py b/deployment/cloud-run/test_swagger_debug_detailed.py index 0cb467f87..b8b872a36 100644 --- a/deployment/cloud-run/test_swagger_debug_detailed.py +++ b/deployment/cloud-run/test_swagger_debug_detailed.py @@ -8,11 +8,11 @@ import traceback # Set required environment variables -os.environ['ADMIN_API_KEY'] = 'test-key-123' -os.environ['MAX_INPUT_LENGTH'] = '512' -os.environ['RATE_LIMIT_PER_MINUTE'] = '100' -os.environ['MODEL_PATH'] = '/app/model' -os.environ['PORT'] = '8084' +os.environ.setdefault('ADMIN_API_KEY', os.environ.get('TEST_ADMIN_API_KEY', 'test-admin-key-123')) +os.environ.setdefault('MAX_INPUT_LENGTH', '512') +os.environ.setdefault('RATE_LIMIT_PER_MINUTE', '100') +os.environ.setdefault('MODEL_PATH', '/app/model') +os.environ.setdefault('PORT', '8084') try: from secure_api_server import app @@ -25,17 +25,28 @@ def run_server(): try: - app.run(host='0.0.0.0', port=8084, debug=False) + app.run(host='127.0.0.1', port=8084, debug=False, use_reloader=False) except Exception as e: print(f"❌ Server error: {e}") traceback.print_exc() - + server_thread = threading.Thread(target=run_server, daemon=True) server_thread.start() - - # Wait for server to start + + # Wait for server to be ready with polling print("🔄 Starting server...") - time.sleep(3) + max_attempts = 30 + for attempt in range(max_attempts): + try: + response = requests.get("http://localhost:8084/", timeout=1) + if response.status_code == 200: + print("✅ Server is ready!") + break + except: + pass + time.sleep(0.1) + else: + print("❌ Server failed to start within timeout") # Test docs endpoint with detailed error capture base_url = "http://localhost:8084" diff --git a/deployment/cloud-run/test_swagger_no_model.py b/deployment/cloud-run/test_swagger_no_model.py index 09b350a00..71afd543e 100644 --- a/deployment/cloud-run/test_swagger_no_model.py +++ b/deployment/cloud-run/test_swagger_no_model.py @@ -8,11 +8,11 @@ from flask_restx import Api, Resource, Namespace # Set required environment variables -os.environ['ADMIN_API_KEY'] = 'test-key-123' -os.environ['MAX_INPUT_LENGTH'] = '512' -os.environ['RATE_LIMIT_PER_MINUTE'] = '100' -os.environ['MODEL_PATH'] = '/app/model' -os.environ['PORT'] = '8083' +os.environ.setdefault('ADMIN_API_KEY', os.environ.get('TEST_ADMIN_API_KEY', 'test-admin-key-123')) +os.environ.setdefault('MAX_INPUT_LENGTH', '512') +os.environ.setdefault('RATE_LIMIT_PER_MINUTE', '100') +os.environ.setdefault('MODEL_PATH', '/app/model') +os.environ.setdefault('PORT', '8083') # Create Flask app app = Flask(__name__) @@ -52,4 +52,4 @@ def get(self): print("- http://localhost:8083/docs (should work)") print("- http://localhost:8083/api/health (should work)") - app.run(host='0.0.0.0', port=int(os.environ.get('PORT', 8083)), debug=False) # Debug mode disabled for security \ No newline at end of file + app.run(host='127.0.0.1', port=int(os.environ.get('PORT', 8083)), debug=False) # Debug mode disabled for security \ No newline at end of file diff --git a/deployment/secure_api_server.py b/deployment/secure_api_server.py index bb92d69da..6508e2f87 100644 --- a/deployment/secure_api_server.py +++ b/deployment/secure_api_server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -🔒 SECURE EMOTION DETECTION API SERVER +SECURE EMOTION DETECTION API SERVER ====================================== Production-ready Flask API server with comprehensive security features. @@ -25,22 +25,45 @@ from collections import defaultdict, deque import threading from functools import wraps -import functools +from typing import List, Tuple, Dict, Sequence, Mapping, TypedDict +from ipaddress import ip_address # Import security components using relative imports from ..src.api_rate_limiter import TokenBucketRateLimiter, RateLimitConfig from ..src.input_sanitizer import InputSanitizer, SanitizationConfig from ..src.security_setup import setup_security_middleware, get_environment -# Configure logging +# Type definitions for provider contracts +class Score(TypedDict, total=False): + label: str + score: float + +Distribution = Sequence[Score] +BatchResults = Sequence[Distribution] + +# Configure logging based on environment +log_level = os.environ.get('LOG_LEVEL', 'INFO').upper() +level_obj = getattr(logging, log_level, None) +numeric_level = level_obj if isinstance(level_obj, int) else logging.INFO +if not isinstance(level_obj, int): + logger = logging.getLogger(__name__) + logger.warning("Unknown LOG_LEVEL '%s'; defaulting to INFO", log_level) + +handlers = [logging.StreamHandler()] +if os.environ.get('ENABLE_FILE_LOG') == '1': + from logging.handlers import RotatingFileHandler + log_file = os.environ.get('LOG_FILE', '/tmp/secure_api_server.log') + handlers.append(RotatingFileHandler(log_file, maxBytes=10_000_000, backupCount=5)) logging.basicConfig( - level=logging.INFO, + level=numeric_level, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - handlers=[ - logging.FileHandler('secure_api_server.log'), - logging.StreamHandler() - ] + handlers=handlers ) + +# Configure Werkzeug logging based on environment +werkzeug_logger = logging.getLogger('werkzeug') +werkzeug_logger.setLevel(numeric_level) + logger = logging.getLogger(__name__) # Initialize Flask app @@ -97,7 +120,7 @@ def update_metrics(response_time, success=True, emotion=None, error_type=None, r with metrics_lock: metrics['total_requests'] += 1 metrics['response_times'].append(response_time) - + if rate_limited: metrics['rate_limited_requests'] += 1 elif success: @@ -108,10 +131,10 @@ def update_metrics(response_time, success=True, emotion=None, error_type=None, r metrics['failed_requests'] += 1 if error_type: metrics['error_counts'][error_type] += 1 - + if sanitization_warnings > 0: metrics['sanitization_warnings'] += sanitization_warnings - + # Update average response time if metrics['response_times']: metrics['average_response_time'] = sum(metrics['response_times']) / len(metrics['response_times']) @@ -123,49 +146,49 @@ def decorated_function(*args, **kwargs): start_time = time.time() client_ip = request.remote_addr user_agent = request.headers.get('User-Agent', '') - + try: # Rate limiting allowed, reason, rate_limit_meta = rate_limiter.allow_request(client_ip, user_agent) if not allowed: response_time = time.time() - start_time update_metrics(response_time, success=False, error_type='rate_limited', rate_limited=True) - logger.warning(f"Rate limit exceeded: {reason} from {client_ip}") + logger.warning("Rate limit exceeded: %s from %s", reason, client_ip) return jsonify({ 'error': 'Rate limit exceeded', 'message': reason, 'retry_after': rate_limit_config.window_size_seconds }), 429 - + # Content type validation if request.method == 'POST': content_type = request.headers.get('Content-Type', '') if not input_sanitizer.validate_content_type(content_type): response_time = time.time() - start_time update_metrics(response_time, success=False, error_type='invalid_content_type') - logger.warning(f"Invalid content type: {content_type} from {client_ip}") + logger.warning("Invalid content type: %s from %s", content_type, client_ip) return jsonify({ 'error': 'Invalid content type', 'message': 'Content-Type must be application/json' }), 400 - + # Process request result = f(*args, **kwargs) - + # Release rate limit slot rate_limiter.release_request(client_ip, user_agent) - + return result - except Exception as e: + except Exception as _e: # Release rate limit slot on error rate_limiter.release_request(client_ip, user_agent) - + response_time = time.time() - start_time update_metrics(response_time, success=False, error_type='endpoint_error') - logger.error(f"Endpoint error: {str(e)}") - return jsonify({'error': str(e)}), 500 - + logger.exception("Endpoint error occurred from %s", client_ip) + return jsonify({'error': 'Internal server error'}), 500 + return decorated_function class SecureEmotionDetectionModel: @@ -175,7 +198,7 @@ def __init__(self): default_model_dir = Path(__file__).resolve().parent.parent / 'model' env_model_dir = os.environ.get("SECURE_MODEL_DIR") self.model_path = Path(env_model_dir).expanduser().resolve() if env_model_dir else default_model_dir - logger.info(f"Loading secure model from: {self.model_path}") + logger.info("Loading secure model from: %s", self.model_path) # Default emotions list available even if model isn't loaded self.emotions = [ @@ -229,46 +252,57 @@ def __init__(self): try: if torch.cuda.is_available(): self.model = self.model.to('cuda') - logger.info("✅ Model moved to GPU") + logger.info("Model moved to GPU") else: - logger.info("⚠️ CUDA not available, using CPU") + logger.info("CUDA not available, using CPU") except Exception: # If torch is absent at runtime, remain on CPU - logger.info("⚠️ Torch not available, using CPU") + logger.info("Torch not available, using CPU") self.loaded = True - logger.info("✅ Secure model loaded successfully") - except Exception as e: - logger.error(f"❌ Failed to load secure model: {str(e)}. Falling back to stub mode.") + # Ensure emotions list matches model's actual labels + if hasattr(self.model, 'config') and hasattr(self.model.config, 'id2label'): + model_labels = list(self.model.config.id2label.values()) + if len(model_labels) == len(self.emotions): + self.emotions = model_labels + logger.info("Model emotions list updated to match model labels: %s", self.emotions) + else: + logger.warning("Model labels count (%d) doesn't match expected emotions count (%d)", + len(model_labels), len(self.emotions)) + + logger.info("Secure model loaded successfully") + + except Exception as _e: + logger.exception("Failed to load secure model; falling back to stub mode.") self.tokenizer = None self.model = None self.loaded = False - + def predict(self, text, confidence_threshold=None): """Make a secure prediction.""" start_time = time.time() - + try: if not getattr(self, 'loaded', False): raise RuntimeError("SecureEmotionDetectionModel is not loaded; prediction unavailable.") # Ensure torch is available within function scope for linter/runtime try: import torch # type: ignore - except Exception as e: # pragma: no cover - logger.error("Torch import failed during prediction: %s", e) + except Exception as _e: # pragma: no cover + logger.error("Torch import failed during prediction: %s", _e) raise # Sanitize input text sanitized_text, warnings = input_sanitizer.sanitize_text(text, "emotion") if warnings: - logger.warning(f"Sanitization warnings: {warnings}") - + logger.warning("Sanitization warnings: %s", warnings) + # Tokenize input inputs = self.tokenizer(sanitized_text, return_tensors='pt', truncation=True, padding=True, max_length=512) - + if torch.cuda.is_available(): inputs = {k: v.to('cuda') for k, v in inputs.items()} - + # Get prediction with torch.no_grad(): outputs = self.model(**inputs) @@ -277,7 +311,7 @@ def predict(self, text, confidence_threshold=None): confidence = probabilities[0][predicted_label].item() # Apply confidence threshold if specified - if confidence_threshold and confidence < confidence_threshold: + if confidence_threshold is not None and confidence < confidence_threshold: predicted_emotion = "uncertain" confidence = 0.0 elif predicted_label in self.model.config.id2label: @@ -286,13 +320,14 @@ def predict(self, text, confidence_threshold=None): predicted_emotion = self.model.config.id2label[str(predicted_label)] else: predicted_emotion = f"unknown_{predicted_label}" - + # Get all probabilities all_probs = probabilities[0].cpu().numpy() - + prediction_time = time.time() - start_time - logger.info(f"Secure prediction completed in {prediction_time:.3f}s: '{sanitized_text[:50]}...' → {predicted_emotion} (conf: {confidence:.3f})") - + logger.info("Secure prediction completed in %.3fs → %s (conf: %.3f)", + prediction_time, predicted_emotion, confidence) + # Create secure response return { 'text': sanitized_text, @@ -315,14 +350,15 @@ def predict(self, text, confidence_threshold=None): 'correlation_id': getattr(g, 'correlation_id', None) } } - - except Exception as e: + + except Exception as _e: prediction_time = time.time() - start_time - logger.error(f"Secure prediction failed after {prediction_time:.3f}s: {str(e)}") + logger.exception("Secure prediction failed after %.3fs", prediction_time) raise + # Secure model factory for explicit creation and testability -logger.info("🔒 Secure model will be created via factory function") +logger.info("Secure model will be created via factory function") def create_secure_model(): """Factory function to create a SecureEmotionDetectionModel or a stub in CI/TEST. @@ -339,7 +375,7 @@ class _Stub: return _Stub() return SecureEmotionDetectionModel() -@functools.lru_cache(maxsize=1) +@lru_cache(maxsize=1) def get_secure_model(): """Return a cached secure model instance created via the factory. @@ -348,6 +384,189 @@ def get_secure_model(): """ return create_secure_model() + +# Provider selection for text emotion (simple registry/factory) +EMOTION_PROVIDER = os.environ.get("EMOTION_PROVIDER", "hf").lower() +_provider_registry = {} + + +def register_provider(name, factory): + """Register a provider factory by name for emotion services.""" + _provider_registry[name] = factory + + +def get_emotion_service(): + """Return an emotion service instance for the configured provider.""" + name = EMOTION_PROVIDER + factory = _provider_registry.get(name) + if not factory: + raise ValueError(f"Unsupported EMOTION_PROVIDER: {name}") + return factory() + + +# Register default providers +try: + from ..src.providers.hf_emotion import HFEmotionService # type: ignore + register_provider("hf", HFEmotionService) +except Exception: + logger.warning("HFEmotionService not available; NLP endpoints may be unavailable") + + +def _parse_single_text_payload(data: dict) -> str: + """Validate and extract 'text' from request payload.""" + text = data.get('text') if isinstance(data, dict) else None + if not isinstance(text, str) or not text.strip(): + raise ValueError('Field "text" must be a non-empty string') + return text + + +def _sanitize_texts_batch(texts: List[str]) -> Tuple[List[str], int]: + """Sanitize batch texts and return (sanitized_texts, total_warnings).""" + sanitized: List[str] = [] + total_warnings = 0 + for t in texts: + s, warnings = input_sanitizer.sanitize_text(t, "emotion") + sanitized.append(s) + total_warnings += len(warnings) + return sanitized, total_warnings + + +def _build_provider_info() -> dict: + """Build provider info dict reflecting local-only mode and model_dir.""" + local_only_env = str(os.environ.get('EMOTION_LOCAL_ONLY', '')).strip().lower() + default_dir = str(Path(__file__).resolve().parent.parent / 'model') + return { + 'local_only': local_only_env in ('1', 'true', 'yes', 'on'), + 'model_dir': os.environ.get('EMOTION_MODEL_DIR', '') or default_dir, + } + + +class _ClientError(Exception): + """Lightweight exception with HTTP status and error type for client faults.""" + + def __init__(self, message: str, status_code: int, error_type: str) -> None: + super().__init__(message) + self.message = message + self.status_code = status_code + self.error_type = error_type + + +def _get_json_payload_or_raise() -> Dict[str, Any]: + """Return JSON payload or raise _ClientError for invalid JSON.""" + data = request.get_json(silent=True) + if data is None: + raise _ClientError('Invalid JSON format', 400, 'invalid_json') + return data + + +def _extract_and_filter_texts_or_raise( + data: Dict[str, Any] +) -> Tuple[List[str], List[str], int]: + """Extract 'texts' list, filter invalid entries, and return tuple. + + Returns (original_texts, filtered_texts, num_filtered). + """ + if not data or 'texts' not in data or not isinstance(data['texts'], list): + raise _ClientError( + 'Field "texts" must be a list of strings', 400, 'validation_error' + ) + original_texts = data['texts'] + texts = [t for t in original_texts if isinstance(t, str) and t.strip()] + num_filtered = len(original_texts) - len(texts) + if not texts: + raise _ClientError('No valid texts provided', 400, 'validation_error') + return original_texts, texts, num_filtered + + +def _validate_alignment_count_or_raise( + results: BatchResults, expected_count: int +) -> bool: + """Ensure provider results match expected count or raise _ClientError.""" + if (not isinstance(results, list)) or (len(results) != expected_count): + raise _ClientError( + 'Provider returned mismatched result count', 502, 'provider_misalignment' + ) + return True + + +def _validate_single_results_or_raise(results: BatchResults) -> List[Dict[str, Any]]: + """Validate single-input provider results shape and return the distribution. + + Expects results to be List[List[Dict[str, Any]]], with len(results) == 1. + Raises _ClientError(502) on invalid shape. + """ + if ( + (not isinstance(results, list)) + or (len(results) != 1) + or (not isinstance(results[0], list)) + ): + outer_type = type(results).__name__ + outer_len = ( + len(results) if isinstance(results, list) else 'N/A' + ) + inner_type = ( + type(results[0]).__name__ + if isinstance(results, list) and results + else 'N/A' + ) + logger.error( + "Provider returned invalid shape for single input: " + "type=%s len=%s inner_type=%s", + outer_type, + outer_len, + inner_type, + ) + raise _ClientError( + 'Provider returned mismatched result count', + 502, + 'provider_misalignment' + ) + dist = results[0] + if dist and not ( + isinstance(dist[0], dict) + and 'label' in dist[0] + and 'score' in dist[0] + ): + inner_first_type = ( + type(dist[0]).__name__ if dist else 'N/A' + ) + inner_keys = ( + list(dist[0].keys()) if isinstance(dist[0], dict) else 'N/A' + ) + logger.error( + "Provider returned invalid inner element: " + "inner_first_type=%s keys=%s", + inner_first_type, + inner_keys, + ) + raise _ClientError( + 'Provider returned mismatched result count', + 502, + 'provider_misalignment' + ) + return dist + + +def _build_single_response( + sanitized_text: str, + dist: List[Dict[str, Any]], + warnings: List[Any] +) -> Dict[str, Any]: + """Build JSON response payload for the single-input endpoint.""" + return { + 'text': sanitized_text, + 'scores': dist, + 'provider': os.environ.get("EMOTION_PROVIDER", EMOTION_PROVIDER).lower(), + 'provider_info': _build_provider_info(), + 'timestamp': time.time(), + 'security': { + 'sanitization_warnings': warnings, + 'request_id': getattr(g, 'request_id', None), + 'correlation_id': getattr(g, 'correlation_id', None) + } + } + + # Read admin API key per-request to reflect environment changes during tests def get_admin_api_key() -> str | None: """Fetch the admin API key from the environment on each call. @@ -365,12 +584,12 @@ def require_admin_api_key(f): Reads the expected key via ``get_admin_api_key()`` for each request and does not cache it. See ``get_admin_api_key`` for concurrency considerations. """ - @functools.wraps(f) + @wraps(f) def decorated_function(*args, **kwargs): api_key = request.headers.get("X-Admin-API-Key") expected_key = get_admin_api_key() if not expected_key or api_key != expected_key: - logger.warning(f"Unauthorized admin access attempt from {request.remote_addr}") + logger.warning("Unauthorized admin access attempt from %s", request.remote_addr) return jsonify({"error": "Unauthorized: admin API key required"}), 403 return f(*args, **kwargs) return decorated_function @@ -380,7 +599,7 @@ def decorated_function(*args, **kwargs): def health_check(): """Secure health check endpoint.""" start_time = time.time() - + try: mdl = get_secure_model() response = { @@ -403,24 +622,24 @@ def health_check(): 'average_response_time_ms': round(metrics['average_response_time'] * 1000, 2) } } - + response_time = time.time() - start_time update_metrics(response_time, success=True) - + return jsonify(response) - - except Exception as e: + + except Exception as _e: response_time = time.time() - start_time update_metrics(response_time, success=False, error_type='health_check_error') - logger.error(f"Health check failed: {str(e)}") - return jsonify({'error': str(e)}), 500 + logger.exception("Health check failed") + return jsonify({'error': 'Internal server error'}), 500 @app.route('/predict', methods=['POST']) @secure_endpoint def predict(): """Secure prediction endpoint.""" start_time = time.time() - + try: # Parse and validate request data try: @@ -428,30 +647,30 @@ def predict(): except werkzeug.exceptions.BadRequest: response_time = time.time() - start_time update_metrics(response_time, success=False, error_type='invalid_json') - logger.error(f"Invalid JSON in request from {request.remote_addr}") + logger.error("Invalid JSON in request from %s", request.remote_addr) return jsonify({'error': 'Invalid JSON format'}), 400 - + if not data: response_time = time.time() - start_time update_metrics(response_time, success=False, error_type='missing_data') return jsonify({'error': 'No data provided'}), 400 - + # Sanitize and validate request try: sanitized_data, warnings = input_sanitizer.validate_emotion_request(data) except ValueError as e: response_time = time.time() - start_time update_metrics(response_time, success=False, error_type='validation_error') - logger.warning(f"Validation error: {str(e)} from {request.remote_addr}") + logger.warning("Validation error: %s from %s", str(e), request.remote_addr) return jsonify({'error': str(e)}), 400 - + # Detect anomalies anomalies = input_sanitizer.detect_anomalies(data) if anomalies: - logger.warning(f"Security anomalies detected: {anomalies}") + logger.warning("Security anomalies detected: %s", anomalies) with metrics_lock: metrics['security_violations'] += 1 - + # Make secure prediction model_instance = get_secure_model() if not getattr(model_instance, 'loaded', False): @@ -460,11 +679,12 @@ def predict(): sanitized_data['text'], confidence_threshold=sanitized_data.get('confidence_threshold') ) - + # Add sanitization warnings to response if warnings: - result['security']['sanitization_warnings'] = warnings - + prior = result.get('security', {}).get('sanitization_warnings', []) + merged = list(dict.fromkeys([*prior, *warnings])) + result['security']['sanitization_warnings'] = merged response_time = time.time() - start_time update_metrics( response_time, @@ -472,21 +692,21 @@ def predict(): emotion=result['predicted_emotion'], sanitization_warnings=len(warnings) ) - + return jsonify(result) - - except Exception as e: + + except Exception as _e: response_time = time.time() - start_time update_metrics(response_time, success=False, error_type='prediction_error') - logger.error(f"Secure prediction endpoint error: {str(e)}") - return jsonify({'error': str(e)}), 500 + logger.exception("Secure prediction endpoint error") + return jsonify({'error': 'Internal server error'}), 500 @app.route('/predict_batch', methods=['POST']) @secure_endpoint def predict_batch(): """Secure batch prediction endpoint.""" start_time = time.time() - + try: # Parse and validate request data try: @@ -494,30 +714,30 @@ def predict_batch(): except werkzeug.exceptions.BadRequest: response_time = time.time() - start_time update_metrics(response_time, success=False, error_type='invalid_json') - logger.error(f"Invalid JSON in batch request from {request.remote_addr}") + logger.error("Invalid JSON in batch request from %s", request.remote_addr) return jsonify({'error': 'Invalid JSON format'}), 400 - + if not data: response_time = time.time() - start_time update_metrics(response_time, success=False, error_type='missing_data') return jsonify({'error': 'No data provided'}), 400 - + # Sanitize and validate request try: sanitized_data, warnings = input_sanitizer.validate_batch_request(data) except ValueError as e: response_time = time.time() - start_time update_metrics(response_time, success=False, error_type='validation_error') - logger.warning(f"Batch validation error: {str(e)} from {request.remote_addr}") + logger.warning("Batch validation error: %s from %s", str(e), request.remote_addr) return jsonify({'error': str(e)}), 400 - + # Detect anomalies anomalies = input_sanitizer.detect_anomalies(data) if anomalies: - logger.warning(f"Security anomalies detected in batch: {anomalies}") + logger.warning("Security anomalies detected in batch: %s", anomalies) with metrics_lock: metrics['security_violations'] += 1 - + # Make secure batch predictions results = [] model_instance = get_secure_model() @@ -530,14 +750,14 @@ def predict_batch(): confidence_threshold=sanitized_data.get('confidence_threshold') ) results.append(result) - + response_time = time.time() - start_time update_metrics( response_time, success=True, sanitization_warnings=len(warnings) ) - + return jsonify({ 'predictions': results, 'count': len(results), @@ -548,14 +768,166 @@ def predict_batch(): 'correlation_id': getattr(g, 'correlation_id', None) } }) - - except Exception as e: + + except Exception as _e: + response_time = time.time() - start_time + update_metrics( + response_time, success=False, error_type='batch_prediction_error' + ) + logger.exception("NLP emotion batch error") + return jsonify({'error': 'Internal server error'}), 500 + + +@app.route('/nlp/emotion', methods=['POST']) +@secure_endpoint +def nlp_emotion(): + """Classify emotion distribution for a single input text.""" + start_time = time.time() + try: + # Parse and validate JSON + data = _get_json_payload_or_raise() + text = _parse_single_text_payload(data) + + # Sanitize and classify + sanitized_text, warnings = input_sanitizer.sanitize_text(text, "emotion") + try: + service = get_emotion_service() + except (ImportError, ValueError): + response_time = time.time() - start_time + update_metrics(response_time, success=False, error_type='provider_error') + logger.exception("Emotion provider misconfiguration") + return jsonify({'error': 'Emotion provider misconfiguration.'}), 503 + except Exception: + response_time = time.time() - start_time + update_metrics(response_time, success=False, error_type='provider_error') + logger.exception("Unknown provider error in /nlp/emotion") + return jsonify({'error': 'Internal server error'}), 500 + + results = service.classify(sanitized_text) + dist = _validate_single_results_or_raise(results) + + response = _build_single_response(sanitized_text, dist, warnings) + + # Update distribution metric by top label + try: + top = max(dist, key=lambda x: x.get('score', 0.0)) if dist else None + update_metrics( + time.time() - start_time, + success=True, + emotion=(top.get('label') if top else None), + sanitization_warnings=len(warnings) + ) + except Exception: + update_metrics( + time.time() - start_time, + success=True, + sanitization_warnings=len(warnings) + ) + + return jsonify(response) + except Exception: + response_time = time.time() - start_time + update_metrics(response_time, success=False, error_type='prediction_error') + logger.exception("NLP emotion error") + return jsonify({'error': 'An internal error occurred.'}), 500 + + +@app.route('/nlp/emotion/batch', methods=['POST']) +@secure_endpoint +def nlp_emotion_batch(): + """Classify emotion distributions for a batch of input texts.""" + start_time = time.time() + try: + data = _get_json_payload_or_raise() + _original_texts, texts, num_filtered = _extract_and_filter_texts_or_raise(data) + if num_filtered > 0: + logger.warning( + "%s invalid texts filtered out from input batch.", num_filtered + ) + + sanitized, total_warnings = _sanitize_texts_batch(texts) + + try: + service = get_emotion_service() + except (ImportError, ValueError): + response_time = time.time() - start_time + update_metrics( + response_time, success=False, error_type='provider_error' + ) + logger.exception("Emotion provider misconfiguration") + return jsonify({'error': 'Emotion provider misconfiguration.'}), 503 + except Exception: + response_time = time.time() - start_time + update_metrics( + response_time, success=False, error_type='provider_error' + ) + logger.exception("Unknown provider error in /nlp/emotion/batch") + return jsonify({'error': 'Internal server error'}), 500 + + results = service.classify(sanitized) + _validate_alignment_count_or_raise(results, len(sanitized)) + + responses = [] + for text, raw_scores in zip(sanitized, results): + scores = raw_scores if isinstance(raw_scores, list) else [] + top = ( + max(scores, key=lambda x: x.get('score', 0.0)) + if scores else {'label': 'unknown', 'score': 0.0} + ) + responses.append({ + 'text': text, + 'scores': scores, + 'top_label': top.get('label'), + 'top_score': top.get('score') + }) + response_time = time.time() - start_time - update_metrics(response_time, success=False, error_type='batch_prediction_error') - logger.error(f"Secure batch prediction endpoint error: {str(e)}") - return jsonify({'error': str(e)}), 500 + try: + first_top = ( + max(results[0], key=lambda x: x.get('score', 0.0)) + if results and results[0] else None + ) + update_metrics( + response_time, + success=True, + emotion=(first_top.get('label') if first_top else None), + sanitization_warnings=total_warnings + ) + except Exception: + update_metrics( + response_time, success=True, sanitization_warnings=total_warnings + ) + + return jsonify({ + 'results': responses, + 'count': len(responses), + 'provider': os.environ.get("EMOTION_PROVIDER", EMOTION_PROVIDER).lower(), + 'provider_info': _build_provider_info(), + 'batch_processing_time_ms': round(response_time * 1000, 2), + 'security': { + 'sanitization_warnings': total_warnings, + 'request_id': getattr(g, 'request_id', None), + 'correlation_id': getattr(g, 'correlation_id', None) + } + }) + except _ClientError as ce: + response_time = time.time() - start_time + update_metrics(response_time, success=False, error_type=ce.error_type) + if ce.error_type == 'provider_misalignment': + logger.error(ce.message) + else: + logger.warning(ce.message) + return jsonify({'error': ce.message}), ce.status_code + except Exception as _e: + response_time = time.time() - start_time + update_metrics( + response_time, success=False, error_type='batch_prediction_error' + ) + logger.exception("NLP emotion batch error") + return jsonify({'error': 'Internal server error'}), 500 @app.route('/metrics', methods=['GET']) +@require_admin_api_key def get_metrics(): """Get detailed security metrics endpoint.""" with metrics_lock: @@ -589,14 +961,21 @@ def add_to_blacklist(): data = request.get_json() if not data or 'ip' not in data: return jsonify({'error': 'IP address required'}), 400 - + ip = data['ip'] + # Validate IP address format + try: + ip_address(ip) + except ValueError as _e: + logger.warning("Invalid IP address format: %s", ip) + return jsonify({'error': f'Invalid IP address format: {ip}'}), 400 + rate_limiter.add_to_blacklist(ip) - logger.info(f"Added {ip} to blacklist") + logger.info("Added %s to blacklist", ip) return jsonify({'message': f'Added {ip} to blacklist'}) - except Exception as e: - logger.error(f"Blacklist error: {str(e)}") - return jsonify({'error': str(e)}), 500 + except Exception as _e: + logger.exception("Blacklist error occurred") + return jsonify({'error': 'Internal server error'}), 500 @app.route('/security/whitelist', methods=['POST']) @require_admin_api_key @@ -606,21 +985,28 @@ def add_to_whitelist(): data = request.get_json() if not data or 'ip' not in data: return jsonify({'error': 'IP address required'}), 400 - + ip = data['ip'] + # Validate IP address format + try: + ip_address(ip) + except ValueError as _e: + logger.warning("Invalid IP address format: %s", ip) + return jsonify({'error': f'Invalid IP address format: {ip}'}), 400 + rate_limiter.add_to_whitelist(ip) - logger.info(f"Added {ip} to whitelist") + logger.info("Added %s to whitelist", ip) return jsonify({'message': f'Added {ip} to whitelist'}) - except Exception as e: - logger.error(f"Whitelist error: {str(e)}") - return jsonify({'error': str(e)}), 500 + except Exception as _e: + logger.exception("Whitelist error occurred") + return jsonify({'error': 'Internal server error'}), 500 @app.route('/', methods=['GET']) @secure_endpoint def home(): """Secure home endpoint with API documentation.""" start_time = time.time() - + try: response = { 'message': 'Secure Emotion Detection API', @@ -639,6 +1025,8 @@ def home(): 'GET /metrics': 'Detailed security metrics', 'POST /predict': 'Secure single prediction', 'POST /predict_batch': 'Secure batch prediction', + 'POST /nlp/emotion': 'Emotion distribution for a single text', + 'POST /nlp/emotion/batch': 'Emotion distributions for a batch of texts', 'POST /security/blacklist': 'Add IP to blacklist (admin)', 'POST /security/whitelist': 'Add IP to whitelist (admin)' }, @@ -663,66 +1051,76 @@ def home(): } } } - + response_time = time.time() - start_time update_metrics(response_time, success=True) - + return jsonify(response) - except Exception as e: + except Exception as _e: response_time = time.time() - start_time update_metrics(response_time, success=False, error_type='documentation_error') - logger.error(f"Documentation endpoint error: {str(e)}") - return jsonify({'error': str(e)}), 500 + logger.exception("Documentation endpoint error") + return jsonify({'error': 'Internal server error'}), 500 @app.errorhandler(werkzeug.exceptions.BadRequest) -def handle_bad_request(e): +def handle_bad_request(_e): """Handle BadRequest exceptions (invalid JSON, etc.).""" - logger.error(f"BadRequest error: {str(e)}") + logger.warning("BadRequest error occurred for %s from %s", request.path, request.remote_addr) update_metrics(0.0, success=False, error_type='invalid_json') return jsonify({'error': 'Invalid JSON format'}), 400 @app.errorhandler(404) -def handle_not_found(e): +def handle_not_found(_e): """Handle 404 errors.""" - logger.warning(f"404 error: {request.path} from {request.remote_addr}") + logger.warning("404 error: %s from %s", request.path, request.remote_addr) return jsonify({'error': 'Endpoint not found'}), 404 @app.errorhandler(500) -def handle_internal_error(e): +def handle_internal_error(_e): """Handle 500 errors.""" - logger.error(f"Internal server error: {str(e)}") + logger.exception("Internal server error occurred") return jsonify({'error': 'Internal server error'}), 500 if __name__ == '__main__': - logger.info("🔒 Starting Secure Emotion Detection API Server") + logger.info("Starting Secure Emotion Detection API Server") logger.info("=" * 60) - logger.info("🛡️ Security Features Enabled:") - logger.info(" ✅ Rate limiting with token bucket algorithm") - logger.info(" ✅ Input sanitization and validation") - logger.info(" ✅ Security headers (CSP, HSTS, X-Frame-Options)") - logger.info(" ✅ Request/response logging and monitoring") - logger.info(" ✅ IP whitelist/blacklist support") - logger.info(" ✅ Abuse detection and automatic blocking") - logger.info(" ✅ Request correlation and tracing") - logger.info("") - logger.info("📋 Available endpoints:") - logger.info(" GET / - API documentation") - logger.info(" GET /health - Health check with security metrics") - logger.info(" GET /metrics - Detailed security metrics") - logger.info(" POST /predict - Secure single prediction") - logger.info(" POST /predict_batch - Secure batch prediction") - logger.info(" POST /security/blacklist - Add IP to blacklist (admin)") - logger.info(" POST /security/whitelist - Add IP to whitelist (admin)") - logger.info("") - logger.info("🚀 Server starting on http://localhost:8000") - logger.info("📝 Example usage:") - logger.info(" curl -X POST http://localhost:8000/predict \\") - logger.info(" -H 'Content-Type: application/json' \\") - logger.info(" -d '{\"text\": \"I am feeling happy today!\"}'") + logger.info("Security Features Enabled:") + logger.info(" - Rate limiting with token bucket algorithm") + logger.info(" - Input sanitization and validation") + logger.info(" - Security headers (CSP, HSTS, X-Frame-Options)") + logger.info(" - Request/response logging and monitoring") + logger.info(" - IP whitelist/blacklist support") + logger.info(" - Abuse detection and automatic blocking") + logger.info(" - Request correlation and tracing") logger.info("") - logger.info(f"🔒 Rate limiting: {rate_limit_config.requests_per_minute} requests per minute") - logger.info("🛡️ Security monitoring: Comprehensive logging and metrics enabled") + + # Only log route information in development/debug mode + if os.environ.get('FLASK_ENV') == 'development' or os.environ.get('DEBUG') == 'true': + logger.info("Available endpoints:") + logger.info(" GET / - API documentation") + logger.info(" GET /health - Health check with security metrics") + logger.info(" GET /metrics - Detailed security metrics") + logger.info(" POST /predict - Secure single prediction") + logger.info(" POST /predict_batch - Secure batch prediction") + logger.info(" POST /security/blacklist - Add IP to blacklist (admin)") + logger.info(" POST /security/whitelist - Add IP to whitelist (admin)") + logger.info("") + logger.info("Server starting on http://localhost:8000") + logger.info("Example usage:") + logger.info(" curl -X POST http://localhost:8000/predict \\") + logger.info(" -H 'Content-Type: application/json' \\") + logger.info(" -d '{\"text\": \"I am feeling happy today!\"}'") + logger.info("") + + logger.info( + "Rate limiting: %s requests per minute", + rate_limit_config.requests_per_minute + ) + logger.info( + "Security monitoring: Comprehensive logging and metrics enabled" + ) logger.info("=" * 60) - - app.run(host='0.0.0.0', port=8000, debug=False) \ No newline at end of file + + if os.environ.get("FLASK_ENV") != "production": + app.run(host='0.0.0.0', port=int(os.environ.get("PORT", "8000")), debug=False) diff --git a/src/api_rate_limiter.py b/src/api_rate_limiter.py index 8ec1995bf..e2dfb3de4 100644 --- a/src/api_rate_limiter.py +++ b/src/api_rate_limiter.py @@ -437,6 +437,16 @@ def release_request(self, client_ip: str, user_agent: str = ""): 0, self.concurrent_requests[client_key] - 1 ) + def add_to_blacklist(self, ip: str) -> None: + """Add IP to blacklist.""" + with self.lock: + self.config.blacklisted_ips.add(ip) + + def add_to_whitelist(self, ip: str) -> None: + """Add IP to whitelist.""" + with self.lock: + self.config.whitelisted_ips.add(ip) + def get_stats(self) -> Dict: """Get rate limiter statistics.""" with self.lock: diff --git a/tests/integration/test_priority1_features.py b/tests/integration/test_priority1_features.py index 417f014cd..5eba3ca81 100644 --- a/tests/integration/test_priority1_features.py +++ b/tests/integration/test_priority1_features.py @@ -30,11 +30,13 @@ class to_uploads: def __init__(self, paths, name_prefix: str): + """Initialize the file uploader context manager.""" self.paths = list(paths) self.name_prefix = name_prefix self._opened = [] def __enter__(self): + """Enter the context and prepare files for upload.""" self._opened = [open(p, "rb") for p in self.paths] files = [ ( @@ -46,6 +48,7 @@ def __enter__(self): return files def __exit__(self, exc_type, exc, tb): + """Exit the context and close opened files.""" for fh in self._opened: try: fh.close() @@ -376,6 +379,7 @@ def test_batch_transcription_all_failures(self, mock_transcriber): def test_batch_transcription_all_success(self, mock_transcriber): """Test batch transcription where all transcriptions succeed.""" def ok_side_effect(file_path, language=None): + """Mock side effect for successful transcription.""" return {"text": "ok", "language": "en", "confidence": 0.9, "duration": 1.0} mock_transcriber.transcribe.side_effect = ok_side_effect diff --git a/tests/unit/test_admin_endpoints.py b/tests/unit/test_admin_endpoints.py index 632b9c7b0..f175c363a 100644 --- a/tests/unit/test_admin_endpoints.py +++ b/tests/unit/test_admin_endpoints.py @@ -39,7 +39,7 @@ def setUp(self): self.app.testing = True # Set admin API key for testing - os.environ['ADMIN_API_KEY'] = 'test-admin-key-123' + os.environ.setdefault('ADMIN_API_KEY', 'test-admin-key-123') def tearDown(self): """Clean up after tests.""" diff --git a/tests/unit/test_api_routing.py b/tests/unit/test_api_routing.py new file mode 100644 index 000000000..186a3bcd0 --- /dev/null +++ b/tests/unit/test_api_routing.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +""" +🧪 API Routing Tests +==================== +Tests for Flask-RESTX routing fixes and endpoint functionality. +""" + +import os +import unittest +import json +from unittest.mock import patch +from pathlib import Path + +class TestAPIRouting(unittest.TestCase): + """Test API routing and endpoint functionality.""" + + ADMIN_KEY = 'test-admin-key-123' + + @classmethod + def setUpClass(cls): + """Set up class-level fixtures.""" + # Set required environment variables BEFORE importing + cls._saved_env = { + k: os.environ.get(k) + for k in ('ADMIN_API_KEY', 'MAX_INPUT_LENGTH', 'RATE_LIMIT_PER_MINUTE') + } + # Force deterministic test values (avoid drift with pre-set env) + os.environ['ADMIN_API_KEY'] = cls.ADMIN_KEY + os.environ['MAX_INPUT_LENGTH'] = '512' + os.environ['RATE_LIMIT_PER_MINUTE'] = '100' + + def setUp(self): + """Set up test fixtures.""" + try: + # Try to import from the deployment directory + import importlib.util + server_path = (Path(__file__).resolve().parents[2] / "deployment" / "cloud-run" / "secure_api_server.py") + if not server_path.exists(): + raise ImportError(f"secure_api_server.py not found at {server_path}") + spec = importlib.util.spec_from_file_location("secure_api_server", str(server_path)) + if spec and spec.loader: + import sys + # Load the module under its spec name so patch targets resolve correctly + self.module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = self.module + self.addCleanup(sys.modules.pop, spec.name, None) + spec.loader.exec_module(self.module) + + # Persistent mocks for each test + self._patchers = [] + def _start(patcher): + self._patchers.append(patcher) + return patcher.start() + + _start(patch.object(self.module, 'check_model_loaded', return_value=True)) + _start(patch.object(self.module, 'predict_emotion', return_value={ + 'text': 'test text', + 'emotions': [{'emotion': 'happy', 'confidence': 0.9}], + 'confidence': 0.9, + 'request_id': 'test-123', + 'timestamp': 1234567890 + })) + _start(patch.object(self.module, 'get_model_status', return_value={ + 'model_loaded': True, + 'model_path': '/test/path', + 'model_size': '100MB' + })) + + # Ensure mocks are stopped after each test + for p in self._patchers: + self.addCleanup(p.stop) + + app = self.module.app + else: + self.skipTest("secure_api_server module not found at expected path") + + self.app = app.test_client() + self.app.testing = True + self.api_available = True + # Central auth header for tests + self.auth_headers = {'X-API-Key': os.environ.get('ADMIN_API_KEY', 'test-admin-key-123')} + except (ImportError, OSError) as e: + import warnings + warnings.warn(f"Could not import secure_api_server: {e}", stacklevel=2) + self.api_available = False + self.app = None + + # Enforce skipping centrally when API is not available + if not self.api_available: + self.skipTest("API not available for testing") + + + @classmethod + def tearDownClass(cls): + """Clean up class-level fixtures.""" + # Restore original environment variables + for k, v in getattr(cls, '_saved_env', {}).items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + def test_root_endpoint(self): + """Test that root endpoint is accessible and returns correct response.""" + response = self.app.get('/') + self.assertEqual(response.status_code, 200) + + self.assertTrue(response.is_json, f"Non-JSON response: {response.data!r}") + data = response.get_json() + self.assertIn('service', data) + self.assertIn('status', data) + self.assertIn('version', data) + self.assertEqual(data['service'], 'SAMO Emotion Detection API') + self.assertEqual(data['status'], 'operational') + + def test_health_endpoint(self): + """Test health endpoint returns correct status.""" + response = self.app.get('/api/health') + self.assertEqual(response.status_code, 200) + + data = response.get_json() + self.assertIn('status', data) + self.assertIn('model_loaded', data) + self.assertIn('timestamp', data) + + def test_predict_endpoint_no_auth(self): + """Test predict endpoint requires API key.""" + response = self.app.post('/api/predict', json={'text': 'I am happy'}) + self.assertEqual(response.status_code, 401) + + data = response.get_json() + self.assertIn('error', data) + self.assertRegex(data['error'], r'(?i)unauthoriz') + + def test_predict_endpoint_with_auth(self): + """Test predict endpoint works with valid API key.""" + response = self.app.post( + '/api/predict', + json={'text': 'I am happy'}, + headers=self.auth_headers + ) + + # Should succeed (200) or be rate limited (429), but not auth error (401) + self.assertIn(response.status_code, [200, 429]) + + def test_predict_batch_endpoint_no_auth(self): + """Test predict_batch endpoint requires API key.""" + response = self.app.post('/api/predict_batch', json={'texts': ['I am happy', 'I am sad']}) + self.assertEqual(response.status_code, 401) + + data = response.get_json() + self.assertIn('error', data) + self.assertRegex(data['error'], r'(?i)unauthoriz') + + def test_predict_batch_endpoint_with_auth(self): + """Test predict_batch endpoint works with valid API key.""" + response = self.app.post( + '/api/predict_batch', + json={'texts': ['I am happy', 'I am sad']}, + headers=self.auth_headers + ) + + # Should succeed (200) or be rate limited (429), but not auth error (401) + self.assertIn(response.status_code, [200, 429]) + + def test_emotions_endpoint(self): + """Test emotions endpoint returns supported emotions.""" + response = self.app.get('/api/emotions') + self.assertEqual(response.status_code, 200) + + data = response.get_json() + self.assertIn('emotions', data) + self.assertIn('count', data) + self.assertIsInstance(data['emotions'], list) + self.assertGreater(data['count'], 0) + + def test_admin_model_status_no_auth(self): + """Test admin model status endpoint requires API key.""" + response = self.app.get('/admin/model_status') + self.assertEqual(response.status_code, 401) + + data = response.get_json() + self.assertIn('error', data) + self.assertRegex(data['error'], r'(?i)unauthoriz') + + def test_admin_model_status_with_auth(self): + """Test admin model status endpoint works with valid API key.""" + response = self.app.get( + '/admin/model_status', headers={'X-API-Key': self.ADMIN_KEY} + ) + + # Should succeed (200) or be rate limited (429), but not auth error (401) + self.assertIn(response.status_code, [200, 429]) + + def test_predict_endpoint_missing_text(self): + """Test predict endpoint handles missing text field.""" + response = self.app.post( + '/api/predict', + json={}, + headers=self.auth_headers + ) + self.assertEqual(response.status_code, 400) + + data = response.get_json() + self.assertIn('error', data) + self.assertIn('Missing text field', data['error']) + + def test_predict_endpoint_invalid_text(self): + """Test predict endpoint handles invalid text input.""" + response = self.app.post( + '/api/predict', + json={'text': ''}, + headers=self.auth_headers + ) + self.assertEqual(response.status_code, 400) + + data = response.get_json() + self.assertIn('error', data) + self.assertIn('non-empty string', data['error']) + + def test_namespace_routing_no_double_slashes(self): + """Test that namespace routes don't have double slashes.""" + # Test that /api/health works (not //api/health) + response = self.app.get('/api/health') + self.assertEqual(response.status_code, 200) + + # Test that /admin/model_status works (not //admin/model_status) + response = self.app.get('/admin/model_status', + headers=self.auth_headers) + # Should succeed (200) or be rate limited (429), but not auth error (401) with valid key + self.assertIn(response.status_code, [200, 429]) + +if __name__ == '__main__': + unittest.main() + + diff --git a/tests/unit/test_http_exception_handler.py b/tests/unit/test_http_exception_handler.py index 3dca5fa54..639e7be39 100644 --- a/tests/unit/test_http_exception_handler.py +++ b/tests/unit/test_http_exception_handler.py @@ -8,6 +8,7 @@ def test_http_exception_handler_400_detail_shape(): + """Test HTTP exception handler response shape for 400 status code.""" client = TestClient(app) @app.get("/__raise_400_test__") @@ -22,6 +23,7 @@ def __raise_400_test__(): # type: ignore def test_http_exception_handler_500_shape(): + """Test HTTP exception handler response shape for 500 status code.""" client = TestClient(app) @app.get("/__raise_500_test__") @@ -36,6 +38,7 @@ def __raise_500_test__(): # type: ignore def test_http_exception_handler_other_4xx_codes(): + """Test HTTP exception handler for other 4xx status codes.""" client = TestClient(app) @app.get("/__raise_401_test__") diff --git a/tests/unit/test_jwt_manager_extra.py b/tests/unit/test_jwt_manager_extra.py index dd1e1433d..17489a34f 100644 --- a/tests/unit/test_jwt_manager_extra.py +++ b/tests/unit/test_jwt_manager_extra.py @@ -8,6 +8,7 @@ def test_create_token_pair_structure(): + """Test the structure of token pair created by JWTManager.""" mgr = JWTManager() token_pair = mgr.create_token_pair( { @@ -27,11 +28,13 @@ def test_create_token_pair_structure(): def test_verify_invalid_token_returns_none(): + """Test that verifying an invalid token returns None.""" mgr = JWTManager() assert mgr.verify_token("not-a-jwt") is None def test_blacklist_and_cleanup_flow(monkeypatch): + """Test token blacklisting and cleanup of expired tokens.""" mgr = JWTManager() # Create a token and blacklist it using public API token = mgr.create_access_token( @@ -64,6 +67,7 @@ def utcnow(cls): def test_refresh_access_token_success_and_failure(): + """Test successful and failed access token refresh scenarios.""" mgr = JWTManager() user = { "user_id": "u3", @@ -89,6 +93,7 @@ def test_refresh_access_token_success_and_failure(): def test_permissions_helpers(): + """Test JWT permission checking helper functions.""" mgr = JWTManager() user = { "user_id": "u4", diff --git a/tests/unit/test_permission_checker_override.py b/tests/unit/test_permission_checker_override.py index db50f5465..893715a71 100644 --- a/tests/unit/test_permission_checker_override.py +++ b/tests/unit/test_permission_checker_override.py @@ -6,6 +6,7 @@ def test_permission_override_header_active_under_pytest(monkeypatch): + """Test that permission override header works when running under pytest.""" # Simulate pytest environment for the app monkeypatch.setenv("PYTEST_CURRENT_TEST", "1") @@ -30,6 +31,7 @@ def test_permission_override_header_active_under_pytest(monkeypatch): def test_permission_override_header_inactive_without_pytest(monkeypatch): + """Test that permission override header is ignored when not running under pytest.""" # Ensure pytest indicator is not set monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False) monkeypatch.setenv("ENABLE_TEST_PERMISSION_INJECTION", "false") diff --git a/tests/unit/test_routing_fixes.py b/tests/unit/test_routing_fixes.py new file mode 100644 index 000000000..18f8c1ce6 --- /dev/null +++ b/tests/unit/test_routing_fixes.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +""" +API Routing Fixes Verification +================================== +Simple test to verify Flask-RESTX routing fixes without heavy dependencies. +""" +import unittest +import re +from pathlib import Path + +# Base path for project files +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent + +class TestRoutingFixes(unittest.TestCase): + """Test that routing fixes have been applied correctly.""" + + def test_secure_api_server_namespaces_no_leading_slash(self): + """Test that secure_api_server.py has namespaces without leading slashes.""" + server_file = PROJECT_ROOT / 'deployment' / 'cloud-run' / 'secure_api_server.py' + self.assertTrue(server_file.exists(), f"Server file not found: {server_file}") + content = server_file.read_text() + + # Use regex to check namespace declarations are quote- and whitespace-agnostic + # Should match: main_ns = Namespace('api' or main_ns=Namespace("api" etc. + main_ns_pattern = re.compile(r'main_ns\s*=\s*Namespace\s*\(\s*[\'"]([^\'"]*)[\'"]', re.IGNORECASE) + admin_ns_pattern = re.compile(r'admin_ns\s*=\s*Namespace\s*\(\s*[\'"]([^\'"]*)[\'"]', re.IGNORECASE) + + main_match = main_ns_pattern.search(content) + admin_match = admin_ns_pattern.search(content) + + self.assertIsNotNone(main_match, "main_ns namespace declaration not found") + self.assertIsNotNone(admin_match, "admin_ns namespace declaration not found") + + main_ns_value = main_match.group(1) + admin_ns_value = admin_match.group(1) + + self.assertEqual(main_ns_value, 'api', f"main_ns should be 'api', got '{main_ns_value}'") + self.assertEqual(admin_ns_value, 'admin', f"admin_ns should be 'admin', got '{admin_ns_value}'") + + # Ensure no leading slashes in namespace values + self.assertFalse(main_ns_value.startswith('/'), f"main_ns should not start with '/', got '{main_ns_value}'") + self.assertFalse(admin_ns_value.startswith('/'), f"admin_ns should not start with '/', got '{admin_ns_value}'") + + def test_root_endpoint_registered_before_flask_restx(self): + """Test that root endpoint is registered before Flask-RESTX initialization.""" + server_file = PROJECT_ROOT / 'deployment' / 'cloud-run' / 'secure_api_server.py' + + content = server_file.read_text() + + # Find the positions of root endpoint registration and Flask-RESTX initialization + # More flexible regex to handle different formatting (quotes, whitespace, methods) + root_route_match = re.search(r"@app\.route\s*\(\s*(['\"])\/\1\s*(?:,\s*methods\s*=\s*\[.*?\])?\s*\)", content) + api_init_match = re.search(r"\bapi\s*=\s*Api\s*\(", content) + + # Explicit assertions to ensure patterns are found + self.assertIsNotNone(root_route_match, "Root route pattern not found in source code") + self.assertIsNotNone(api_init_match, "API initialization pattern not found in source code") + + root_pos = root_route_match.start() + api_pos = api_init_match.start() + self.assertLess(root_pos, api_pos, "Root endpoint should be registered before Flask-RESTX initialization") + + def test_test_files_fixed(self): + """Test that test files have been fixed with correct namespace definitions.""" + # Test each file individually to avoid loops in tests + test_file = PROJECT_ROOT / 'deployment' / 'cloud-run' / 'test_swagger_debug.py' + self.assertTrue(test_file.exists(), f"Expected file not found: {test_file}") + content = test_file.read_text() + namespace_matches = re.findall(r"Namespace\(\s*(['\"])(.*?)\1", content) + for _, name in namespace_matches: + self.assertFalse(name.startswith('/'), f"Found leading slash in namespace '{name}' in {test_file}") + + def test_root_endpoints_before_api_init_in_test_files(self): + """Test that test files have root endpoints registered before Flask-RESTX init.""" + # Test one file at a time to avoid loops in tests + test_file = PROJECT_ROOT / 'deployment' / 'cloud-run' / 'test_swagger_debug.py' + self.assertTrue(test_file.exists(), f"Test file not found: {test_file}") + content = test_file.read_text() + root_route_match = re.search(r"@app\.route\s*\(\s*(['\"])\/\1\s*(?:,\s*methods\s*=\s*\[.*?\])?\s*\)", content) + api_init_match = re.search(r"\bapi\s*=\s*Api\s*\(", content) + + # Explicit assertions to ensure patterns are found + self.assertIsNotNone(root_route_match, f"Root route pattern not found in {test_file}") + self.assertIsNotNone(api_init_match, f"API initialization pattern not found in {test_file}") + + root_pos = root_route_match.start() + api_pos = api_init_match.start() + self.assertLess(root_pos, api_pos, f"Root endpoint should be before API init in {test_file}") + + def test_no_double_slashes_in_routes(self): + """Test that there are no double slashes in route definitions.""" + server_file = PROJECT_ROOT / 'deployment' / 'cloud-run' / 'secure_api_server.py' + + content = server_file.read_text() + + # Check all route decorators (supports single/double quotes) + route_matches = re.findall(r"@[^)]*\.route\(\s*(['\"])(.*?)\1", content) + self.assertTrue(route_matches, "No route decorators found in secure_api_server.py") + for _, route in route_matches: + self.assertNotIn('//', route, f"Found double slash in route: {route}") + +if __name__ == '__main__': + unittest.main() + diff --git a/tests/unit/test_secure_model_loader.py b/tests/unit/test_secure_model_loader.py index f770129a9..1cbe3d4e6 100644 --- a/tests/unit/test_secure_model_loader.py +++ b/tests/unit/test_secure_model_loader.py @@ -28,11 +28,13 @@ class TestModel(nn.Module): """Simple test model for testing that meets validation criteria.""" def __init__(self, input_size=10, output_size=5): + """Initialize the test model with linear layer.""" super().__init__() self.linear = nn.Linear(input_size, output_size) self.model_name = 'TestModel' # Add required attribute def forward(self, x): + """Forward pass through the emotion classifier.""" return self.linear(x) @@ -40,11 +42,13 @@ class BERTEmotionClassifier(nn.Module): """Test model that matches allowed model types exactly.""" def __init__(self, num_emotions=5): + """Initialize the BERT emotion classifier model.""" super().__init__() self.linear = nn.Linear(768, num_emotions) # BERT hidden size self.model_name = 'BERTEmotionClassifier' def forward(self, x): + """Forward pass through the linear layer.""" return self.linear(x)