diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3532227..6e85140 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -51,7 +51,6 @@ Developers extend `TemplateServer` to create application-specific servers (see ` ```powershell # Setup (first time) uv sync # Install dependencies -uv run generate-certificate # Create self-signed SSL certs (certs/ dir) uv run generate-new-token # Generate API key, save hash to .env # Development @@ -76,9 +75,9 @@ docker compose down # Stop and remove containers ### Docker Multi-Stage Build -- **Stage 1 (builder)**: Uses `uv` to build wheel, copies `configuration/` directory and other required files +- **Stage 1 (builder)**: Uses `uv` to build wheel, copies required files - **Stage 2 (runtime)**: Installs wheel, copies runtime files (.here, configs, LICENSE, README.md) from wheel to /app -- **Startup Script**: `/app/start.sh` generates token/certs if missing, starts server +- **Startup Script**: `/app/start.sh` generates token if missing, starts server - **Config Selection**: Uses `config.json` for all environments - **Build Args**: `PORT=443` (exposes port) - **Health Check**: Curls `/api/health` with unverified SSL context (no auth required) @@ -117,7 +116,6 @@ docker compose down # Stop and remove containers ### What's NOT Implemented Yet -- Custom domain-specific endpoints (template provides base functionality only) - Database/metadata storage (users implement as needed in subclasses) - CORS configuration (can be added by subclasses) - API key rotation/expiry @@ -125,7 +123,6 @@ docker compose down # Stop and remove containers ### Testing Requirements -- Mock `pyhere.here()` for all file path tests (see `conftest.py`) - Use fixtures for TemplateServer/ExampleServer instantiation - Test async endpoints with `@pytest.mark.asyncio` - Mock `uvicorn.run` when testing server `.run()` methods @@ -134,18 +131,24 @@ docker compose down # Stop and remove containers All PRs must pass: -**CI Workflow:** +**Build Workflow (build.yml):** + +1. `build_wheel` - Create and upload Python wheel package +2. `verify_structure` - Verify installed package structure and required files + +**CI Workflow (ci.yml):** 1. `validate-pyproject` - pyproject.toml schema validation 2. `ruff` - linting (120 char line length, strict rules in pyproject.toml) 3. `mypy` - 100% type coverage (strict mode) 4. `pytest` - 99% code coverage, HTML report uploaded -5. `version-check` - pyproject.toml vs uv.lock version consistency +5. `bandit` - security check for Python code +6. `pip-audit` - audit dependencies for known vulnerabilities +7. `version-check` - pyproject.toml vs uv.lock version consistency -**Docker Workflow:** +**Docker Workflow (docker.yml):** -1. `docker-development` - Build and test dev image with docker compose -2. `docker-production` - Build and test prod image with ENV=prod, PORT=443 +1. `build` - Build and test development image with docker compose ## Quick Reference diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0bba23b..fb28526 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -64,7 +64,6 @@ jobs: echo "Checking required directories in site-packages..." REQUIRED_DIRS=( "${SITE_PACKAGES}/${PACKAGE_NAME}" - "${SITE_PACKAGES}/configuration" ) for dir in "${REQUIRED_DIRS[@]}"; do @@ -77,7 +76,6 @@ jobs: # Check for required files in site-packages echo "Checking required files in site-packages..." REQUIRED_FILES=( - "${SITE_PACKAGES}/configuration/config.json" "${SITE_PACKAGES}/README.md" "${SITE_PACKAGES}/LICENSE" "${SITE_PACKAGES}/.here" diff --git a/Dockerfile b/Dockerfile index d150b29..ebace3b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,6 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ # Copy backend source files COPY python_template_server/ ./python_template_server/ -COPY configuration/ ./configuration/ COPY pyproject.toml .here LICENSE README.md ./ # Build the wheel @@ -26,21 +25,23 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ # Copy the built wheel from backend builder COPY --from=backend-builder /build/dist/*.whl /tmp/ +# Copy configuration +COPY configuration /app/configuration/ + # Install the wheel RUN uv pip install --system --no-cache /tmp/*.whl && \ rm /tmp/*.whl # Create required directories -RUN mkdir -p /app/logs /app/certs +RUN mkdir -p /app/logs # Copy included files from installed wheel to app directory RUN SITE_PACKAGES_DIR=$(find /usr/local/lib -name "site-packages" -type d | head -1) && \ - cp -r "${SITE_PACKAGES_DIR}/configuration" /app/ && \ cp "${SITE_PACKAGES_DIR}/.here" /app/.here && \ cp "${SITE_PACKAGES_DIR}/LICENSE" /app/LICENSE && \ cp "${SITE_PACKAGES_DIR}/README.md" /app/README.md -# Create startup script with Ollama model checking +# Create startup script RUN echo '#!/bin/sh\n\ set -e\n\ \n\ @@ -51,20 +52,14 @@ RUN echo '#!/bin/sh\n\ export $(grep -v "^#" .env | xargs)\n\ fi\n\ \n\ - # Generate certificates if needed\n\ - if [ ! -f certs/cert.pem ] || [ ! -f certs/key.pem ]; then\n\ - echo "Generating self-signed certificates..."\n\ - generate-certificate\n\ - fi\n\ - \n\ - exec python-template-server' > /app/start.sh && \ + exec python-template-server --port $PORT' > /app/start.sh && \ chmod +x /app/start.sh -# Expose HTTPS port -EXPOSE 443 +# Expose server port +EXPOSE $PORT # Health check HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD python -c "import urllib.request; urllib.request.urlopen('https://localhost:443/api/health', context=__import__('ssl')._create_unverified_context()).read()" || exit 1 + CMD python -c "import urllib.request; urllib.request.urlopen('https://localhost:'\"$PORT\"'/api/health', context=__import__('ssl')._create_unverified_context()).read()" || exit 1 CMD ["/app/start.sh"] diff --git a/README.md b/README.md index ef40848..1116323 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ This repository provides a solid foundation for building secure, observable Fast - [Quick Start](#quick-start) - [Prerequisites](#prerequisites) - [Installation](#installation) - - [Generate Certificates and API Token](#generate-certificates-and-api-token) + - [Generate API Token](#generate-api-token) - [Run the Server](#run-the-server) - [Using as a Template](#using-as-a-template) - [Docker Deployment](#docker-deployment) @@ -73,13 +73,9 @@ cd python-template-server uv sync --extra dev ``` -### Generate Certificates and API Token +### Generate API Token ```sh -# Generate self-signed SSL certificate (saves to certs/ directory) -uv run generate-certificate - -# Generate API authentication token (saves hash to .env) uv run generate-new-token # ⚠️ Save the displayed token - you'll need it for API requests! ``` diff --git a/docker-compose.yml b/docker-compose.yml index ebc7ddb..faea5f1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,10 +6,11 @@ services: dockerfile: Dockerfile container_name: python-template-server ports: - - "443:443" + - "${PORT:-443}:${PORT:-443}" environment: # Load API token hash from .env file (optional - will be generated if missing) - API_TOKEN_HASH=${API_TOKEN_HASH:-} + - PORT=${PORT:-443} volumes: - certs:/app/certs - logs:/app/logs @@ -20,7 +21,7 @@ services: "CMD", "sh", "-c", - 'python -c "import urllib.request; urllib.request.urlopen(''https://localhost:443/api/health'', context=__import__(''ssl'')._create_unverified_context()).read()"', + 'python -c "import urllib.request; urllib.request.urlopen(''https://localhost:$PORT/api/health'', context=__import__(''ssl'')._create_unverified_context()).read()"', ] interval: 30s timeout: 10s diff --git a/docs/DOCKER_DEPLOYMENT.md b/docs/DOCKER_DEPLOYMENT.md index c5d0eb4..5b4fd42 100644 --- a/docs/DOCKER_DEPLOYMENT.md +++ b/docs/DOCKER_DEPLOYMENT.md @@ -101,12 +101,15 @@ Configure the FastAPI server using environment variables in `docker-compose.yml` ```yaml environment: - API_TOKEN_HASH=${API_TOKEN_HASH} + - PORT=${PORT:-443} ``` The `API_TOKEN_HASH` is loaded from your local `.env` file. If the `.env` file exists when you run `docker compose up`, the container will use that token hash. Otherwise, the container startup script will generate a new token and create the `.env` file. +The `PORT` environment variable sets the server port (default 443). + ### Server Configuration Modify `config.json` to customize: diff --git a/docs/SMG.md b/docs/SMG.md index f00c73e..d3e88cf 100644 --- a/docs/SMG.md +++ b/docs/SMG.md @@ -9,9 +9,7 @@ This document outlines how to configure and setup a development environment to w - [Directory Structure](#directory-structure) - [Architecture Overview](#architecture-overview) - [Installing Dependencies](#installing-dependencies) - - [Setting Up Certificates and Authentication](#setting-up-certificates-and-authentication) - - [Generating SSL Certificates](#generating-ssl-certificates) - - [Generating API Authentication Tokens](#generating-api-authentication-tokens) + - [Setting Up Authentication](#setting-up-authentication) - [Running the Backend](#running-the-backend) - [Testing, Linting, and Type Checking](#testing-linting-and-type-checking) @@ -83,26 +81,9 @@ After installing dev dependencies, set up pre-commit hooks: uv run pre-commit install ``` -### Setting Up Certificates and Authentication +### Setting Up Authentication -Before running the server, you need to generate SSL certificates and an API authentication token. - -#### Generating SSL Certificates - -The server requires self-signed SSL certificates for HTTPS support: - -```sh -uv run generate-certificate -``` - -This command: -- Creates a self-signed certificate valid for 365 days -- Generates RSA-4096 key pairs -- Saves certificates to the `certs/` directory (`cert.pem` and `key.pem`) - -#### Generating API Authentication Tokens - -Generate a secure API token for authenticating requests: +Before running the server, you need to generate an API authentication token. ```sh uv run generate-new-token diff --git a/pyproject.toml b/pyproject.toml index f840383..d7fba89 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,6 @@ repository = "https://github.com/javidahmed64592/python-template-server" [project.scripts] python-template-server = "python_template_server.main:run" -generate-certificate = "python_template_server.certificate_handler:generate_self_signed_certificate" generate-new-token = "python_template_server.authentication_handler:generate_new_token" [tool.hatch.metadata] @@ -55,7 +54,6 @@ allow-direct-references = true [tool.hatch.build] include = [ "python_template_server/**", - "configuration/**", ".here", "LICENSE", "README.md", @@ -71,7 +69,7 @@ addopts = [ [tool.coverage.run] branch = true -source = ["python_template_server"] +source = ["python_template_server", "tests"] [tool.coverage.report] fail_under = 80 diff --git a/python_template_server/certificate_handler.py b/python_template_server/certificate_handler.py index d7ae648..544ec3c 100644 --- a/python_template_server/certificate_handler.py +++ b/python_template_server/certificate_handler.py @@ -1,6 +1,5 @@ """Generate self-signed SSL certificate for local development.""" -import argparse import ipaddress import logging import sys @@ -12,9 +11,7 @@ from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.x509.oid import NameOID -from python_template_server.constants import CONFIG_FILE_PATH from python_template_server.logging_setup import setup_logging -from python_template_server.main import ExampleServer from python_template_server.models import CertificateConfigModel setup_logging() @@ -132,38 +129,3 @@ def generate_self_signed_cert(self) -> None: except OSError: logger.exception("Failed to generate certificate files!") raise - - -def parse_args() -> argparse.Namespace: - """Parse command-line arguments for certificate generation. - - :return argparse.Namespace: Parsed arguments - """ - parser = argparse.ArgumentParser(description="Generate self-signed certificates for local development.") - parser.add_argument( - "--config", - type=str, - default=str(CONFIG_FILE_PATH), - help="Path to the configuration file (default: configuration/config.json)", - ) - return parser.parse_args() - - -def generate_self_signed_certificate() -> None: - """Generate self-signed certificates for local development. - - :raise SystemExit: If certificate generation fails - """ - args = parse_args() - config_filepath = Path(args.config) - - try: - server = ExampleServer(config_filepath=config_filepath) - handler = CertificateHandler(server.config.certificate) - handler.generate_self_signed_cert() - except (OSError, PermissionError): - logger.exception("Failed to generate certificates!") - sys.exit(1) - except Exception: - logger.exception("Unexpected error during certificate generation!") - sys.exit(1) diff --git a/python_template_server/constants.py b/python_template_server/constants.py index efaec85..4fb2bb8 100644 --- a/python_template_server/constants.py +++ b/python_template_server/constants.py @@ -31,6 +31,6 @@ # Logging constants LOG_MAX_BYTES = 10 * BYTES_TO_MB # 10 MB LOG_BACKUP_COUNT = 5 -LOG_FORMAT = "[%(asctime)s] (%(levelname)s) %(module)s: %(message)s" +LOG_FORMAT = "[%(asctime)s] %(levelname)s [%(module)s]: %(message)s" LOG_DATE_FORMAT = "%d/%m/%Y | %H:%M:%S" LOG_LEVEL = "INFO" diff --git a/python_template_server/middleware/request_logging_middleware.py b/python_template_server/middleware/request_logging_middleware.py index 8e9706a..cc0f0d0 100644 --- a/python_template_server/middleware/request_logging_middleware.py +++ b/python_template_server/middleware/request_logging_middleware.py @@ -19,12 +19,14 @@ def __init__(self, app: ASGIApp) -> None: async def dispatch(self, request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response: """Log request and response details.""" client_ip = request.client.host if request.client else "unknown" + client_port = request.client.port if request.client else 0 self.logger.info( - "Request: %s %s from %s", + "Request: %s %s from %s:%d", request.method, request.url.path, client_ip, + client_port, ) response = await call_next(request) diff --git a/python_template_server/models.py b/python_template_server/models.py index e165dfe..a95e1de 100644 --- a/python_template_server/models.py +++ b/python_template_server/models.py @@ -14,8 +14,8 @@ class ServerConfigModel(BaseModel): """Server configuration model.""" - host: str = Field(default="localhost", description="Server hostname or IP address") - port: int = Field(default=8000, ge=1, le=65535, description="Server port number") + host: str = Field(default="0.0.0.0", description="Server hostname or IP address") # noqa: S104 + port: int = Field(default=443, ge=1, le=65535, description="Server port number") @property def address(self) -> str: @@ -33,7 +33,13 @@ class SecurityConfigModel(BaseModel): hsts_max_age: int = Field(default=31536000, ge=0, description="HSTS max-age in seconds (1 year default)") content_security_policy: str = Field( - default="default-src 'self'", description="Content Security Policy header value" + default=( + "default-src 'self'; " + "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " + "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " + "img-src 'self' data: https://cdn.jsdelivr.net https://fastapi.tiangolo.com" + ), + description="Content Security Policy header value", ) @@ -87,6 +93,7 @@ def save_to_file(self, filepath: Path) -> None: :param Path filepath: Path to the configuration file """ + filepath.parent.mkdir(parents=True, exist_ok=True) with filepath.open("w", encoding="utf-8") as config_file: config_file.write(self.model_dump_json(indent=2)) config_file.write("\n") diff --git a/python_template_server/template_server.py b/python_template_server/template_server.py index 45cf944..f9c97bc 100644 --- a/python_template_server/template_server.py +++ b/python_template_server/template_server.py @@ -1,5 +1,6 @@ """Template FastAPI server module.""" +import argparse import json import logging import sys @@ -20,6 +21,7 @@ from slowapi.util import get_remote_address from python_template_server.authentication_handler import load_hashed_token, verify_token +from python_template_server.certificate_handler import CertificateHandler from python_template_server.constants import API_KEY_HEADER_NAME, API_PREFIX, CONFIG_FILE_PATH, PACKAGE_NAME from python_template_server.logging_setup import setup_logging from python_template_server.middleware import RequestLoggingMiddleware, SecurityHeadersMiddleware @@ -34,6 +36,7 @@ setup_logging() logger = logging.getLogger(__name__) +argparser = argparse.ArgumentParser(description="Template FastAPI Server") class TemplateServer(ABC): @@ -65,7 +68,8 @@ def __init__( self.api_prefix = api_prefix self.api_key_header_name = api_key_header_name self.config_filepath = config_filepath - self.config = config or self.load_config(config_filepath) + self.config = config or self.load_config(self.config_filepath) + self.cert_handler = CertificateHandler(self.config.certificate) CustomJSONResponse.configure(self.config.json_response) @@ -113,22 +117,25 @@ def load_config(self, config_filepath: Path) -> TemplateServerConfig: logger.error("Configuration file not found: %s", config_filepath) sys.exit(1) - config_data = {} try: with config_filepath.open() as f: config_data = json.load(f) + config = self.validate_config(config_data) + argparser.add_argument("--port", type=int, default=config.server.port, help="Port to run the server on") + args = argparser.parse_args() + config.server.port = args.port + config.save_to_file(config_filepath) except json.JSONDecodeError: logger.exception("JSON parsing error: %s", config_filepath) sys.exit(1) except OSError: logger.exception("JSON read error: %s", config_filepath) sys.exit(1) - - try: - return self.validate_config(config_data) except ValidationError: logger.exception("Invalid configuration in: %s", config_filepath) sys.exit(1) + else: + return config async def _verify_api_key( self, api_key: str | None = Security(APIKeyHeader(name=API_KEY_HEADER_NAME, auto_error=False)) @@ -225,17 +232,14 @@ def _limit_route(self, route_function: Callable[..., Any]) -> Callable[..., Any] return route_function def run(self) -> None: - """Run the server using uvicorn. - - :raise FileNotFoundError: If SSL certificate files are missing - """ + """Run the server using uvicorn.""" try: cert_file = self.config.certificate.ssl_cert_file_path key_file = self.config.certificate.ssl_key_file_path if not (cert_file.exists() and key_file.exists()): - logger.error("SSL certificate files are missing. Expected: '%s' and '%s'", cert_file, key_file) - sys.exit(1) + logger.warning("SSL certificate or key file not found, generating self-signed certificate...") + self.cert_handler.generate_self_signed_cert() logger.info("Starting server: %s%s", self.config.server.url, self.api_prefix) uvicorn.run( @@ -244,10 +248,12 @@ def run(self) -> None: port=self.config.server.port, ssl_keyfile=str(key_file), ssl_certfile=str(cert_file), + log_level="warning", + access_log=False, ) logger.info("Server stopped.") - except OSError: - logger.exception("Failed to start - ran into an OSError!") + except Exception: + logger.exception("Failed to start!") sys.exit(1) def add_unauthenticated_route( diff --git a/tests/conftest.py b/tests/conftest.py index 0cc1ee5..44312c0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,7 @@ """Pytest fixtures for the application's unit tests.""" from collections.abc import Generator +from pathlib import Path from unittest.mock import MagicMock, mock_open, patch import pytest @@ -16,14 +17,6 @@ # General fixtures -@pytest.fixture(autouse=True) -def mock_here(tmp_path: str) -> Generator[MagicMock]: - """Mock the here() function to return a temporary directory.""" - with patch("pyhere.here") as mock_here: - mock_here.return_value = tmp_path - yield mock_here - - @pytest.fixture def mock_exists() -> Generator[MagicMock]: """Mock the Path.exists() method.""" @@ -52,14 +45,6 @@ def mock_touch() -> Generator[MagicMock]: yield mock_touch -@pytest.fixture -def mock_sys_exit() -> Generator[MagicMock]: - """Mock sys.exit to raise SystemExit.""" - with patch("sys.exit") as mock_exit: - mock_exit.side_effect = SystemExit - yield mock_exit - - @pytest.fixture def mock_set_key() -> Generator[MagicMock]: """Mock the set_key function.""" @@ -74,6 +59,12 @@ def mock_os_getenv() -> Generator[MagicMock]: yield mock_getenv +@pytest.fixture +def mock_tmp_config_path(tmp_path: Path) -> Path: + """Provide a temporary config file path.""" + return tmp_path / "config.json" + + # Template Server Configuration Models @pytest.fixture def mock_server_config_dict() -> dict: diff --git a/tests/middleware/test_request_logging_middleware.py b/tests/middleware/test_request_logging_middleware.py index ac5d1c9..f11bb60 100644 --- a/tests/middleware/test_request_logging_middleware.py +++ b/tests/middleware/test_request_logging_middleware.py @@ -21,6 +21,8 @@ async def test_dispatch_logs_request_and_response( self, mock_app: FastAPI, mock_request: Request, mock_response: Response ) -> None: """Test that dispatch logs both request and response.""" + assert mock_request.client is not None # Ensure client is set for this test + middleware = RequestLoggingMiddleware(mock_app) # Mock the call_next function @@ -34,7 +36,7 @@ async def test_dispatch_logs_request_and_response( assert result == mock_response middleware.logger.info.assert_has_calls( [ - call("Request: %s %s from %s", "GET", "/test", "127.0.0.1"), + call("Request: %s %s from %s:%d", "GET", "/test", "127.0.0.1", mock_request.client.port), call("Response: %s %s -> %d", "GET", "/test", 200), ] ) @@ -56,4 +58,4 @@ async def test_dispatch_handles_missing_client(self, mock_app: FastAPI, mock_res result = await middleware.dispatch(request, call_next) assert result == mock_response - middleware.logger.info.assert_any_call("Request: %s %s from %s", "POST", "/api/endpoint", "unknown") + middleware.logger.info.assert_any_call("Request: %s %s from %s:%d", "POST", "/api/endpoint", "unknown", 0) diff --git a/tests/test_certificate_handler.py b/tests/test_certificate_handler.py index 35d4aef..d26b8bd 100644 --- a/tests/test_certificate_handler.py +++ b/tests/test_certificate_handler.py @@ -1,6 +1,5 @@ """Unit tests for the python_template_server.certificate_handler module.""" -from collections.abc import Generator from pathlib import Path from unittest.mock import MagicMock, patch @@ -10,38 +9,12 @@ from python_template_server.certificate_handler import ( CertificateHandler, - generate_self_signed_certificate, ) -from python_template_server.models import CertificateConfigModel, TemplateServerConfig +from python_template_server.models import CertificateConfigModel RSA_KEY_SIZE = 4096 -@pytest.fixture(autouse=True) -def mock_parse_args( - tmp_path: Path, -) -> Generator[MagicMock]: - """Mock argparse.ArgumentParser.parse_args to return a test config path.""" - test_config_path = tmp_path / "config.json" - with patch( - "python_template_server.certificate_handler.parse_args", - return_value=MagicMock(autospec=True, config=str(test_config_path)), - ) as mock_parse: - yield mock_parse - - -@pytest.fixture -def mock_example_server( - tmp_path: Path, mock_template_server_config: TemplateServerConfig -) -> Generator[MagicMock]: - """Mock the ExampleServer class.""" - with patch("python_template_server.certificate_handler.ExampleServer") as mock_server: - cert_dir = tmp_path / "certs" - mock_template_server_config.certificate.directory = str(cert_dir) - mock_server.return_value.config = mock_template_server_config - yield mock_server - - class TestCertificateHandler: """Unit tests for the CertificateHandler class.""" @@ -133,7 +106,6 @@ def test_generate_self_signed_cert_directory_creation_fails( mock_mkdir: MagicMock, mock_open_file: MagicMock, mock_exists: MagicMock, - mock_sys_exit: MagicMock, ) -> None: """Test certificate generation when directory creation fails.""" mock_exists.return_value = False @@ -144,8 +116,6 @@ def test_generate_self_signed_cert_directory_creation_fails( with pytest.raises(SystemExit): handler.generate_self_signed_cert() - mock_sys_exit.assert_called_once_with(1) - def test_generate_self_signed_cert_permission_error( self, mock_certificate_config: CertificateConfigModel, tmp_path: Path ) -> None: @@ -175,77 +145,3 @@ def test_generate_self_signed_cert_os_error( with patch.object(handler, "write_to_cert_file", side_effect=OSError("Disk full")): with pytest.raises(OSError, match="Disk full"): handler.generate_self_signed_cert() - - -class TestGenerateSelfSignedCertificate: - """Unit tests for the generate_self_signed_certificate function.""" - - def test_generate_self_signed_certificate_success( - self, mock_example_server: MagicMock, mock_template_server_config: TemplateServerConfig, tmp_path: Path - ) -> None: - """Test successful certificate generation via wrapper function.""" - with ( - patch.object(CertificateHandler, "write_to_key_file") as mock_write_key, - patch.object(CertificateHandler, "write_to_cert_file") as mock_write_cert, - ): - generate_self_signed_certificate() - - # Verify write methods were called with PEM-encoded data - mock_write_key.assert_called_once() - key_data = mock_write_key.call_args[0][0] - assert b"BEGIN RSA PRIVATE KEY" in key_data - - mock_write_cert.assert_called_once() - cert_data = mock_write_cert.call_args[0][0] - assert b"BEGIN CERTIFICATE" in cert_data - - def test_generate_self_signed_certificate_os_error( - self, - mock_example_server: MagicMock, - mock_template_server_config: TemplateServerConfig, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test certificate generation wrapper handles OSError.""" - with patch( - "python_template_server.certificate_handler.CertificateHandler.generate_self_signed_cert", - side_effect=OSError("Disk error"), - ): - with pytest.raises(SystemExit): - generate_self_signed_certificate() - - mock_sys_exit.assert_called_once_with(1) - - def test_generate_self_signed_certificate_permission_error( - self, - mock_example_server: MagicMock, - mock_template_server_config: TemplateServerConfig, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test certificate generation wrapper handles PermissionError.""" - with patch( - "python_template_server.certificate_handler.CertificateHandler.generate_self_signed_cert", - side_effect=PermissionError("No permission"), - ): - with pytest.raises(SystemExit): - generate_self_signed_certificate() - - mock_sys_exit.assert_called_once_with(1) - - def test_generate_self_signed_certificate_unexpected_error( - self, - mock_example_server: MagicMock, - mock_template_server_config: TemplateServerConfig, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test certificate generation wrapper handles unexpected exceptions.""" - with patch( - "python_template_server.certificate_handler.CertificateHandler.generate_self_signed_cert", - side_effect=RuntimeError("Unexpected error"), - ): - with pytest.raises(SystemExit): - generate_self_signed_certificate() - - mock_sys_exit.assert_called_once_with(1) diff --git a/tests/test_template_server.py b/tests/test_template_server.py index 92b4340..3dec9a5 100644 --- a/tests/test_template_server.py +++ b/tests/test_template_server.py @@ -69,14 +69,21 @@ def mock_timestamp() -> Generator[str]: @pytest.fixture -def mock_template_server(mock_template_server_config: TemplateServerConfig) -> MockTemplateServer: +def mock_template_server( + mock_template_server_config: TemplateServerConfig, mock_tmp_config_path: Path +) -> Generator[MockTemplateServer]: """Provide a MockTemplateServer instance for testing.""" - return MockTemplateServer(config=mock_template_server_config) + with patch("python_template_server.template_server.CertificateHandler", return_value=MagicMock(), autospec=True): + yield MockTemplateServer(config_filepath=mock_tmp_config_path, config=mock_template_server_config) class MockTemplateServer(TemplateServer): """Mock subclass of TemplateServer for testing.""" + def __init__(self, config_filepath: Path, config: TemplateServerConfig | None = None) -> None: + """Initialize MockTemplateServer.""" + super().__init__(config_filepath=config_filepath, config=config) + def mock_unprotected_method(self, request: Request) -> BaseResponse: """Mock unprotected method.""" return BaseResponse( @@ -170,91 +177,67 @@ def test_json_response_configured( class TestLoadConfig: """Tests for the load_config function.""" - def test_load_config_with_filepath_success(self, mock_template_server_config: TemplateServerConfig) -> None: + def test_load_config_with_filepath_success( + self, mock_template_server_config: TemplateServerConfig, mock_tmp_config_path: Path + ) -> None: """Test that load_config is called with the specified filepath when config is None.""" with patch.object( MockTemplateServer, "load_config", return_value=mock_template_server_config ) as mock_load_config: - custom_filepath = Path("/custom/config.json") - server = MockTemplateServer(config_filepath=custom_filepath) + server = MockTemplateServer(config_filepath=mock_tmp_config_path) - mock_load_config.assert_called_once_with(custom_filepath) + mock_load_config.assert_called_once_with(mock_tmp_config_path) assert server.config == mock_template_server_config - def test_load_config_with_no_filepath_success( - self, - mock_exists: MagicMock, - mock_open_file: MagicMock, - mock_sys_exit: MagicMock, - mock_template_server_config: TemplateServerConfig, - ) -> None: - """Test successful loading of config.""" - mock_exists.return_value = True - mock_open_file.return_value.read.return_value = json.dumps(mock_template_server_config.model_dump()) - - config = MockTemplateServer().config - - assert isinstance(config, TemplateServerConfig) - assert config == mock_template_server_config - mock_sys_exit.assert_not_called() - def test_load_config_file_not_found( self, mock_exists: MagicMock, - mock_sys_exit: MagicMock, + mock_tmp_config_path: Path, ) -> None: """Test loading config when the file does not exist.""" mock_exists.return_value = False with pytest.raises(SystemExit): - MockTemplateServer() - - mock_sys_exit.assert_called_once_with(1) + MockTemplateServer(config_filepath=mock_tmp_config_path) def test_load_config_invalid_json( self, mock_exists: MagicMock, mock_open_file: MagicMock, - mock_sys_exit: MagicMock, + mock_tmp_config_path: Path, ) -> None: """Test loading config with invalid JSON content.""" mock_exists.return_value = True mock_open_file.return_value.read.return_value = "invalid json" with pytest.raises(SystemExit): - MockTemplateServer() - - mock_sys_exit.assert_called_with(1) + MockTemplateServer(config_filepath=mock_tmp_config_path) def test_load_config_os_error( self, mock_exists: MagicMock, mock_open_file: MagicMock, - mock_sys_exit: MagicMock, + mock_tmp_config_path: Path, ) -> None: """Test loading config that raises an OSError.""" mock_exists.return_value = True mock_open_file.side_effect = OSError("File read error") with pytest.raises(SystemExit): - MockTemplateServer() - - mock_sys_exit.assert_called_with(1) + MockTemplateServer(config_filepath=mock_tmp_config_path) def test_load_config_validation_error( self, mock_exists: MagicMock, mock_open_file: MagicMock, - mock_sys_exit: MagicMock, + mock_tmp_config_path: Path, ) -> None: """Test loading config that fails validation.""" mock_exists.return_value = True mock_open_file.return_value.read.return_value = json.dumps({"server": {"host": "localhost", "port": 999999}}) with pytest.raises(SystemExit): - MockTemplateServer() - - mock_sys_exit.assert_called_once_with(1) + MockTemplateServer(config_filepath=mock_tmp_config_path) class TestVerifyApiKey: @@ -318,34 +301,42 @@ def test_rate_limit_exception_handler(self, mock_template_server: TemplateServer assert json.loads(response.body.decode()) == {"detail": "Rate limit exceeded"} assert response.headers.get("Retry-After") == str(exc.retry_after) - def test_setup_rate_limiting_enabled(self, mock_template_server_config: TemplateServerConfig) -> None: + def test_setup_rate_limiting_enabled( + self, mock_template_server_config: TemplateServerConfig, mock_tmp_config_path: Path + ) -> None: """Test rate limiting setup when enabled.""" mock_template_server_config.rate_limit.enabled = True - server = MockTemplateServer(config=mock_template_server_config) + server = MockTemplateServer(config_filepath=mock_tmp_config_path, config=mock_template_server_config) assert server.limiter is not None assert server.app.state.limiter is not None - def test_setup_rate_limiting_disabled(self, mock_template_server_config: TemplateServerConfig) -> None: + def test_setup_rate_limiting_disabled( + self, mock_template_server_config: TemplateServerConfig, mock_tmp_config_path: Path + ) -> None: """Test rate limiting setup when disabled.""" - server = MockTemplateServer(config=mock_template_server_config) + server = MockTemplateServer(config_filepath=mock_tmp_config_path, config=mock_template_server_config) assert server.limiter is None - def test_limit_route_with_limiter_enabled(self, mock_template_server_config: TemplateServerConfig) -> None: + def test_limit_route_with_limiter_enabled( + self, mock_template_server_config: TemplateServerConfig, mock_tmp_config_path: Path + ) -> None: """Test _limit_route when rate limiting is enabled.""" mock_template_server_config.rate_limit.enabled = True - server = MockTemplateServer(config=mock_template_server_config) + server = MockTemplateServer(config_filepath=mock_tmp_config_path, config=mock_template_server_config) limited_route = server._limit_route(server.mock_unprotected_method) assert limited_route != server.mock_unprotected_method assert hasattr(limited_route, "__wrapped__") - def test_limit_route_with_limiter_disabled(self, mock_template_server_config: TemplateServerConfig) -> None: + def test_limit_route_with_limiter_disabled( + self, mock_template_server_config: TemplateServerConfig, mock_tmp_config_path: Path + ) -> None: """Test _limit_route when rate limiting is disabled.""" - server = MockTemplateServer(config=mock_template_server_config) + server = MockTemplateServer(config_filepath=mock_tmp_config_path, config=mock_template_server_config) limited_route = server._limit_route(server.mock_unprotected_method) assert limited_route == server.mock_unprotected_method @@ -354,41 +345,47 @@ def test_limit_route_with_limiter_disabled(self, mock_template_server_config: Te class TestTemplateServerRun: """Unit tests for TemplateServer.run method.""" - def test_run_success(self, mock_template_server: TemplateServer, mock_exists: MagicMock) -> None: + @pytest.fixture + def mock_uvicorn_run(self) -> Generator[MagicMock]: + """Mock uvicorn.run function.""" + with patch("python_template_server.template_server.uvicorn.run") as mock_run: + yield mock_run + + def test_run_success( + self, mock_template_server: TemplateServer, mock_exists: MagicMock, mock_uvicorn_run: MagicMock + ) -> None: """Test successful server run.""" mock_exists.side_effect = [True, True] - with patch("python_template_server.template_server.uvicorn.run") as mock_uvicorn_run: - mock_template_server.run() + mock_template_server.run() mock_uvicorn_run.assert_called_once() call_kwargs = mock_uvicorn_run.call_args.kwargs assert call_kwargs["host"] == mock_template_server.config.server.host assert call_kwargs["port"] == mock_template_server.config.server.port - def test_run_missing_cert_file(self, mock_template_server: TemplateServer, mock_exists: MagicMock) -> None: - """Test run raises SystemExit when certificate file is missing.""" - mock_exists.side_effect = [False, True] - - with pytest.raises(SystemExit): - mock_template_server.run() + def test_run_generates_cert_when_missing( + self, mock_template_server: TemplateServer, mock_exists: MagicMock, mock_uvicorn_run: MagicMock + ) -> None: + """Test that self-signed certificate is generated when cert/key files are missing.""" + # Mock the cert and key file paths to not exist + mock_exists.side_effect = [False, False] - def test_run_missing_key_file(self, mock_template_server: TemplateServer, mock_exists: MagicMock) -> None: - """Test run raises SystemExit when key file is missing.""" - mock_exists.side_effect = [True, False] + mock_template_server.run() - with pytest.raises(SystemExit): - mock_template_server.run() + mock_template_server.cert_handler.generate_self_signed_cert.assert_called_once() # type: ignore[attr-defined] + mock_uvicorn_run.assert_called_once() - def test_run_os_error(self, mock_template_server: TemplateServer, mock_exists: MagicMock) -> None: - """Test run raises SystemExit on OSError.""" + def test_run_error( + self, mock_template_server: TemplateServer, mock_exists: MagicMock, mock_uvicorn_run: MagicMock + ) -> None: + """Test run raises SystemExit on Exception.""" mock_exists.side_effect = [True, True] - with patch("python_template_server.template_server.uvicorn.run") as mock_uvicorn_run: - mock_uvicorn_run.side_effect = OSError("Test OSError") + mock_uvicorn_run.side_effect = Exception("Test Exception") - with pytest.raises(SystemExit): - mock_template_server.run() + with pytest.raises(SystemExit): + mock_template_server.run() class TestTemplateServerRoutes: @@ -431,11 +428,11 @@ def test_add_authenticated_route(self, mock_template_server: MockTemplateServer) assert test_route.response_model == BaseResponse def test_limited_parameter_with_rate_limiting_enabled( - self, mock_template_server_config: TemplateServerConfig + self, mock_template_server_config: TemplateServerConfig, mock_tmp_config_path: Path ) -> None: """Test that limited=True applies rate limiting when limiter is enabled.""" mock_template_server_config.rate_limit.enabled = True - server = MockTemplateServer(config=mock_template_server_config) + server = MockTemplateServer(config_filepath=mock_tmp_config_path, config=mock_template_server_config) # Get the limited routes api_routes = [route for route in server.app.routes if isinstance(route, APIRoute)] @@ -452,10 +449,12 @@ def test_limited_parameter_with_rate_limiting_enabled( # Unlimited route should not have the limiter wrapper assert not hasattr(unlimited_route.endpoint, "__wrapped__") - def test_authenticated_route_limited_parameter(self, mock_template_server_config: TemplateServerConfig) -> None: + def test_authenticated_route_limited_parameter( + self, mock_template_server_config: TemplateServerConfig, mock_tmp_config_path: Path + ) -> None: """Test that limited parameter works correctly for authenticated routes.""" mock_template_server_config.rate_limit.enabled = True - server = MockTemplateServer(config=mock_template_server_config) + server = MockTemplateServer(config_filepath=mock_tmp_config_path, config=mock_template_server_config) # Get the authenticated routes api_routes = [route for route in server.app.routes if isinstance(route, APIRoute)]