Add deployment Docker Compose file, testing utilities, security helpers, and database initialization script
- Introduced `docker-compose.deploy.yml` for deployment scenarios with pre-built Docker images. - Added `auth_test_utils.py` to simplify authentication testing in FastAPI. - Implemented `security.py` for token-based operations like file uploads and password resets. - Created `init_db.py` for database initialization and superuser creation during startup. - Updated dependencies and tests to support optional authentication in FastAPI. - Enhanced entrypoint script to handle database initialization.
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi import Depends, HTTPException, status, Header
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from fastapi.security.utils import get_authorization_scheme_param
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.auth import get_token_data, TokenExpiredError, TokenInvalidError
|
||||
@@ -109,9 +110,32 @@ def get_current_superuser(
|
||||
return current_user
|
||||
|
||||
|
||||
async def get_optional_token(authorization: str = Header(None)) -> Optional[str]:
|
||||
"""
|
||||
Get the token from the Authorization header without requiring it.
|
||||
|
||||
This is a custom dependency that doesn't raise an exception when no token is provided,
|
||||
unlike the standard OAuth2PasswordBearer.
|
||||
|
||||
Args:
|
||||
authorization: Authorization header value
|
||||
|
||||
Returns:
|
||||
The token string if valid Bearer token is provided, None otherwise
|
||||
"""
|
||||
if not authorization:
|
||||
return None
|
||||
|
||||
scheme, token = get_authorization_scheme_param(authorization)
|
||||
if scheme.lower() != "bearer":
|
||||
return None
|
||||
|
||||
return token
|
||||
|
||||
|
||||
def get_optional_current_user(
|
||||
db: Session = Depends(get_db),
|
||||
token: Optional[str] = Depends(oauth2_scheme)
|
||||
token: Optional[str] = Depends(get_optional_token)
|
||||
) -> Optional[User]:
|
||||
"""
|
||||
Get the current user if authenticated, otherwise return None.
|
||||
|
||||
76
backend/app/init_db.py
Normal file
76
backend/app/init_db.py
Normal file
@@ -0,0 +1,76 @@
|
||||
# app/init_db.py
|
||||
import logging
|
||||
from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from app.core.config import settings
|
||||
from app.crud.user import user as user_crud
|
||||
from app.schemas.users import UserCreate
|
||||
from app.core.database import engine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def init_db(db: Session) -> Optional[UserCreate]:
|
||||
"""
|
||||
Initialize database with first superuser if settings are configured and user doesn't exist.
|
||||
|
||||
Returns:
|
||||
The created or existing superuser, or None if creation fails
|
||||
"""
|
||||
# Use default values if not set in environment variables
|
||||
superuser_email = settings.FIRST_SUPERUSER_EMAIL or "admin@example.com"
|
||||
superuser_password = settings.FIRST_SUPERUSER_PASSWORD or "admin123"
|
||||
|
||||
if not settings.FIRST_SUPERUSER_EMAIL or not settings.FIRST_SUPERUSER_PASSWORD:
|
||||
logger.warning(
|
||||
"First superuser credentials not configured in settings. "
|
||||
f"Using defaults: {superuser_email}"
|
||||
)
|
||||
|
||||
try:
|
||||
# Check if superuser already exists
|
||||
existing_user = user_crud.get_by_email(db, email=superuser_email)
|
||||
|
||||
if existing_user:
|
||||
logger.info(f"Superuser already exists: {existing_user.email}")
|
||||
return existing_user
|
||||
|
||||
# Create superuser if doesn't exist
|
||||
user_in = UserCreate(
|
||||
email=superuser_email,
|
||||
password=superuser_password,
|
||||
first_name="Admin",
|
||||
last_name="User",
|
||||
is_superuser=True
|
||||
)
|
||||
|
||||
user = user_crud.create(db, obj_in=user_in)
|
||||
logger.info(f"Created first superuser: {user.email}")
|
||||
|
||||
return user
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error initializing database: {e}")
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Configure logging to show info logs
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
|
||||
with Session(engine) as session:
|
||||
try:
|
||||
user = init_db(session)
|
||||
if user:
|
||||
print(f"✓ Database initialized successfully")
|
||||
print(f"✓ Superuser: {user.email}")
|
||||
else:
|
||||
print("✗ Failed to initialize database")
|
||||
except Exception as e:
|
||||
print(f"✗ Error initializing database: {e}")
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
129
backend/app/utils/auth_test_utils.py
Normal file
129
backend/app/utils/auth_test_utils.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Authentication utilities for testing.
|
||||
This module provides tools to bypass FastAPI's authentication in tests.
|
||||
"""
|
||||
from typing import Callable, Dict, Optional
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from app.api.dependencies.auth import get_current_user, get_optional_current_user
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
def create_test_auth_client(
|
||||
app: FastAPI,
|
||||
test_user: User,
|
||||
extra_overrides: Optional[Dict[Callable, Callable]] = None
|
||||
) -> TestClient:
|
||||
"""
|
||||
Create a test client with authentication pre-configured.
|
||||
|
||||
This bypasses the OAuth2 token validation and directly returns the test user.
|
||||
|
||||
Args:
|
||||
app: The FastAPI app to test
|
||||
test_user: The user object to use for authentication
|
||||
extra_overrides: Additional dependency overrides to apply
|
||||
|
||||
Returns:
|
||||
TestClient with authentication configured
|
||||
"""
|
||||
# First override the oauth2_scheme dependency to return a dummy token
|
||||
# This prevents FastAPI from trying to extract a real bearer token from the request
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||
app.dependency_overrides[oauth2_scheme] = lambda: "dummy_token_for_testing"
|
||||
|
||||
# Then override the get_current_user dependency to return our test user
|
||||
app.dependency_overrides[get_current_user] = lambda: test_user
|
||||
|
||||
# Apply any extra overrides
|
||||
if extra_overrides:
|
||||
for dep, override in extra_overrides.items():
|
||||
app.dependency_overrides[dep] = override
|
||||
|
||||
# Create and return the client
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def create_test_optional_auth_client(
|
||||
app: FastAPI,
|
||||
test_user: User
|
||||
) -> TestClient:
|
||||
"""
|
||||
Create a test client with optional authentication pre-configured.
|
||||
|
||||
This is useful for testing endpoints that use get_optional_current_user.
|
||||
|
||||
Args:
|
||||
app: The FastAPI app to test
|
||||
test_user: The user object to use for authentication
|
||||
|
||||
Returns:
|
||||
TestClient with optional authentication configured
|
||||
"""
|
||||
# Override the get_optional_current_user dependency
|
||||
app.dependency_overrides[get_optional_current_user] = lambda: test_user
|
||||
|
||||
# Create and return the client
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def create_test_superuser_client(
|
||||
app: FastAPI,
|
||||
test_user: User
|
||||
) -> TestClient:
|
||||
"""
|
||||
Create a test client with superuser authentication pre-configured.
|
||||
|
||||
Args:
|
||||
app: The FastAPI app to test
|
||||
test_user: The user object to use as superuser
|
||||
|
||||
Returns:
|
||||
TestClient with superuser authentication
|
||||
"""
|
||||
# Make sure user is a superuser
|
||||
test_user.is_superuser = True
|
||||
|
||||
# Use the auth client creation with superuser
|
||||
return create_test_auth_client(app, test_user)
|
||||
|
||||
|
||||
def create_test_unauthenticated_client(app: FastAPI) -> TestClient:
|
||||
"""
|
||||
Create a test client that will fail authentication checks.
|
||||
|
||||
This is useful for testing the unauthorized case of protected endpoints.
|
||||
|
||||
Args:
|
||||
app: The FastAPI app to test
|
||||
|
||||
Returns:
|
||||
TestClient without authentication
|
||||
"""
|
||||
# Any authentication attempts will fail
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def cleanup_test_client_auth(app: FastAPI) -> None:
|
||||
"""
|
||||
Clean up authentication overrides from the FastAPI app.
|
||||
|
||||
Call this after your tests to restore normal authentication behavior.
|
||||
|
||||
Args:
|
||||
app: The FastAPI app to clean up
|
||||
"""
|
||||
# Get all auth dependencies
|
||||
auth_deps = [
|
||||
get_current_user,
|
||||
get_optional_current_user,
|
||||
OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||
]
|
||||
|
||||
# Remove overrides
|
||||
for dep in auth_deps:
|
||||
if dep in app.dependency_overrides:
|
||||
del app.dependency_overrides[dep]
|
||||
110
backend/app/utils/security.py
Normal file
110
backend/app/utils/security.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
Security utilities for token-based operations.
|
||||
|
||||
This module provides utilities for creating and verifying signed tokens,
|
||||
useful for operations like file uploads, password resets, or any other
|
||||
time-limited, single-use operations.
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def create_upload_token(file_path: str, content_type: str, expires_in: int = 300) -> str:
|
||||
"""
|
||||
Create a signed token for secure file uploads.
|
||||
|
||||
This generates a time-limited, single-use token that can be verified
|
||||
to ensure the upload is authorized.
|
||||
|
||||
Args:
|
||||
file_path: The destination path for the file
|
||||
content_type: The expected content type (e.g., "image/jpeg")
|
||||
expires_in: Expiration time in seconds (default: 300 = 5 minutes)
|
||||
|
||||
Returns:
|
||||
A base64 encoded token string
|
||||
|
||||
Example:
|
||||
>>> token = create_upload_token("/uploads/avatar.jpg", "image/jpeg")
|
||||
>>> # Send token to client, client includes it in upload request
|
||||
"""
|
||||
# Create the payload
|
||||
payload = {
|
||||
"path": file_path,
|
||||
"content_type": content_type,
|
||||
"exp": int(time.time()) + expires_in,
|
||||
"nonce": secrets.token_hex(8) # Add randomness to prevent token reuse
|
||||
}
|
||||
|
||||
# Convert to JSON and encode
|
||||
payload_bytes = json.dumps(payload).encode('utf-8')
|
||||
|
||||
# Create a signature using the secret key
|
||||
signature = hashlib.sha256(
|
||||
payload_bytes + settings.SECRET_KEY.encode('utf-8')
|
||||
).hexdigest()
|
||||
|
||||
# Combine payload and signature
|
||||
token_data = {
|
||||
"payload": payload,
|
||||
"signature": signature
|
||||
}
|
||||
|
||||
# Encode the final token
|
||||
token_json = json.dumps(token_data)
|
||||
token = base64.urlsafe_b64encode(token_json.encode('utf-8')).decode('utf-8')
|
||||
|
||||
return token
|
||||
|
||||
|
||||
def verify_upload_token(token: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Verify an upload token and return the payload if valid.
|
||||
|
||||
Args:
|
||||
token: The token string to verify
|
||||
|
||||
Returns:
|
||||
The payload dictionary if valid, None if invalid or expired
|
||||
|
||||
Example:
|
||||
>>> payload = verify_upload_token(token_from_client)
|
||||
>>> if payload:
|
||||
... file_path = payload["path"]
|
||||
... content_type = payload["content_type"]
|
||||
... # Proceed with upload
|
||||
... else:
|
||||
... # Token invalid or expired
|
||||
"""
|
||||
try:
|
||||
# Decode the token
|
||||
token_json = base64.urlsafe_b64decode(token.encode('utf-8')).decode('utf-8')
|
||||
token_data = json.loads(token_json)
|
||||
|
||||
# Extract payload and signature
|
||||
payload = token_data["payload"]
|
||||
signature = token_data["signature"]
|
||||
|
||||
# Verify signature
|
||||
payload_bytes = json.dumps(payload).encode('utf-8')
|
||||
expected_signature = hashlib.sha256(
|
||||
payload_bytes + settings.SECRET_KEY.encode('utf-8')
|
||||
).hexdigest()
|
||||
|
||||
if signature != expected_signature:
|
||||
return None
|
||||
|
||||
# Check expiration
|
||||
if payload["exp"] < int(time.time()):
|
||||
return None
|
||||
|
||||
return payload
|
||||
|
||||
except (ValueError, KeyError, json.JSONDecodeError):
|
||||
return None
|
||||
@@ -4,5 +4,9 @@ echo "Starting Backend"
|
||||
|
||||
# Apply database migrations
|
||||
alembic upgrade head
|
||||
|
||||
# Initialize database (creates first superuser if needed)
|
||||
python app/init_db.py
|
||||
|
||||
# Execute the command passed to docker run
|
||||
exec "$@"
|
||||
202
backend/migrate.py
Executable file
202
backend/migrate.py
Executable file
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Database migration helper script.
|
||||
Provides convenient commands for generating and applying Alembic migrations.
|
||||
"""
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure the project root is in the Python path
|
||||
project_root = Path(__file__).resolve().parent
|
||||
if str(project_root) not in sys.path:
|
||||
sys.path.append(str(project_root))
|
||||
|
||||
try:
|
||||
# Import settings to check if configuration is working
|
||||
from app.core.config import settings
|
||||
|
||||
print(f"Using database URL: {settings.database_url}")
|
||||
except ImportError as e:
|
||||
print(f"Error importing settings: {e}")
|
||||
print("Make sure your Python path includes the project root.")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def check_models():
|
||||
"""Check if all models are properly imported"""
|
||||
print("Checking model imports...")
|
||||
|
||||
try:
|
||||
# Import all models through the models package
|
||||
from app.models import __all__ as all_models
|
||||
print(f"Found {len(all_models)} model(s):")
|
||||
for model in all_models:
|
||||
print(f" - {model}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error checking models: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def generate_migration(message):
|
||||
"""Generate an Alembic migration with the given message"""
|
||||
print(f"Generating migration: {message}")
|
||||
|
||||
cmd = ["alembic", "revision", "--autogenerate", "-m", message]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
print(result.stdout)
|
||||
if result.returncode != 0:
|
||||
print("Error generating migration:", file=sys.stderr)
|
||||
print(result.stderr, file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Extract revision ID if possible
|
||||
revision = None
|
||||
for line in result.stdout.split("\n"):
|
||||
if "Generating" in line and "..." in line:
|
||||
try:
|
||||
# Look for the revision ID, which is typically 12 hex characters
|
||||
parts = line.split()
|
||||
for part in parts:
|
||||
if len(part) >= 12 and all(c in "0123456789abcdef" for c in part[:12]):
|
||||
revision = part[:12]
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if revision:
|
||||
print(f"Generated revision: {revision}")
|
||||
else:
|
||||
print("Generated migration (revision ID not identified)")
|
||||
|
||||
return revision or True
|
||||
|
||||
|
||||
def apply_migration(revision=None):
|
||||
"""Apply migrations up to the specified revision or head"""
|
||||
target = revision or "head"
|
||||
print(f"Applying migration(s) to: {target}")
|
||||
|
||||
cmd = ["alembic", "upgrade", target]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
print(result.stdout)
|
||||
if result.returncode != 0:
|
||||
print("Error applying migration:", file=sys.stderr)
|
||||
print(result.stderr, file=sys.stderr)
|
||||
return False
|
||||
|
||||
print("Migration(s) applied successfully")
|
||||
return True
|
||||
|
||||
|
||||
def show_current():
|
||||
"""Show current revision"""
|
||||
print("Current database revision:")
|
||||
|
||||
cmd = ["alembic", "current"]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
print(result.stdout)
|
||||
if result.returncode != 0:
|
||||
print("Error getting current revision:", file=sys.stderr)
|
||||
print(result.stderr, file=sys.stderr)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def list_migrations():
|
||||
"""List all migrations and their status"""
|
||||
print("Listing migrations:")
|
||||
|
||||
cmd = ["alembic", "history", "--verbose"]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
print(result.stdout)
|
||||
if result.returncode != 0:
|
||||
print("Error listing migrations:", file=sys.stderr)
|
||||
print(result.stderr, file=sys.stderr)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def check_database_connection():
|
||||
"""Check if database is accessible"""
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
try:
|
||||
engine = create_engine(settings.database_url)
|
||||
with engine.connect() as conn:
|
||||
print("✓ Database connection successful!")
|
||||
return True
|
||||
except SQLAlchemyError as e:
|
||||
print(f"✗ Error connecting to database: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Database migration helper for FastNext template'
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest='command', help='Command to run')
|
||||
|
||||
# Generate command
|
||||
generate_parser = subparsers.add_parser('generate', help='Generate a migration')
|
||||
generate_parser.add_argument('message', help='Migration message')
|
||||
|
||||
# Apply command
|
||||
apply_parser = subparsers.add_parser('apply', help='Apply migrations')
|
||||
apply_parser.add_argument('--revision', help='Specific revision to apply to')
|
||||
|
||||
# List command
|
||||
subparsers.add_parser('list', help='List migrations')
|
||||
|
||||
# Current command
|
||||
subparsers.add_parser('current', help='Show current revision')
|
||||
|
||||
# Check command
|
||||
subparsers.add_parser('check', help='Check database connection and models')
|
||||
|
||||
# Auto command (generate and apply)
|
||||
auto_parser = subparsers.add_parser('auto', help='Generate and apply migration')
|
||||
auto_parser.add_argument('message', help='Migration message')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == 'generate':
|
||||
check_models()
|
||||
generate_migration(args.message)
|
||||
|
||||
elif args.command == 'apply':
|
||||
apply_migration(args.revision)
|
||||
|
||||
elif args.command == 'list':
|
||||
list_migrations()
|
||||
|
||||
elif args.command == 'current':
|
||||
show_current()
|
||||
|
||||
elif args.command == 'check':
|
||||
check_database_connection()
|
||||
check_models()
|
||||
|
||||
elif args.command == 'auto':
|
||||
check_models()
|
||||
revision = generate_migration(args.message)
|
||||
if revision:
|
||||
proceed = input("\nPress Enter to apply migration or Ctrl+C to abort... ")
|
||||
apply_migration()
|
||||
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1
backend/tests/api/dependencies/__init__.py
Normal file
1
backend/tests/api/dependencies/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# tests/api/dependencies/__init__.py
|
||||
213
backend/tests/api/dependencies/test_auth_dependencies.py
Normal file
213
backend/tests/api/dependencies/test_auth_dependencies.py
Normal file
@@ -0,0 +1,213 @@
|
||||
# tests/api/dependencies/test_auth_dependencies.py
|
||||
import pytest
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.api.dependencies.auth import (
|
||||
get_current_user,
|
||||
get_current_active_user,
|
||||
get_current_superuser,
|
||||
get_optional_current_user
|
||||
)
|
||||
from app.core.auth import TokenExpiredError, TokenInvalidError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_token():
|
||||
"""Fixture providing a mock JWT token"""
|
||||
return "mock.jwt.token"
|
||||
|
||||
|
||||
class TestGetCurrentUser:
|
||||
"""Tests for get_current_user dependency"""
|
||||
|
||||
def test_get_current_user_success(self, db_session, mock_user, mock_token):
|
||||
"""Test successfully getting the current user"""
|
||||
# Mock get_token_data to return user_id that matches our mock_user
|
||||
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
|
||||
mock_get_data.return_value.user_id = mock_user.id
|
||||
|
||||
# Call the dependency
|
||||
user = get_current_user(db=db_session, token=mock_token)
|
||||
|
||||
# Verify the correct user was returned
|
||||
assert user.id == mock_user.id
|
||||
assert user.email == mock_user.email
|
||||
|
||||
def test_get_current_user_nonexistent(self, db_session, mock_token):
|
||||
"""Test when the token contains a user ID that doesn't exist"""
|
||||
# Mock get_token_data to return a non-existent user ID
|
||||
nonexistent_id = uuid.UUID("11111111-1111-1111-1111-111111111111")
|
||||
|
||||
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
|
||||
mock_get_data.return_value.user_id = nonexistent_id
|
||||
|
||||
# Should raise HTTPException with 404 status
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
get_current_user(db=db_session, token=mock_token)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert "User not found" in exc_info.value.detail
|
||||
|
||||
def test_get_current_user_inactive(self, db_session, mock_user, mock_token):
|
||||
"""Test when the user is inactive"""
|
||||
# Make the user inactive
|
||||
mock_user.is_active = False
|
||||
db_session.commit()
|
||||
|
||||
# Mock get_token_data
|
||||
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
|
||||
mock_get_data.return_value.user_id = mock_user.id
|
||||
|
||||
# Should raise HTTPException with 403 status
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
get_current_user(db=db_session, token=mock_token)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "Inactive user" in exc_info.value.detail
|
||||
|
||||
def test_get_current_user_expired_token(self, db_session, mock_token):
|
||||
"""Test with an expired token"""
|
||||
# Mock get_token_data to raise TokenExpiredError
|
||||
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
|
||||
mock_get_data.side_effect = TokenExpiredError("Token expired")
|
||||
|
||||
# Should raise HTTPException with 401 status
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
get_current_user(db=db_session, token=mock_token)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert "Token expired" in exc_info.value.detail
|
||||
|
||||
def test_get_current_user_invalid_token(self, db_session, mock_token):
|
||||
"""Test with an invalid token"""
|
||||
# Mock get_token_data to raise TokenInvalidError
|
||||
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
|
||||
mock_get_data.side_effect = TokenInvalidError("Invalid token")
|
||||
|
||||
# Should raise HTTPException with 401 status
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
get_current_user(db=db_session, token=mock_token)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert "Could not validate credentials" in exc_info.value.detail
|
||||
|
||||
|
||||
class TestGetCurrentActiveUser:
|
||||
"""Tests for get_current_active_user dependency"""
|
||||
|
||||
def test_get_current_active_user(self, mock_user):
|
||||
"""Test getting an active user"""
|
||||
# Ensure user is active
|
||||
mock_user.is_active = True
|
||||
|
||||
# Call the dependency with mocked current_user
|
||||
user = get_current_active_user(current_user=mock_user)
|
||||
|
||||
# Should return the same user
|
||||
assert user == mock_user
|
||||
|
||||
def test_get_current_inactive_user(self, mock_user):
|
||||
"""Test getting an inactive user"""
|
||||
# Make user inactive
|
||||
mock_user.is_active = False
|
||||
|
||||
# Should raise HTTPException with 403 status
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
get_current_active_user(current_user=mock_user)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "Inactive user" in exc_info.value.detail
|
||||
|
||||
|
||||
class TestGetCurrentSuperuser:
|
||||
"""Tests for get_current_superuser dependency"""
|
||||
|
||||
def test_get_current_superuser(self, mock_user):
|
||||
"""Test getting a superuser"""
|
||||
# Make user a superuser
|
||||
mock_user.is_superuser = True
|
||||
|
||||
# Call the dependency with mocked current_user
|
||||
user = get_current_superuser(current_user=mock_user)
|
||||
|
||||
# Should return the same user
|
||||
assert user == mock_user
|
||||
|
||||
def test_get_current_non_superuser(self, mock_user):
|
||||
"""Test getting a non-superuser"""
|
||||
# Ensure user is not a superuser
|
||||
mock_user.is_superuser = False
|
||||
|
||||
# Should raise HTTPException with 403 status
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
get_current_superuser(current_user=mock_user)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "Not enough permissions" in exc_info.value.detail
|
||||
|
||||
|
||||
class TestGetOptionalCurrentUser:
|
||||
"""Tests for get_optional_current_user dependency"""
|
||||
|
||||
def test_get_optional_current_user_with_token(self, db_session, mock_user, mock_token):
|
||||
"""Test getting optional user with a valid token"""
|
||||
# Mock get_token_data
|
||||
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
|
||||
mock_get_data.return_value.user_id = mock_user.id
|
||||
|
||||
# Call the dependency
|
||||
user = get_optional_current_user(db=db_session, token=mock_token)
|
||||
|
||||
# Should return the correct user
|
||||
assert user is not None
|
||||
assert user.id == mock_user.id
|
||||
|
||||
def test_get_optional_current_user_no_token(self, db_session):
|
||||
"""Test getting optional user with no token"""
|
||||
# Call the dependency with no token
|
||||
user = get_optional_current_user(db=db_session, token=None)
|
||||
|
||||
# Should return None
|
||||
assert user is None
|
||||
|
||||
def test_get_optional_current_user_invalid_token(self, db_session, mock_token):
|
||||
"""Test getting optional user with an invalid token"""
|
||||
# Mock get_token_data to raise TokenInvalidError
|
||||
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
|
||||
mock_get_data.side_effect = TokenInvalidError("Invalid token")
|
||||
|
||||
# Call the dependency
|
||||
user = get_optional_current_user(db=db_session, token=mock_token)
|
||||
|
||||
# Should return None, not raise an exception
|
||||
assert user is None
|
||||
|
||||
def test_get_optional_current_user_expired_token(self, db_session, mock_token):
|
||||
"""Test getting optional user with an expired token"""
|
||||
# Mock get_token_data to raise TokenExpiredError
|
||||
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
|
||||
mock_get_data.side_effect = TokenExpiredError("Token expired")
|
||||
|
||||
# Call the dependency
|
||||
user = get_optional_current_user(db=db_session, token=mock_token)
|
||||
|
||||
# Should return None, not raise an exception
|
||||
assert user is None
|
||||
|
||||
def test_get_optional_current_user_inactive(self, db_session, mock_user, mock_token):
|
||||
"""Test getting optional user when user is inactive"""
|
||||
# Make the user inactive
|
||||
mock_user.is_active = False
|
||||
db_session.commit()
|
||||
|
||||
# Mock get_token_data
|
||||
with patch('app.api.dependencies.auth.get_token_data') as mock_get_data:
|
||||
mock_get_data.return_value.user_id = mock_user.id
|
||||
|
||||
# Call the dependency
|
||||
user = get_optional_current_user(db=db_session, token=mock_token)
|
||||
|
||||
# Should return None for inactive users
|
||||
assert user is None
|
||||
@@ -0,0 +1,98 @@
|
||||
# Docker Compose configuration for DEPLOYMENT with pre-built images
|
||||
#
|
||||
# IMPORTANT: This configuration is designed for deployment scenarios where you have
|
||||
# already built and pushed your Docker images to a container registry.
|
||||
#
|
||||
# Since this is a template project, you'll need to:
|
||||
# 1. Build your images: docker-compose build
|
||||
# 2. Tag them appropriately: docker tag <image> <your-registry>/<your-project>:<tag>
|
||||
# 3. Push to your registry: docker push <your-registry>/<your-project>:<tag>
|
||||
# 4. Update the image references below to point to your registry
|
||||
#
|
||||
# Example registry paths:
|
||||
# - Docker Hub: username/project-backend:latest
|
||||
# - GitHub Container Registry: ghcr.io/username/project-backend:latest
|
||||
# - GitLab Registry: registry.gitlab.com/username/project/backend:latest
|
||||
# - Private Registry: registry.example.com/project-backend:latest
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data/
|
||||
environment:
|
||||
- POSTGRES_USER=${POSTGRES_USER}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
- POSTGRES_DB=${POSTGRES_DB}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- app-network
|
||||
restart: unless-stopped
|
||||
|
||||
backend:
|
||||
# REPLACE THIS with your actual image from your container registry
|
||||
# Examples:
|
||||
# image: ghcr.io/your-username/your-project-backend:latest
|
||||
# image: your-registry.com/your-project/backend:v1.0.0
|
||||
# image: username/your-project-backend:latest
|
||||
image: YOUR_REGISTRY/YOUR_PROJECT_BACKEND:latest
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
- ENVIRONMENT=production
|
||||
- DEBUG=false
|
||||
- BACKEND_CORS_ORIGINS=${BACKEND_CORS_ORIGINS}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- app-network
|
||||
restart: unless-stopped
|
||||
# Uncomment if you need persistent data storage for uploads, etc.
|
||||
# volumes:
|
||||
# - ${HOST_DATA_FILES_DIR:-./data}:${DATA_FILES_DIR:-/app/data}
|
||||
|
||||
frontend:
|
||||
# REPLACE THIS with your actual image from your container registry
|
||||
# Examples:
|
||||
# image: ghcr.io/your-username/your-project-frontend:latest
|
||||
# image: your-registry.com/your-project/frontend:v1.0.0
|
||||
# image: username/your-project-frontend:latest
|
||||
image: YOUR_REGISTRY/YOUR_PROJECT_FRONTEND:latest
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- app-network
|
||||
restart: unless-stopped
|
||||
|
||||
# Optional: Add a reverse proxy like nginx or traefik here
|
||||
# nginx:
|
||||
# image: nginx:alpine
|
||||
# ports:
|
||||
# - "80:80"
|
||||
# - "443:443"
|
||||
# volumes:
|
||||
# - ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
# - ./ssl:/etc/nginx/ssl:ro
|
||||
# depends_on:
|
||||
# - frontend
|
||||
# - backend
|
||||
# networks:
|
||||
# - app-network
|
||||
# restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
networks:
|
||||
app-network:
|
||||
driver: bridge
|
||||
|
||||
Reference in New Issue
Block a user