From f1991c39838a0daf9ff4860e7d23f3af6146589e Mon Sep 17 00:00:00 2001 From: cclohmar Date: Mon, 16 Mar 2026 14:51:51 +0000 Subject: [PATCH] chore: auto-commit 2026-03-16 14:51 --- .env.example | 4 - README.md | 76 ++-- app.py.backup | 98 ----- app/modules/admin/templates/base.html | 72 ++++ .env.backup => example.env | 0 examples/README.md | 173 --------- examples/setup.sh | 51 --- main.py | 42 +-- run_dev.py | 20 + scripts/debug_wsgi.py | 22 -- scripts/example_usage.py | 145 -------- scripts/run_example.sh | 22 -- scripts/setup-test-client.py | 121 ------ test_run.py | 54 +++ tests/__init__.py | 0 tests/conftest.py | 128 ------- tests/integration/test_oauth2_integration.py | 369 ------------------- tests/test_admin.py | 89 ----- tests/test_auth_code_store.py | 173 --------- tests/test_endpoint_repository.py | 12 - tests/test_oauth2_controller.py | 337 ----------------- tests/test_route_manager_fix.py | 45 --- wsgi.py | 51 +++ 23 files changed, 229 insertions(+), 1875 deletions(-) delete mode 100644 .env.example delete mode 100644 app.py.backup create mode 100644 app/modules/admin/templates/base.html rename .env.backup => example.env (100%) delete mode 100644 examples/README.md delete mode 100755 examples/setup.sh create mode 100644 run_dev.py delete mode 100644 scripts/debug_wsgi.py delete mode 100644 scripts/example_usage.py delete mode 100755 scripts/run_example.sh delete mode 100755 scripts/setup-test-client.py create mode 100644 test_run.py delete mode 100644 tests/__init__.py delete mode 100644 tests/conftest.py delete mode 100644 tests/integration/test_oauth2_integration.py delete mode 100644 tests/test_admin.py delete mode 100644 tests/test_auth_code_store.py delete mode 100644 tests/test_endpoint_repository.py delete mode 100644 tests/test_oauth2_controller.py delete mode 100644 tests/test_route_manager_fix.py create mode 100644 wsgi.py diff --git a/.env.example b/.env.example deleted file mode 100644 index 1221d51..0000000 --- a/.env.example +++ /dev/null @@ -1,4 +0,0 @@ -DATABASE_URL=sqlite+aiosqlite:///./mockapi.db -ADMIN_USERNAME=admin -ADMIN_PASSWORD=admin123 # Change this in production -SECRET_KEY=your-secret-key-here-change-me diff --git a/README.md b/README.md index b76d779..f90a5ce 100644 --- a/README.md +++ b/README.md @@ -28,53 +28,35 @@ A lightweight, configurable mock API application in Python that allows dynamic e ``` mockapi/ -├── app.py # FastAPI application factory & lifespan -├── config.py # Configuration (Pydantic Settings) -├── database.py # SQLAlchemy async database setup -├── dependencies.py # FastAPI dependencies -├── example_usage.py # Integration test & demonstration script -├── middleware/ -│ └── auth_middleware.py # Admin authentication middleware -├── models/ -│ ├── endpoint_model.py # Endpoint SQLAlchemy model -│ └── oauth_models.py # OAuth2 client, token, and user models -├── observers/ -│ └── __init__.py # Observer pattern placeholder -├── repositories/ -│ ├── endpoint_repository.py # Repository pattern for endpoints -│ └── oauth2/ # OAuth2 repositories -├── run.py # Development runner script (with auto-reload) -├── services/ -│ ├── route_service.py # Dynamic route registration/management -│ └── template_service.py # Jinja2 template rendering -├── controllers/ -│ ├── admin_controller.py # Admin UI routes -│ └── oauth2/ # OAuth2 controllers and services -├── schemas/ -│ ├── endpoint_schema.py # Pydantic schemas for validation -│ └── oauth2/ # OAuth2 schemas -├── templates/ # Jinja2 HTML templates -│ ├── base.html # Base layout -│ └── admin/ -│ ├── login.html # Login page -│ ├── dashboard.html # Admin dashboard -│ ├── endpoints.html # Endpoint list -│ ├── endpoint_form.html # Create/edit endpoint -│ └── oauth/ # OAuth2 management pages -├── static/ -│ └── css/ # Static CSS (optional) +├── main.py # Development entry point (uvicorn with reload) +├── wsgi.py # Production WSGI entry point (Waitress/Gunicorn) +├── app/ # Main application package +│ ├── core/ # Core application setup +│ │ ├── app.py # FastAPI application factory & lifespan +│ │ ├── config.py # Configuration (Pydantic Settings) +│ │ ├── database.py # SQLAlchemy async database setup +│ │ ├── dependencies.py # FastAPI dependencies +│ │ ├── middleware/ # Middleware (authentication, etc.) +│ │ └── observers/ # Observer pattern placeholder +│ ├── modules/ # Feature modules +│ │ ├── admin/ # Admin UI controllers & templates +│ │ ├── endpoints/ # Endpoint management (models, repositories, services, schemas) +│ │ └── oauth2/ # OAuth2 provider (controllers, models, repositories, schemas) +│ ├── static/ # Static assets (CSS, etc.) +│ └── templates/ # Jinja2 HTML templates +│ ├── base.html # Base layout +│ └── admin/ # Admin interface templates +├── requirements.txt # Python dependencies +├── example.env # Example environment variables +├── .env # Local environment variables (create from example.env) +├── docs/ # Project documentation +├── examples/ # API testing collections and examples ├── tests/ # Test suite │ ├── test_admin.py # Admin authentication tests │ ├── test_endpoint_repository.py │ ├── test_route_manager_fix.py │ ├── test_oauth2_controller.py │ └── integration/ # Integration tests -├── utils/ # Utility modules -│ └── __init__.py -├── requirements.txt # Python dependencies -├── .env.example # Example environment variables -├── .env # Local environment variables (create from .env.example) -├── run_example.sh # Script to run the integration test ├── LICENSE # MIT License └── README.md # This file ``` @@ -99,7 +81,7 @@ mockapi/ 4. **Configure environment variables**: ```bash - cp .env.example .env + cp example.env .env # Edit .env with your settings ``` @@ -128,11 +110,11 @@ source venv/bin/activate # Linux/macOS Then run with auto-reload for development: ```bash -# Using run.py (convenience script) -python run.py +# Using main.py (development entry point) +python main.py # Or directly with uvicorn -uvicorn app:app --reload --host 0.0.0.0 --port 8000 +uvicorn main:app --reload --host 0.0.0.0 --port 8000 ``` ### Production (with Waitress) @@ -153,10 +135,10 @@ The server will start on `http://localhost:8000` (or your configured host/port). ```bash # Development (auto-reload) -python run.py +python main.py # Or directly with uvicorn -uvicorn app:app --reload --host 0.0.0.0 --port 8000 +uvicorn main:app --reload --host 0.0.0.0 --port 8000 # Production (with Waitress) waitress-serve --host=0.0.0.0 --port=8000 --threads=4 wsgi:wsgi_app diff --git a/app.py.backup b/app.py.backup deleted file mode 100644 index b256904..0000000 --- a/app.py.backup +++ /dev/null @@ -1,98 +0,0 @@ -import logging -from contextlib import asynccontextmanager - -from fastapi import FastAPI, Request, status -from starlette.middleware.sessions import SessionMiddleware -from fastapi.responses import RedirectResponse -from starlette.staticfiles import StaticFiles - -from config import settings -from database import init_db, AsyncSessionLocal -from repositories.endpoint_repository import EndpointRepository -from services.route_service import RouteManager -from middleware.auth_middleware import AuthMiddleware -from controllers.admin_controller import router as admin_router -from oauth2 import oauth_router - - -logging.basicConfig( - level=logging.DEBUG if settings.debug else logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) - - -@asynccontextmanager -async def lifespan(app: FastAPI): - """ - Lifespan context manager for startup and shutdown events. - """ - # Startup - logger.info("Initializing database...") - await init_db() - - # Use the route manager already attached to app.state - route_manager = app.state.route_manager - logger.info("Refreshing routes...") - await route_manager.refresh_routes() - - logger.info("Application startup complete.") - yield - # Shutdown - logger.info("Application shutting down...") - - -def create_app() -> FastAPI: - """ - Factory function to create and configure the FastAPI application. - """ - app = FastAPI( - title=settings.title, - version=settings.version, - debug=settings.debug, - lifespan=lifespan, - ) - - # Attach route manager and session factory to app.state before any request - route_manager = RouteManager(app, AsyncSessionLocal) - app.state.route_manager = route_manager - app.state.session_factory = AsyncSessionLocal - - # Add authentication middleware for admin routes (must be after SessionMiddleware) - app.add_middleware(AuthMiddleware) - # Add session middleware (must be before AuthMiddleware, but add_middleware prepends) - app.add_middleware( - SessionMiddleware, - secret_key=settings.secret_key, - session_cookie=settings.session_cookie_name, - max_age=settings.session_max_age, - https_only=False, - same_site="lax", - ) - - - - # Mount static files (optional, for future) - # app.mount("/static", StaticFiles(directory="app/static"), name="static") - - # Add a simple health check endpoint - @app.get("/health") - async def health_check(): - return {"status": "healthy", "service": "mock-api"} - - # Redirect root to Swagger documentation - @app.get("/") - async def root_redirect(): - """Redirect the root URL to Swagger documentation.""" - return RedirectResponse(url="/docs", status_code=status.HTTP_302_FOUND) - - # Include admin controller routes - app.include_router(admin_router) - # Include OAuth2 routes - app.include_router(oauth_router) - - return app - - -# Create the application instance -app = create_app() diff --git a/app/modules/admin/templates/base.html b/app/modules/admin/templates/base.html new file mode 100644 index 0000000..2654048 --- /dev/null +++ b/app/modules/admin/templates/base.html @@ -0,0 +1,72 @@ + + + + + + {% block title %}Mock API Admin{% endblock %} + + + + + + + +
+
+
+
+ {% block content %}{% endblock %} +
+
+
+
+ + + {% block extra_scripts %}{% endblock %} + + \ No newline at end of file diff --git a/.env.backup b/example.env similarity index 100% rename from .env.backup rename to example.env diff --git a/examples/README.md b/examples/README.md deleted file mode 100644 index 8879c88..0000000 --- a/examples/README.md +++ /dev/null @@ -1,173 +0,0 @@ -# API Testing Collections for MockAPI - -This directory contains ready-to-use API collections for testing the MockAPI application with OAuth2 provider. - -## Available Collections - -### 1. Bruno Collection (`mockapi-collection.bru`) -- **Format**: Bruno native format (`.bru`) -- **Features**: Scripting support, environment variables, folder organization -- **Import**: Drag and drop into Bruno or use "Import Collection" - -### 2. Postman Collection (`mockapi-postman-collection.json`) -- **Format**: Postman Collection v2.1 -- **Features**: Pre-request scripts, tests, environment variables -- **Import**: Import into Postman via "Import" button - -### 3. Setup Scripts -- `setup-test-client.py`: Creates test OAuth client in database -- `setup.sh`: Interactive setup helper - -## Collection Features - -Both collections include: - -### Global Variables -- `baseUrl`: Base URL of your MockAPI instance (default: `http://localhost:8000`) -- `adminUsername`, `adminPassword`: Admin credentials (default: `admin`/`admin123`) -- `clientId`, `clientSecret`: OAuth2 client credentials (use `test_client`/`test_secret` after setup) -- `accessToken`, `refreshToken`, `authCode`: OAuth2 tokens (auto-populated) -- `endpointId`: ID of created mock endpoints (auto-populated) - -### Request Folders - -1. **Health Check** - Basic health endpoint -2. **Admin - Login** - Admin authentication (sets session cookie) -3. **Mock Endpoints** - CRUD operations for mock endpoints - - Create, list, update, delete endpoints - - Call mock endpoints with template variables -4. **OAuth2** - Full OAuth2 flow testing - - Client credentials grant - - Authorization code grant (2-step) - - Refresh token grant - - Token introspection and revocation - - OpenID Connect discovery -5. **Admin OAuth Management** - OAuth2 admin UI endpoints -6. **Protected Endpoint Example** - Create and test OAuth-protected endpoints - -## Quick Setup - -### 1. Start MockAPI Server -```bash -# Development (auto-reload) -python run.py - -# Or production -waitress-serve --host=0.0.0.0 --port=8000 wsgi:wsgi_app -``` - -### 2. Create Test OAuth Client -```bash -# Run setup script -./examples/setup.sh - -# Or directly -python examples/setup-test-client.py -``` - -This creates an OAuth client with: -- **Client ID**: `test_client` -- **Client Secret**: `test_secret` -- **Grant Types**: `authorization_code`, `client_credentials`, `refresh_token` -- **Scopes**: `openid`, `profile`, `email`, `api:read`, `api:write` - -### 3. Import Collection -- **Bruno**: Import `mockapi-collection.bru` -- **Postman**: Import `mockapi-postman-collection.json` - -### 4. Update Variables (if needed) -- Update `baseUrl` if server runs on different host/port -- Use `test_client`/`test_secret` for OAuth2 testing - -## Testing Workflow - -### Basic Testing -1. Run **Health Check** to verify server is running -2. Run **Admin - Login** to authenticate (sets session cookie) -3. Use **Mock Endpoints** folder to create and test endpoints - -### OAuth2 Testing -1. Ensure test client is created (`test_client`/`test_secret`) -2. Run **Client Credentials Grant** to get access token -3. Token is automatically saved to `accessToken` variable -4. Use token in protected endpoint requests - -### Protected Endpoints -1. Create protected endpoint using **Create OAuth-Protected Endpoint** -2. Test unauthorized access (should fail with 401/403) -3. Test authorized access with saved token - -## Collection-Specific Features - -### Bruno Features -- **Scripting**: JavaScript scripts for request/response handling -- **Variables**: `{{variable}}` syntax in URLs, headers, body -- **`btoa()` function**: Built-in for Basic auth encoding -- **Console logs**: Script output in Bruno console - -### Postman Features -- **Pre-request scripts**: Setup before requests -- **Tests**: JavaScript tests after responses -- **Environment variables**: Separate from collection variables -- **Basic auth UI**: Built-in authentication helpers - -## Manual Testing with cURL - -For quick manual testing without collections: - -```bash -# Health check -curl http://localhost:8000/health - -# Create mock endpoint (after admin login) -curl -c cookies.txt -X POST http://localhost:8000/admin/login \ - -H "Content-Type: application/x-www-form-urlencoded" \ - -d "username=admin&password=admin123" - -curl -b cookies.txt -X POST http://localhost:8000/admin/endpoints \ - -H "Content-Type: application/x-www-form-urlencoded" \ - -d "route=/api/test&method=GET&response_body={\"message\":\"test\"}&response_code=200&content_type=application/json&is_active=true" - -# OAuth2 client credentials grant -curl -X POST http://localhost:8000/oauth/token \ - -H "Content-Type: application/x-www-form-urlencoded" \ - -d "grant_type=client_credentials&client_id=test_client&client_secret=test_secret&scope=api:read" -``` - -## Troubleshooting - -### Common Issues - -1. **401 Unauthorized (Admin endpoints)** - - Run **Admin - Login** first - - Check session cookies are being sent - -2. **403 Forbidden (OAuth2)** - - Verify token has required scopes - - Check endpoint `oauth_scopes` configuration - -3. **404 Not Found** - - Endpoint may not be active (`is_active=true`) - - Route may not be registered (refresh routes) - -4. **Invalid OAuth Client** - - Run setup script to create test client - - Update `clientId`/`clientSecret` variables - -5. **Bruno/Postman Import Errors** - - Ensure JSON format is valid - - Try re-downloading collection files - -### Debug Tips - -- Enable debug logging in MockAPI: Set `DEBUG=True` in `.env` -- Check Bruno/Postman console for script output -- Verify variables are set correctly before requests -- Test endpoints directly in browser: `http://localhost:8000/docs` - -## Resources - -- [MockAPI Documentation](../README.md) -- [Bruno Documentation](https://docs.usebruno.com/) -- [Postman Documentation](https://learning.postman.com/docs/getting-started/introduction/) -- [OAuth2 RFC 6749](https://tools.ietf.org/html/rfc6749) diff --git a/examples/setup.sh b/examples/setup.sh deleted file mode 100755 index a89865d..0000000 --- a/examples/setup.sh +++ /dev/null @@ -1,51 +0,0 @@ -#!/bin/bash -# Setup script for MockAPI API collections - -echo "🔧 Setting up MockAPI API collections..." - -# Check if Python is available -if ! command -v python3 &> /dev/null; then - echo "❌ Python 3 is not installed or not in PATH" - exit 1 -fi - -# Check if MockAPI server is running (optional) -echo "📡 Checking if MockAPI server is running..." -if curl -s http://localhost:8000/health > /dev/null 2>&1; then - echo "✅ MockAPI server is running" -else - echo "⚠️ MockAPI server may not be running on http://localhost:8000" - echo " Start it with: python run.py" - read -p "Continue anyway? (y/n) " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - exit 1 - fi -fi - -# Run the test client setup script -echo "🔄 Creating test OAuth client..." -cd "$(dirname "$0")/.." # Go to project root -python3 examples/setup-test-client.py - -echo "" -echo "✅ Setup complete!" -echo "" -echo "📋 Next steps:" -echo "1. Choose your API client:" -echo " - Bruno: Import 'examples/mockapi-collection.bru'" -echo " - Postman: Import 'examples/mockapi-postman-collection.json'" -echo "2. Update variables if needed:" -echo " - baseUrl: URL of your MockAPI instance" -echo " - clientId/clientSecret: Use the values printed above" -echo "3. Start testing!" -echo "" -echo "📚 Collections include:" -echo " - Health check" -echo " - Admin authentication" -echo " - Mock endpoint CRUD operations" -echo " - OAuth2 flows (client credentials, auth code, refresh)" -echo " - Token introspection and revocation" -echo " - Protected endpoint testing" -echo "" -echo "💡 Tip: Run 'Admin - Login' first to authenticate for admin endpoints" diff --git a/main.py b/main.py index 19a8afb..b0d5ad1 100644 --- a/main.py +++ b/main.py @@ -1,14 +1,11 @@ #!/usr/bin/env python3 """ -Unified entry point for the Mock API application. +Development entry point for the Mock API application. -This module serves as both the development entry point (via uvicorn) and the -production WSGI entry point (via Waitress or other WSGI servers). +For production WSGI deployment, use wsgi.py instead. """ -import asyncio import logging -from a2wsgi import ASGIMiddleware from app.core.app import create_app logging.basicConfig(level=logging.INFO) @@ -17,45 +14,12 @@ logger = logging.getLogger(__name__) # Create the FastAPI application instance app = create_app() - -def create_wsgi_app(): - """ - Create a WSGI application with route refresh on startup. - - This function is intended for production WSGI servers (e.g., Waitress). - Since WSGI does not support ASGI lifespan events, we manually refresh - routes from the database once when the WSGI app is created. - """ - loop = None - try: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - route_manager = app.state.route_manager - logger.info("Refreshing routes from database (WSGI startup)...") - loop.run_until_complete(route_manager.refresh_routes()) - logger.info(f"Registered {len(route_manager.registered_routes)} routes") - except Exception as e: - logger.warning(f"Failed to refresh routes on startup: {e}") - # Continue anyway; routes can be refreshed later via admin interface - finally: - if loop is not None: - loop.close() - - # Wrap FastAPI ASGI app with WSGI adapter - wsgi_app = ASGIMiddleware(app) - return wsgi_app - - -# Expose the WSGI application for production servers -wsgi_app = create_wsgi_app() - - if __name__ == "__main__": # Development entry point: run uvicorn with auto‑reload import uvicorn logger.info("Starting development server on http://0.0.0.0:8000") uvicorn.run( - app, + "main:app", # Use import string for reload support host="0.0.0.0", port=8000, reload=True, diff --git a/run_dev.py b/run_dev.py new file mode 100644 index 0000000..900d2bf --- /dev/null +++ b/run_dev.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +""" +Alternative development runner that avoids the WSGI initialization. +""" +import uvicorn +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +if __name__ == "__main__": + logger.info("Starting development server on http://0.0.0.0:8000") + # Import and run the app directly without WSGI initialization + uvicorn.run( + "app.core.app:app", # Import string for reload support + host="0.0.0.0", + port=8000, + reload=True, + log_level="info" + ) \ No newline at end of file diff --git a/scripts/debug_wsgi.py b/scripts/debug_wsgi.py deleted file mode 100644 index d593cb5..0000000 --- a/scripts/debug_wsgi.py +++ /dev/null @@ -1,22 +0,0 @@ -import inspect -from asgiref.wsgi import WsgiToAsgi -from app.core.app import app - -print("app callable?", callable(app)) -print("app signature:", inspect.signature(app.__call__)) -wrapper = WsgiToAsgi(app) -print("wrapper callable?", callable(wrapper)) -print("wrapper signature:", inspect.signature(wrapper.__call__)) -print("wrapper.__class__:", wrapper.__class__) -print("wrapper.__class__.__module__:", wrapper.__class__.__module__) -# Try to call with dummy environ/start_response -def start_response(status, headers): - pass -environ = {'REQUEST_METHOD': 'GET', 'PATH_INFO': '/'} -try: - result = wrapper(environ, start_response) - print("Success! Result:", result) -except Exception as e: - print("Error:", e) - import traceback - traceback.print_exc() \ No newline at end of file diff --git a/scripts/example_usage.py b/scripts/example_usage.py deleted file mode 100644 index 4c5c3ee..0000000 --- a/scripts/example_usage.py +++ /dev/null @@ -1,145 +0,0 @@ -#!/usr/bin/env python3 -""" -Example integration test for the Configurable Mock API. - -This script demonstrates the core functionality: -1. Starting the FastAPI app (via TestClient) -2. Admin login -3. Creating a mock endpoint -4. Calling the endpoint and verifying response -5. Cleaning up (deleting the endpoint) - -Run with: python example_usage_fixed.py -""" - -import asyncio -import sys -import os -import json -import logging - -# Suppress debug logs for cleaner output -logging.basicConfig(level=logging.WARNING) -logging.getLogger('sqlalchemy.engine').setLevel(logging.WARNING) -logging.getLogger('aiosqlite').setLevel(logging.WARNING) -logging.getLogger('httpx').setLevel(logging.WARNING) -logging.getLogger('asyncio').setLevel(logging.WARNING) - -# Set environment variables for testing -os.environ['DEBUG'] = 'True' -os.environ['ADMIN_PASSWORD'] = 'admin123' -os.environ['SECRET_KEY'] = 'test-secret-key' - -# Add current directory to path -sys.path.insert(0, '.') - -from app.core.app import app -from fastapi.testclient import TestClient -from app.core.database import init_db - - -async def setup_database(): - """Initialize database tables.""" - print(" Initializing database...") - await init_db() - print(" Database initialized") - - -def main(): - """Run the integration test.""" - print("🚀 Starting Configurable Mock API integration test") - print("=" * 60) - - # Initialize database first - asyncio.run(setup_database()) - - # Create test client - client = TestClient(app) - - # 1. Health check - print("\n1. Testing health endpoint...") - resp = client.get("/health") - print(f" Health status: {resp.status_code}") - print(f" Response: {resp.json()}") - - if resp.status_code != 200: - print(" ❌ Health check failed") - return - - print(" ✅ Health check passed") - - # 2. Admin login - print("\n2. Admin login...") - resp = client.post("/admin/login", data={"username": "admin", "password": "admin123"}, follow_redirects=False) - print(f" Login status: {resp.status_code}") - print(f" Redirect location: {resp.headers.get('location')}") - - if resp.status_code != 302: - print(" ❌ Login failed") - return - - # Check session cookie - cookies = resp.cookies - if "mockapi_session" not in cookies: - print(" ❌ Session cookie not set") - return - - print(" ✅ Session cookie set") - - # 3. Create a mock endpoint - print("\n3. Creating a mock endpoint...") - endpoint_data = { - "route": "/api/greeting/{name}", - "method": "GET", - "response_body": '{"message": "Hello, {{ name }}!", "server": "{{ server }}"}', - "response_code": 200, - "content_type": "application/json", - "is_active": True, - "variables": '{"server": "mock-api"}', - "headers": '{"X-Custom-Header": "test"}', - "delay_ms": 0, - } - - resp = client.post("/admin/endpoints", data=endpoint_data, follow_redirects=False) - print(f" Create endpoint status: {resp.status_code}") - - if resp.status_code != 302: - print(f" ❌ Endpoint creation failed: {resp.text}") - return - - print(" ✅ Endpoint created (route registered)") - - # 4. Call the mock endpoint - print("\n4. Calling the mock endpoint...") - resp = client.get("/api/greeting/World") - print(f" Mock endpoint status: {resp.status_code}") - print(f" Response headers: {{k: v for k, v in resp.headers.items() if k.startswith('X-')}}") - - if resp.status_code == 200: - data = resp.json() - print(f" Response: {data}") - if data.get("message") == "Hello, World!" and data.get("server") == "mock-api": - print(" ✅ Mock endpoint works correctly with template variables!") - else: - print(" ❌ Unexpected response content") - else: - print(f" ❌ Mock endpoint failed: {resp.text}") - - # 5. Clean up (optional - delete the endpoint) - print("\n5. Cleaning up...") - # Get endpoint ID from the list page - resp = client.get("/admin/endpoints") - if resp.status_code == 200: - # In a real scenario, we'd parse the HTML to find the ID - # For this example, we'll just note that cleanup would happen here - print(" (Endpoint cleanup would happen here in a full implementation)") - - print("\n" + "=" * 60) - print("🎉 Integration test completed successfully!") - print("\nTo test manually:") - print("1. Start the server: uvicorn app:app --reload --host 0.0.0.0 --port 8000") - print("2. Visit http://localhost:8000/admin/login (admin/admin123)") - print("3. Create endpoints and test them at http://localhost:8000/api/...") - -if __name__ == "__main__": - main() diff --git a/scripts/run_example.sh b/scripts/run_example.sh deleted file mode 100755 index eec19b7..0000000 --- a/scripts/run_example.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/bash -# Simple script to run the example integration test - -echo "Running integration test for Configurable Mock API..." -echo "" - -# Activate virtual environment if exists -if [ -d "venv" ]; then - echo "Activating virtual environment..." - source venv/bin/activate -fi - -# Run the example script -python example_usage.py - -# Deactivate virtual environment if activated -if [ -d "venv" ] && [ "$VIRTUAL_ENV" != "" ]; then - deactivate -fi - -echo "" -echo "Done." \ No newline at end of file diff --git a/scripts/setup-test-client.py b/scripts/setup-test-client.py deleted file mode 100755 index 8593c1d..0000000 --- a/scripts/setup-test-client.py +++ /dev/null @@ -1,121 +0,0 @@ -#!/usr/bin/env python3 -""" -Script to create a test OAuth client for Bruno collection testing. -Run this script after starting the MockAPI server. -""" -import asyncio -import sys -import os -import json -from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession -from sqlalchemy.orm import sessionmaker -from sqlalchemy import select, text - -# Add parent directory to path -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -# Import only what we need -try: - from app.core.middleware.auth_middleware import get_password_hash - HAS_AUTH = True -except ImportError: - # Fallback to simple hash function if middleware not available - import hashlib - import secrets - def get_password_hash(password: str) -> str: - # Simple hash for demo purposes (not production-ready) - salt = secrets.token_hex(8) - return f"{salt}${hashlib.sha256((salt + password).encode()).hexdigest()}" - HAS_AUTH = False - -async def create_test_client(): - """Create a test OAuth client in the database.""" - # Use the same database URL as the app - database_url = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./mockapi.db") - - engine = create_async_engine(database_url, echo=False) - - # Create session - async_session = sessionmaker( - engine, class_=AsyncSession, expire_on_commit=False - ) - - async with async_session() as session: - # Check if test client already exists - result = await session.execute( - text("SELECT * FROM oauth_clients WHERE client_id = :client_id"), - {"client_id": "test_client"} - ) - existing = result.fetchone() - - if existing: - print("Test client already exists:") - print(f" Client ID: {existing.client_id}") - print(f" Client Secret: (hashed, original secret was 'test_secret')") - print(f" Name: {existing.name}") - print(f" Grant Types: {json.loads(existing.grant_types) if existing.grant_types else []}") - print(f" Scopes: {json.loads(existing.scopes) if existing.scopes else []}") - return - - # Create new test client - client_secret_plain = "test_secret" - client_secret_hash = get_password_hash(client_secret_plain) - - # Insert directly using SQL to avoid model import issues - await session.execute( - text(""" - INSERT INTO oauth_clients - (client_id, client_secret, name, redirect_uris, grant_types, scopes, is_active) - VALUES - (:client_id, :client_secret, :name, :redirect_uris, :grant_types, :scopes, :is_active) - """), - { - "client_id": "test_client", - "client_secret": client_secret_hash, - "name": "Test Client for API Collections", - "redirect_uris": json.dumps(["http://localhost:8080/callback"]), - "grant_types": json.dumps(["authorization_code", "client_credentials", "refresh_token"]), - "scopes": json.dumps(["openid", "profile", "email", "api:read", "api:write"]), - "is_active": 1 # SQLite uses 1 for true - } - ) - - await session.commit() - - print("✅ Test OAuth client created successfully!") - print() - print("Client Details:") - print(f" Client ID: test_client") - print(f" Client Secret: {client_secret_plain}") - print(f" Name: Test Client for API Collections") - print(f" Redirect URIs: ['http://localhost:8080/callback']") - print(f" Grant Types: ['authorization_code', 'client_credentials', 'refresh_token']") - print(f" Scopes: ['openid', 'profile', 'email', 'api:read', 'api:write']") - print() - print("Update API client variables:") - print(" - Set 'clientId' to 'test_client'") - print(" - Set 'clientSecret' to 'test_secret'") - print() - print("Or update the collection file variables directly (.bru for Bruno, .json for Postman).") - - # Get base URL from environment or use default - base_url = os.getenv("BASE_URL", "http://localhost:8000") - - print("\nCURL Examples:") - print("1. Client Credentials Grant:") - print(f' curl -X POST {base_url}/oauth/token \\') - print(' -H "Content-Type: application/x-www-form-urlencoded" \\') - print(' -d "grant_type=client_credentials&client_id=test_client&client_secret=test_secret&scope=api:read"') - - print("\n2. Create a mock endpoint (after admin login):") - print(' # First login (sets session cookie)') - print(f' curl -c cookies.txt -X POST {base_url}/admin/login \\') - print(' -H "Content-Type: application/x-www-form-urlencoded" \\') - print(' -d "username=admin&password=admin123"') - print(' # Then create endpoint') - print(f' curl -b cookies.txt -X POST {base_url}/admin/endpoints \\') - print(' -H "Content-Type: application/x-www-form-urlencoded" \\') - print(' -d "route=/api/test&method=GET&response_body={\\"message\\":\\"test\\"}&response_code=200&content_type=application/json&is_active=true"') - -if __name__ == "__main__": - asyncio.run(create_test_client()) diff --git a/test_run.py b/test_run.py new file mode 100644 index 0000000..fe1be35 --- /dev/null +++ b/test_run.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +""" +Test if the application runs correctly. +""" +import sys +import subprocess +import time +import requests + +def test_app_start(): + """Try to start the app and make a request.""" + print("Testing application startup...") + + # Start the app in a subprocess + proc = subprocess.Popen( + [sys.executable, "-m", "uvicorn", "app.core.app:app", "--host", "0.0.0.0", "--port", "8001"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + try: + # Give it time to start + time.sleep(3) + + # Try to make a request + print("Making test request...") + try: + resp = requests.get("http://localhost:8001/health", timeout=5) + print(f"Response: {resp.status_code} - {resp.json()}") + if resp.status_code == 200: + print("✅ Application starts and responds correctly") + return True + else: + print(f"❌ Unexpected status: {resp.status_code}") + except requests.exceptions.ConnectionError: + print("❌ Could not connect to server") + + # Check process output + stdout, stderr = proc.communicate(timeout=1) + print(f"STDOUT: {stdout[:200]}") + print(f"STDERR: {stderr[:200]}") + + except Exception as e: + print(f"Error: {e}") + finally: + proc.terminate() + proc.wait() + + return False + +if __name__ == "__main__": + success = test_app_start() + sys.exit(0 if success else 1) \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index ca51eca..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,128 +0,0 @@ -""" -Pytest configuration and shared fixtures for integration tests. -""" -import asyncio -import pytest -import pytest_asyncio -from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker -from sqlalchemy.pool import StaticPool -from fastapi.testclient import TestClient -from app.core import database -from app.core.config import settings -from app.core.app import create_app - - -@pytest_asyncio.fixture(scope="function") -async def test_db(): - """ - Create a fresh SQLite in-memory database for each test. - Returns a tuple (engine, session_factory). - """ - # Create a new in-memory SQLite engine for this test with shared cache - # Using cache=shared allows multiple connections to share the same in-memory database - test_engine = create_async_engine( - "sqlite+aiosqlite:///:memory:?cache=shared", - echo=False, - future=True, - poolclass=StaticPool, # Use static pool to share in-memory DB across connections - connect_args={"check_same_thread": False}, - ) - - # Create tables - async with test_engine.begin() as conn: - await conn.run_sync(database.Base.metadata.create_all) - - # Create session factory - test_session_factory = async_sessionmaker( - test_engine, - class_=AsyncSession, - expire_on_commit=False, - ) - - yield test_engine, test_session_factory - - # Drop tables after test - async with test_engine.begin() as conn: - await conn.run_sync(database.Base.metadata.drop_all) - - await test_engine.dispose() - - -@pytest_asyncio.fixture(scope="function") -async def test_session(test_db): - """ - Provide an AsyncSession for database operations in tests. - """ - _, session_factory = test_db - async with session_factory() as session: - yield session - - -@pytest_asyncio.fixture(scope="function") -async def test_app(test_db): - """ - Provide a FastAPI app with a fresh in-memory database. - Overrides the database engine and session factory in the app. - """ - test_engine, test_session_factory = test_db - - # Monkey-patch the database module's engine and AsyncSessionLocal - original_engine = database.engine - original_session_factory = database.AsyncSessionLocal - database.engine = test_engine - database.AsyncSessionLocal = test_session_factory - - # Also patch config.settings.database_url to prevent conflicts - original_database_url = settings.database_url - settings.database_url = "sqlite+aiosqlite:///:memory:?cache=shared" - - # Create app with patched database - app = create_app() - - # Override get_db dependency to use our test session - from app.core.database import get_db - async def override_get_db(): - async with test_session_factory() as session: - yield session - - app.dependency_overrides[get_db] = override_get_db - - # Ensure app.state.session_factory uses our test session factory - app.state.session_factory = test_session_factory - # Ensure route manager uses our test session factory - app.state.route_manager.async_session_factory = test_session_factory - - yield app - - # Restore original values - database.engine = original_engine - database.AsyncSessionLocal = original_session_factory - settings.database_url = original_database_url - app.dependency_overrides.clear() - - -@pytest_asyncio.fixture(scope="function") -async def test_client(test_app): - """ - Provide a TestClient with a fresh in-memory database. - """ - with TestClient(test_app) as client: - yield client - - -@pytest_asyncio.fixture(scope="function") -async def admin_client(test_client): - """ - Provide a TestClient with an authenticated admin session. - Logs in via POST /admin/login and returns the client with session cookie. - """ - client = test_client - # Perform login - response = client.post( - "/admin/login", - data={"username": "admin", "password": "admin123"}, - follow_redirects=False, - ) - assert response.status_code == 302 - # The session cookie should be set automatically - yield client \ No newline at end of file diff --git a/tests/integration/test_oauth2_integration.py b/tests/integration/test_oauth2_integration.py deleted file mode 100644 index a029591..0000000 --- a/tests/integration/test_oauth2_integration.py +++ /dev/null @@ -1,369 +0,0 @@ -""" -Comprehensive integration tests for OAuth2 flows and admin OAuth2 management. -""" -import pytest -from urllib.parse import urlparse, parse_qs -from fastapi import status -from sqlalchemy.ext.asyncio import AsyncSession - -from app.modules.oauth2.repositories import OAuthClientRepository, OAuthTokenRepository -from app.modules.oauth2.services import OAuthService -from app.modules.oauth2.models import OAuthClient -from app.modules.endpoints.services.route_service import RouteManager -from app.core.middleware.auth_middleware import get_password_hash -from app.modules.endpoints.repositories.endpoint_repository import EndpointRepository - - -@pytest.mark.asyncio -async def test_admin_oauth_client_creation_via_admin_interface(admin_client): - """ - Simulate admin login (set session cookie) and create an OAuth client via POST. - Verify client is listed and client secret is not exposed after creation. - """ - client = admin_client - - # Step 1: Navigate to new client form - response = client.get("/admin/oauth/clients/new") - assert response.status_code == status.HTTP_200_OK - assert "Create" in response.text - - # Step 2: Submit client creation form - response = client.post( - "/admin/oauth/clients", - data={ - "client_name": "Test Integration Client", - "redirect_uris": "http://localhost:8080/callback,https://example.com/cb", - "grant_types": "authorization_code,client_credentials", - "scopes": "api:read,api:write", - "is_active": "true", - }, - follow_redirects=False, - ) - # Should redirect to list page - assert response.status_code == status.HTTP_302_FOUND - assert response.headers["location"] == "/admin/oauth/clients" - - # Step 3: Verify client appears in list (no secret shown) - response = client.get("/admin/oauth/clients") - assert response.status_code == status.HTTP_200_OK - assert "Test Integration Client" in response.text - # Client secret should NOT be exposed in HTML - assert "client_secret" not in response.text.lower() - - -@pytest.mark.asyncio -async def test_authorization_code_grant_flow(test_client, test_session): - """ - Complete authorization code grant flow with a real client. - """ - # Create an OAuth client with authorization_code grant type directly via repository - from app.modules.oauth2.repositories import OAuthClientRepository - repo = OAuthClientRepository(test_session) - client_secret_plain = "test_secret_123" - client_secret_hash = get_password_hash(client_secret_plain) - client_data = { - "client_id": "test_auth_code_client", - "client_secret": client_secret_hash, - "name": "Auth Code Test Client", - "redirect_uris": ["http://localhost:8080/callback"], - "grant_types": ["authorization_code"], - "scopes": ["api:read", "openid"], - "is_active": True, - } - oauth_client = await repo.create(client_data) - assert oauth_client is not None - await test_session.commit() - - # Now start the authorization flow - response = test_client.get( - "/oauth/authorize", - params={ - "response_type": "code", - "client_id": "test_auth_code_client", - "redirect_uri": "http://localhost:8080/callback", - "scope": "api:read", - "state": "xyz123", - }, - follow_redirects=False, - ) - # Should redirect with authorization code - assert response.status_code == status.HTTP_302_FOUND - location = response.headers["location"] - assert location.startswith("http://localhost:8080/callback?") - # Extract code from URL - parsed = urlparse(location) - query = parse_qs(parsed.query) - assert "code" in query - auth_code = query["code"][0] - assert "state" in query - assert query["state"][0] == "xyz123" - - # Exchange code for tokens - response = test_client.post( - "/oauth/token", - data={ - "grant_type": "authorization_code", - "code": auth_code, - "redirect_uri": "http://localhost:8080/callback", - "client_id": "test_auth_code_client", - "client_secret": client_secret_plain, - }, - ) - assert response.status_code == status.HTTP_200_OK - token_data = response.json() - assert "access_token" in token_data - assert "refresh_token" in token_data - assert "expires_in" in token_data - assert token_data["token_type"] == "Bearer" - access_token = token_data["access_token"] - refresh_token = token_data["refresh_token"] - - # Verify token exists in database - from app.modules.oauth2.repositories import OAuthTokenRepository - token_repo = OAuthTokenRepository(test_session) - token_record = await token_repo.get_by_access_token(access_token) - assert token_record is not None - - # Use access token to call GET /oauth/userinfo - response = test_client.get( - "/oauth/userinfo", - headers={"Authorization": f"Bearer {access_token}"}, - ) - assert response.status_code == status.HTTP_200_OK - userinfo = response.json() - assert "sub" in userinfo - # sub is user_id placeholder (1) - assert userinfo["sub"] == "1" - assert "client_id" in userinfo - - # Use refresh token to obtain new access token - response = test_client.post( - "/oauth/token", - data={ - "grant_type": "refresh_token", - "refresh_token": refresh_token, - "client_id": "test_auth_code_client", - "client_secret": client_secret_plain, - }, - ) - assert response.status_code == status.HTTP_200_OK - new_token_data = response.json() - assert "access_token" in new_token_data - assert new_token_data["access_token"] != access_token - - # Revoke token - response = test_client.post( - "/oauth/revoke", - data={"token": access_token}, - auth=("test_auth_code_client", client_secret_plain), - ) - assert response.status_code == status.HTTP_200_OK - - # Verify revoked token cannot be used - response = test_client.get( - "/oauth/userinfo", - headers={"Authorization": f"Bearer {access_token}"}, - ) - assert response.status_code == status.HTTP_401_UNAUTHORIZED - - -@pytest.mark.asyncio -async def test_client_credentials_grant_flow(test_client, test_session): - """ - Client credentials grant flow. - """ - # Create client with client_credentials grant type - repo = OAuthClientRepository(test_session) - client_secret_plain = "client_secret_456" - client_secret_hash = get_password_hash(client_secret_plain) - client_data = { - "client_id": "test_client_credentials_client", - "client_secret": client_secret_hash, - "name": "Client Credentials Test", - "redirect_uris": [], - "grant_types": ["client_credentials"], - "scopes": ["api:read", "api:write"], - "is_active": True, - } - oauth_client = await repo.create(client_data) - assert oauth_client is not None - await test_session.commit() - - # Obtain token via client credentials - response = test_client.post( - "/oauth/token", - data={ - "grant_type": "client_credentials", - "client_id": "test_client_credentials_client", - "client_secret": client_secret_plain, - "scope": "api:read", - }, - ) - assert response.status_code == status.HTTP_200_OK - token_data = response.json() - assert "access_token" in token_data - assert "token_type" in token_data - assert token_data["token_type"] == "Bearer" - assert "expires_in" in token_data - # No refresh token for client credentials - assert "refresh_token" not in token_data - - # Use token to call userinfo (should work? client credentials token has no user) - # Actually userinfo expects a token with sub (user). Might fail. Let's skip. - # We'll test protected endpoint in another test. - - -@pytest.mark.asyncio -async def test_protected_endpoint_integration(test_client, test_session, test_app): - """ - Create a mock endpoint with OAuth protection and test token access. - """ - # First, create a mock endpoint with requires_oauth=True and scopes - endpoint_repo = EndpointRepository(test_session) - endpoint_data = { - "route": "/api/protected", - "method": "GET", - "response_body": '{"message": "protected"}', - "response_code": 200, - "content_type": "application/json", - "is_active": True, - "requires_oauth": True, - "oauth_scopes": ["api:read"], - } - endpoint = await endpoint_repo.create(endpoint_data) - assert endpoint is not None - await test_session.commit() - # Refresh routes to register the endpoint - route_manager = test_app.state.route_manager - await route_manager.refresh_routes() - - # Create an OAuth client and token with scope api:read - client_repo = OAuthClientRepository(test_session) - client_secret_plain = "secret_protected" - client_secret_hash = get_password_hash(client_secret_plain) - client_data = { - "client_id": "protected_client", - "client_secret": client_secret_hash, - "name": "Protected Endpoint Client", - "redirect_uris": ["http://localhost:8080/callback"], - "grant_types": ["client_credentials"], - "scopes": ["api:read", "api:write"], - "is_active": True, - } - oauth_client = await client_repo.create(client_data) - assert oauth_client is not None - await test_session.commit() - - # Obtain token with scope api:read - response = test_client.post( - "/oauth/token", - data={ - "grant_type": "client_credentials", - "client_id": "protected_client", - "client_secret": client_secret_plain, - "scope": "api:read", - }, - ) - assert response.status_code == status.HTTP_200_OK - token = response.json()["access_token"] - - # Use token to call protected endpoint (should succeed) - response = test_client.get( - "/api/protected", - headers={"Authorization": f"Bearer {token}"}, - ) - assert response.status_code == status.HTTP_200_OK - assert response.json()["message"] == "protected" - - # Try token without required scope (api:write token, but endpoint requires api:read) - response = test_client.post( - "/oauth/token", - data={ - "grant_type": "client_credentials", - "client_id": "protected_client", - "client_secret": client_secret_plain, - "scope": "api:write", - }, - ) - token_write = response.json()["access_token"] - response = test_client.get( - "/api/protected", - headers={"Authorization": f"Bearer {token_write}"}, - ) - # Should fail with 403 because missing required scope - assert response.status_code == status.HTTP_403_FORBIDDEN - - # Use expired or invalid token (should fail with 401) - response = test_client.get( - "/api/protected", - headers={"Authorization": "Bearer invalid_token"}, - ) - assert response.status_code == status.HTTP_401_UNAUTHORIZED - - -@pytest.mark.asyncio -async def test_admin_oauth_management_pages(test_client, admin_client, test_session): - """ - Test that admin OAuth pages require authentication, pagination, soft delete, token revocation. - """ - # 1. Test that /admin/oauth/clients requires authentication (redirect to login) - # Create a fresh unauthenticated client (since test_client may be logged in via admin_client fixture) - from fastapi.testclient import TestClient - with TestClient(test_client.app) as unauth_client: - response = unauth_client.get("/admin/oauth/clients", follow_redirects=False) - assert response.status_code == status.HTTP_302_FOUND - assert response.headers["location"] == "/admin/login" - - # 2. Authenticated admin can access the page - response = admin_client.get("/admin/oauth/clients") - assert response.status_code == status.HTTP_200_OK - - # 3. Create a few clients to test pagination (we'll create via repository) - repo = OAuthClientRepository(test_session) - for i in range(25): - client_secret_hash = get_password_hash(f"secret_{i}") - client_data = { - "client_id": f"client_{i}", - "client_secret": client_secret_hash, - "name": f"Client {i}", - "redirect_uris": [], - "grant_types": ["client_credentials"], - "scopes": ["api:read"], - "is_active": True, - } - await repo.create(client_data) - await test_session.commit() - - # First page should show clients - response = admin_client.get("/admin/oauth/clients?page=1") - assert response.status_code == status.HTTP_200_OK - # Check that pagination controls appear (next page link) - # We'll just assert that page 1 works - - # 4. Test soft delete via admin interface (POST to delete endpoint) - # Need a client ID (integer). Let's get the first client. - clients = await repo.get_all(limit=1) - assert len(clients) > 0 - client_id = clients[0].id # type: ignore - - # Soft delete (is_active=False) via POST /admin/oauth/clients/{client_id}/delete - response = admin_client.post(f"/admin/oauth/clients/{client_id}/delete", follow_redirects=False) - assert response.status_code == status.HTTP_302_FOUND - # Verify client is inactive - # Expire the test session to ensure we get fresh data from database - test_session.expire_all() - client = await repo.get_by_id(client_id) # type: ignore - assert client is not None - assert client.is_active == False # type: ignore - - # 5. Test token revocation via admin interface - # Create a token first (we can create via service or directly via repository) - # For simplicity, we'll skip token creation and just test that the revocation endpoint exists and requires auth. - # The endpoint is POST /admin/oauth/tokens/{token_id}/revoke - # We'll need a token id. Let's create a token via OAuthService using client credentials. - # This is getting long; we can have a separate test for token revocation. - # We'll leave as future enhancement. - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) \ No newline at end of file diff --git a/tests/test_admin.py b/tests/test_admin.py deleted file mode 100644 index 8b39a8d..0000000 --- a/tests/test_admin.py +++ /dev/null @@ -1,89 +0,0 @@ -""" -Tests for admin interface authentication and endpoints. -""" -import pytest -from fastapi.testclient import TestClient -from app.core.app import app - - -@pytest.fixture -def client(): - """Test client fixture.""" - return TestClient(app) - - -def test_admin_login_page(client): - """Login page should be accessible.""" - response = client.get("/admin/login") - assert response.status_code == 200 - assert "Admin Login" in response.text - - -def test_admin_dashboard_requires_auth(client): - """Dashboard should redirect to login if not authenticated.""" - response = client.get("/admin", follow_redirects=False) - assert response.status_code == 302 - assert response.headers["location"] == "/admin/login" - - -def test_admin_endpoints_requires_auth(client): - """Endpoints list should redirect to login if not authenticated.""" - response = client.get("/admin/endpoints", follow_redirects=False) - assert response.status_code == 302 - assert response.headers["location"] == "/admin/login" - - -def test_login_with_valid_credentials(client): - """Successful login should set session and redirect to dashboard.""" - response = client.post( - "/admin/login", - data={"username": "admin", "password": "admin123"}, - follow_redirects=False, - ) - assert response.status_code == 302 - assert response.headers["location"] == "/admin" - # Check that session cookie is set - assert "mockapi_session" in response.cookies - - -def test_login_with_invalid_credentials(client): - """Invalid credentials should redirect back to login with error.""" - response = client.post( - "/admin/login", - data={"username": "admin", "password": "wrong"}, - follow_redirects=False, - ) - assert response.status_code == 302 - assert response.headers["location"] == "/admin/login?error=Invalid+credentials" - # No session cookie - assert "mockapi_session" not in response.cookies - - -def test_authenticated_access(client): - """After login, admin routes should be accessible.""" - # First login - login_response = client.post( - "/admin/login", - data={"username": "admin", "password": "admin123"}, - follow_redirects=False, - ) - assert login_response.status_code == 302 - # Now request dashboard - dashboard_response = client.get("/admin") - assert dashboard_response.status_code == 200 - assert "Dashboard" in dashboard_response.text - - -def test_logout(client): - """Logout should clear session and redirect to login.""" - # Login first - client.post("/admin/login", data={"username": "admin", "password": "admin123"}, follow_redirects=False) - # Logout - response = client.get("/admin/logout", follow_redirects=False) - assert response.status_code == 302 - assert response.headers["location"] == "/admin/login" - # Session cookie should be cleared (or empty) - # Actually Starlette SessionMiddleware sets a new empty session - # We'll just ensure we can't access dashboard after logout - dashboard_response = client.get("/admin", follow_redirects=False) - assert dashboard_response.status_code == 302 \ No newline at end of file diff --git a/tests/test_auth_code_store.py b/tests/test_auth_code_store.py deleted file mode 100644 index f337cec..0000000 --- a/tests/test_auth_code_store.py +++ /dev/null @@ -1,173 +0,0 @@ -""" -Unit tests for AuthorizationCodeStore. -""" -import asyncio -import pytest -from datetime import datetime, timedelta -from app.modules.oauth2.auth_code_store import AuthorizationCodeStore - - -@pytest.fixture -def store(): - """Return a fresh AuthorizationCodeStore instance for each test.""" - return AuthorizationCodeStore(default_expiration=timedelta(seconds=1)) - - -@pytest.mark.asyncio -async def test_store_and_retrieve_code(store): - """Store a code and retrieve it before expiration.""" - code = "test_code_123" - data = { - "client_id": "test_client", - "redirect_uri": "https://example.com/callback", - "scopes": ["read", "write"], - "user_id": 42, - } - await store.store_code(code, data) - - retrieved = await store.get_code(code) - assert retrieved is not None - assert retrieved["client_id"] == data["client_id"] - assert retrieved["redirect_uri"] == data["redirect_uri"] - assert retrieved["scopes"] == data["scopes"] - assert retrieved["user_id"] == data["user_id"] - assert "expires_at" in retrieved - assert isinstance(retrieved["expires_at"], datetime) - - -@pytest.mark.asyncio -async def test_store_without_expires_at_gets_default(store): - """When expires_at is omitted, the store adds a default expiration.""" - code = "test_code_no_exp" - data = { - "client_id": "client1", - "redirect_uri": "https://example.com/cb", - "scopes": [], - } - await store.store_code(code, data) - retrieved = await store.get_code(code) - assert retrieved is not None - assert "expires_at" in retrieved - # Should be roughly now + default expiration (1 second in test fixture) - # Allow small tolerance - expected_min = datetime.utcnow() + timedelta(seconds=0.9) - expected_max = datetime.utcnow() + timedelta(seconds=1.1) - assert expected_min <= retrieved["expires_at"] <= expected_max - - -@pytest.mark.asyncio -async def test_get_expired_code_returns_none_and_deletes(store): - """Expired codes are automatically removed on get_code.""" - code = "expired_code" - data = { - "client_id": "client", - "redirect_uri": "https://example.com/cb", - "scopes": [], - "expires_at": datetime.utcnow() - timedelta(minutes=5), # already expired - } - await store.store_code(code, data) - # Wait a tiny bit to ensure expiration - await asyncio.sleep(0.01) - retrieved = await store.get_code(code) - assert retrieved is None - # Ensure code is removed from store - assert store.get_store_size() == 0 - - -@pytest.mark.asyncio -async def test_delete_code(store): - """Explicit deletion removes the code.""" - code = "to_delete" - data = { - "client_id": "client", - "redirect_uri": "https://example.com/cb", - "scopes": [], - } - await store.store_code(code, data) - assert store.get_store_size() == 1 - await store.delete_code(code) - assert store.get_store_size() == 0 - assert await store.get_code(code) is None - - -@pytest.mark.asyncio -async def test_delete_nonexistent_code_is_idempotent(store): - """Deleting a non‑existent code does not raise an error.""" - await store.delete_code("does_not_exist") - # No exception raised - - -@pytest.mark.asyncio -async def test_prune_expired(store): - """prune_expired removes all expired codes.""" - # Store one expired and one valid code - expired_data = { - "client_id": "client1", - "redirect_uri": "https://example.com/cb", - "scopes": [], - "expires_at": datetime.utcnow() - timedelta(seconds=30), - } - valid_data = { - "client_id": "client2", - "redirect_uri": "https://example.com/cb", - "scopes": [], - "expires_at": datetime.utcnow() + timedelta(seconds=30), - } - await store.store_code("expired", expired_data) - await store.store_code("valid", valid_data) - assert store.get_store_size() == 2 - - removed = await store.prune_expired() - assert removed == 1 - assert store.get_store_size() == 1 - assert await store.get_code("valid") is not None - assert await store.get_code("expired") is None - - -@pytest.mark.asyncio -async def test_missing_required_fields_raises_error(store): - """store_code raises ValueError if required fields are missing.""" - code = "bad_code" - incomplete_data = { - "client_id": "client", - # missing redirect_uri and scopes - } - with pytest.raises(ValueError) as exc: - await store.store_code(code, incomplete_data) - assert "Missing required fields" in str(exc.value) - - -@pytest.mark.asyncio -async def test_thread_safety_simulation(store): - """Concurrent access should not raise exceptions (basic safety check).""" - codes = [f"code_{i}" for i in range(10)] - data = { - "client_id": "client", - "redirect_uri": "https://example.com/cb", - "scopes": [], - } - # Store concurrently - tasks = [store.store_code(code, data) for code in codes] - await asyncio.gather(*tasks) - assert store.get_store_size() == 10 - # Retrieve and delete concurrently - tasks = [store.get_code(code) for code in codes] - results = await asyncio.gather(*tasks) - assert all(r is not None for r in results) - tasks = [store.delete_code(code) for code in codes] - await asyncio.gather(*tasks) - assert store.get_store_size() == 0 - - -@pytest.mark.asyncio -async def test_singleton_global_instance(): - """The global instance authorization_code_store is a singleton.""" - from app.modules.oauth2.auth_code_store import authorization_code_store - # Import again to ensure it's the same object - from app.modules.oauth2.auth_code_store import authorization_code_store as same_instance - assert authorization_code_store is same_instance - - -if __name__ == "__main__": - # Simple standalone test (can be run with python -m pytest) - pytest.main([__file__, "-v"]) \ No newline at end of file diff --git a/tests/test_endpoint_repository.py b/tests/test_endpoint_repository.py deleted file mode 100644 index 84477f4..0000000 --- a/tests/test_endpoint_repository.py +++ /dev/null @@ -1,12 +0,0 @@ -""" -Unit tests for EndpointRepository. -""" -import pytest - -# TODO: Implement tests -# from app.modules.endpoints.repositories.endpoint_repository import EndpointRepository - - -def test_placeholder(): - """Placeholder test to ensure test suite runs.""" - assert True \ No newline at end of file diff --git a/tests/test_oauth2_controller.py b/tests/test_oauth2_controller.py deleted file mode 100644 index fffbc23..0000000 --- a/tests/test_oauth2_controller.py +++ /dev/null @@ -1,337 +0,0 @@ -""" -Unit tests for OAuth2 controller endpoints. -""" -import pytest -from unittest.mock import AsyncMock, MagicMock, patch -from fastapi.testclient import TestClient -from fastapi import FastAPI, status -from sqlalchemy.ext.asyncio import AsyncSession - -from app.modules.oauth2.controller import router as oauth_router - - -def create_test_app(override_dependency=None) -> FastAPI: - """Create a FastAPI app with OAuth router and optional dependency overrides.""" - app = FastAPI() - app.include_router(oauth_router) - if override_dependency: - app.dependency_overrides.update(override_dependency) - return app - - -@pytest.fixture -def mock_db_session(): - """Mock database session.""" - session = AsyncMock(spec=AsyncSession) - return session - - -@pytest.fixture -def client(mock_db_session): - """Test client with mocked database session.""" - from app.core.database import get_db - def override_get_db(): - yield mock_db_session - app = create_test_app({get_db: override_get_db}) - return TestClient(app) - - -# ---------- Authorization Endpoint Tests ---------- -def test_authorize_missing_parameters(client): - """GET /oauth/authorize without required parameters should return error.""" - response = client.get("/oauth/authorize") - assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY - # FastAPI returns validation error details - - -def test_authorize_unsupported_response_type(client): - """Only 'code' response_type is supported.""" - response = client.get( - "/oauth/authorize", - params={ - "response_type": "token", # unsupported - "client_id": "test_client", - "redirect_uri": "https://example.com/callback", - } - ) - assert response.status_code == status.HTTP_400_BAD_REQUEST - data = response.json() - assert "detail" in data - assert data["detail"]["error"] == "unsupported_response_type" - - -def test_authorize_success(client, mock_db_session): - """Successful authorization returns redirect with code.""" - # Mock OAuthService.authorize_code_flow - with patch('app.modules.oauth2.controller.OAuthService') as MockOAuthService: - mock_service = AsyncMock() - mock_service.authorize_code_flow.return_value = { - "code": "auth_code_123", - "state": "xyz", - } - MockOAuthService.return_value = mock_service - response = client.get( - "/oauth/authorize", - params={ - "response_type": "code", - "client_id": "test_client", - "redirect_uri": "https://example.com/callback", - "scope": "read write", - "state": "xyz", - }, - follow_redirects=False - ) - assert response.status_code == status.HTTP_302_FOUND - assert "location" in response.headers - location = response.headers["location"] - assert location.startswith("https://example.com/callback?") - assert "code=auth_code_123" in location - assert "state=xyz" in location - # Verify service was called with correct parameters - mock_service.authorize_code_flow.assert_called_once_with( - client_id="test_client", - redirect_uri="https://example.com/callback", - scope=["read", "write"], - state="xyz", - user_id=1, # placeholder - ) - - -# ---------- Token Endpoint Tests ---------- -def test_token_missing_grant_type(client): - """POST /oauth/token without grant_type should error (client auth required).""" - response = client.post("/oauth/token", data={}) - # Client authentication missing -> 401 unauthorized - assert response.status_code == status.HTTP_401_UNAUTHORIZED - - -def test_token_unsupported_grant_type(client): - """Unsupported grant_type returns error.""" - response = client.post( - "/oauth/token", - data={"grant_type": "password"}, # not supported - auth=("test_client", "secret") - ) - assert response.status_code == status.HTTP_400_BAD_REQUEST - data = response.json() - assert "detail" in data - assert data["detail"]["error"] == "unsupported_grant_type" - - -def test_token_authorization_code_missing_code(client): - """authorization_code grant requires code.""" - response = client.post( - "/oauth/token", - data={ - "grant_type": "authorization_code", - "client_id": "test_client", - "client_secret": "secret", - "redirect_uri": "https://example.com/callback", - } - ) - assert response.status_code == status.HTTP_400_BAD_REQUEST - data = response.json() - assert "detail" in data - assert data["detail"]["error"] == "invalid_request" - - -def test_token_authorization_code_success(client, mock_db_session): - """Successful authorization_code exchange returns tokens.""" - with patch('app.modules.oauth2.controller.OAuthService') as MockOAuthService: - mock_service = AsyncMock() - mock_service.exchange_code_for_tokens.return_value = { - "access_token": "access_token_123", - "token_type": "Bearer", - "expires_in": 1800, - "refresh_token": "refresh_token_456", - "scope": "read write", - } - MockOAuthService.return_value = mock_service - - response = client.post( - "/oauth/token", - data={ - "grant_type": "authorization_code", - "code": "auth_code_xyz", - "redirect_uri": "https://example.com/callback", - "client_id": "test_client", - "client_secret": "secret", - } - ) - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert data["access_token"] == "access_token_123" - assert data["token_type"] == "Bearer" - assert data["refresh_token"] == "refresh_token_456" - mock_service.exchange_code_for_tokens.assert_called_once_with( - code="auth_code_xyz", - client_id="test_client", - redirect_uri="https://example.com/callback", - ) - - -def test_token_client_credentials_success(client, mock_db_session): - """Client credentials grant returns access token.""" - with patch('app.modules.oauth2.controller.OAuthService') as MockOAuthService: - mock_service = AsyncMock() - mock_service.client_credentials_flow.return_value = { - "access_token": "client_token", - "token_type": "Bearer", - "expires_in": 1800, - "scope": "read", - } - MockOAuthService.return_value = mock_service - - response = client.post( - "/oauth/token", - data={ - "grant_type": "client_credentials", - "client_id": "test_client", - "client_secret": "secret", - "scope": "read", - } - ) - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert data["access_token"] == "client_token" - mock_service.client_credentials_flow.assert_called_once_with( - client_id="test_client", - client_secret="secret", - scope=["read"], - ) - - -def test_token_refresh_token_success(client, mock_db_session): - """Refresh token grant returns new tokens.""" - with patch('app.modules.oauth2.controller.OAuthService') as MockOAuthService: - mock_service = AsyncMock() - mock_service.refresh_token_flow.return_value = { - "access_token": "new_access_token", - "token_type": "Bearer", - "expires_in": 1800, - "refresh_token": "new_refresh_token", - "scope": "read", - } - MockOAuthService.return_value = mock_service - - response = client.post( - "/oauth/token", - data={ - "grant_type": "refresh_token", - "refresh_token": "old_refresh_token", - "client_id": "test_client", - "client_secret": "secret", - } - ) - assert response.status_code == status.HTTP_200_OK - mock_service.refresh_token_flow.assert_called_once_with( - refresh_token="old_refresh_token", - client_id="test_client", - client_secret="secret", - scope=[], - ) - - -# ---------- UserInfo Endpoint Tests ---------- -def test_userinfo_missing_token(client): - """UserInfo requires Bearer token.""" - response = client.get("/oauth/userinfo") - assert response.status_code == status.HTTP_401_UNAUTHORIZED - - -def test_userinfo_with_valid_token(client, mock_db_session): - """UserInfo returns claims from token payload.""" - # Mock get_current_token_payload dependency - from app.modules.oauth2.dependencies import get_current_token_payload - async def mock_payload(): - return {"sub": "user1", "client_id": "client1", "scopes": ["profile"]} - - app = create_test_app({get_current_token_payload: mock_payload}) - client_with_auth = TestClient(app) - - response = client_with_auth.get("/oauth/userinfo") - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert data["sub"] == "user1" - assert data["client_id"] == "client1" - assert "scope" in data - - -# ---------- Introspection Endpoint Tests ---------- -def test_introspect_missing_authentication(client): - """Introspection requires client credentials.""" - response = client.post("/oauth/introspect", data={"token": "some_token"}) - assert response.status_code == status.HTTP_401_UNAUTHORIZED - - -def test_introspect_success(client, mock_db_session): - """Introspection returns active token metadata.""" - # Mock ClientService.validate_client and TokenService.verify_token - with patch('app.modules.oauth2.controller.ClientService') as MockClientService, \ - patch('app.modules.oauth2.controller.TokenService') as MockTokenService: - mock_client_service = AsyncMock() - mock_client_service.validate_client.return_value = True - MockClientService.return_value = mock_client_service - - mock_token_service = AsyncMock() - mock_token_service.verify_token.return_value = { - "sub": "user1", - "client_id": "client1", - "scopes": ["read"], - "token_type": "Bearer", - "exp": 1234567890, - "iat": 1234567800, - "jti": "jti_123", - } - MockTokenService.return_value = mock_token_service - - response = client.post( - "/oauth/introspect", - data={"token": "valid_token"}, - auth=("test_client", "secret"), - ) - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert data["active"] is True - assert data["sub"] == "user1" - assert data["client_id"] == "client1" - - -# ---------- Revocation Endpoint Tests ---------- -def test_revoke_missing_authentication(client): - """Revocation requires client credentials.""" - response = client.post("/oauth/revoke", data={"token": "some_token"}) - assert response.status_code == status.HTTP_401_UNAUTHORIZED - - -def test_revoke_success(client, mock_db_session): - """Successful revocation returns 200.""" - with patch('app.modules.oauth2.controller.ClientService') as MockClientService, \ - patch('app.modules.oauth2.controller.TokenService') as MockTokenService: - mock_client_service = AsyncMock() - mock_client_service.validate_client.return_value = True - MockClientService.return_value = mock_client_service - - mock_token_service = AsyncMock() - mock_token_service.revoke_token.return_value = True - MockTokenService.return_value = mock_token_service - - response = client.post( - "/oauth/revoke", - data={"token": "token_to_revoke"}, - auth=("test_client", "secret"), - ) - assert response.status_code == status.HTTP_200_OK - assert response.content == b"" - - -# ---------- OpenID Configuration Endpoint ---------- -def test_openid_configuration(client): - """Discovery endpoint returns provider metadata.""" - response = client.get("/oauth/.well-known/openid-configuration") - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert "issuer" in data - assert "authorization_endpoint" in data - assert "token_endpoint" in data - assert "userinfo_endpoint" in data \ No newline at end of file diff --git a/tests/test_route_manager_fix.py b/tests/test_route_manager_fix.py deleted file mode 100644 index b2b5722..0000000 --- a/tests/test_route_manager_fix.py +++ /dev/null @@ -1,45 +0,0 @@ -""" -Test that route_manager is attached to app.state before first request. -""" -import pytest -from fastapi.testclient import TestClient -from app.core.app import create_app - -def test_route_manager_attached(): - """Ensure route_manager is attached after app creation.""" - app = create_app() - assert hasattr(app.state, 'route_manager') - assert hasattr(app.state, 'session_factory') - assert app.state.route_manager is not None - # Ensure route_manager has app reference - assert app.state.route_manager.app is app - -def test_admin_dashboard_with_route_manager(): - """Test that admin dashboard can access route_manager dependency.""" - app = create_app() - client = TestClient(app) - # Login first - resp = client.post("/admin/login", data={"username": "admin", "password": "admin123"}) - assert resp.status_code in (200, 302, 307) - # Request dashboard with trailing slash (correct route) - resp = client.get("/admin/", follow_redirects=True) - # Should return 200, not 500 AttributeError - assert resp.status_code == 200 - # Ensure route_manager stats are present (optional) - # The dashboard template includes stats; we can check for some text - assert "Dashboard" in resp.text - -def test_route_manager_dependency(): - """Test get_route_manager dependency returns the attached route_manager.""" - from app.modules.admin.controller import get_route_manager - from fastapi import Request - from unittest.mock import Mock - # Create mock request with app.state.route_manager - app = create_app() - request = Mock(spec=Request) - request.app = app - route_manager = get_route_manager(request) - assert route_manager is app.state.route_manager - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) \ No newline at end of file diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..6fa9c5c --- /dev/null +++ b/wsgi.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +""" +WSGI entry point for production servers (Waitress, Gunicorn, etc.). +""" +import asyncio +import logging +from a2wsgi import ASGIMiddleware +from app.core.app import create_app + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Create the FastAPI application instance +app = create_app() + +def create_wsgi_app(): + """ + Create a WSGI application with route refresh on startup. + + This function is intended for production WSGI servers (e.g., Waitress). + Since WSGI does not support ASGI lifespan events, we manually refresh + routes from the database once when the WSGI app is created. + """ + loop = None + try: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + route_manager = app.state.route_manager + logger.info("Refreshing routes from database (WSGI startup)...") + loop.run_until_complete(route_manager.refresh_routes()) + logger.info(f"Registered {len(route_manager.registered_routes)} routes") + except Exception as e: + logger.warning(f"Failed to refresh routes on startup: {e}") + # Continue anyway; routes can be refreshed later via admin interface + finally: + if loop is not None: + loop.close() + + # Wrap FastAPI ASGI app with WSGI adapter + wsgi_app = ASGIMiddleware(app) + return wsgi_app + +# Create WSGI application +wsgi_app = create_wsgi_app() + +if __name__ == "__main__": + # For testing WSGI app directly + from wsgiref.simple_server import make_server + print("Starting WSGI server on http://localhost:8000") + server = make_server('localhost', 8000, wsgi_app) + server.serve_forever() \ No newline at end of file