chore: auto-commit 2026-03-16 14:51

This commit is contained in:
cclohmar 2026-03-16 14:51:51 +00:00
parent 9a586d0955
commit f1991c3983
23 changed files with 229 additions and 1875 deletions

View file

@ -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

View file

@ -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
├── 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/
│ ├── 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)
│ └── 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

View file

@ -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()

View file

@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Mock API Admin{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css">
<style>
.sidebar {
min-height: calc(100vh - 56px);
background-color: #f8f9fa;
}
.content-header {
border-bottom: 1px solid #dee2e6;
padding-bottom: 0.5rem;
margin-bottom: 1rem;
}
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid">
<a class="navbar-brand" href="/admin">
<i class="bi bi-gear"></i> Mock API Admin
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link {% if request.url.path == '/admin' %}active{% endif %}" href="/admin">
<i class="bi bi-speedometer2"></i> Dashboard
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.url.path == '/admin/endpoints' %}active{% endif %}" href="/admin/endpoints">
<i class="bi bi-list-ul"></i> Endpoints
</a>
</li>
</ul>
<ul class="navbar-nav">
<li class="nav-item">
<span class="navbar-text text-light me-3">
<i class="bi bi-person-circle"></i> {{ session.get('username', 'Guest') }}
</span>
</li>
<li class="nav-item">
<a class="nav-link" href="/admin/logout">
<i class="bi bi-box-arrow-right"></i> Logout
</a>
</li>
</ul>
</div>
</div>
</nav>
<div class="container-fluid">
<div class="row">
<main class="col-md-12">
<div class="container mt-4">
{% block content %}{% endblock %}
</div>
</main>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz" crossorigin="anonymous"></script>
{% block extra_scripts %}{% endblock %}
</body>
</html>

View file

@ -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)

View file

@ -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"

42
main.py
View file

@ -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 autoreload
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,

20
run_dev.py Normal file
View file

@ -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"
)

View file

@ -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()

View file

@ -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()

View file

@ -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."

View file

@ -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())

54
test_run.py Normal file
View file

@ -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)

View file

View file

@ -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

View file

@ -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"])

View file

@ -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

View file

@ -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 nonexistent 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"])

View file

@ -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

View file

@ -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

View file

@ -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"])

51
wsgi.py Normal file
View file

@ -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()