diff --git a/app/api/diagnostic.py b/app/api/diagnostic.py index 400b2f9f..7fffbcbd 100644 --- a/app/api/diagnostic.py +++ b/app/api/diagnostic.py @@ -3,6 +3,7 @@ Diagnostic API endpoints """ import logging +from typing import Annotated from fastapi import APIRouter, Depends, Request @@ -14,10 +15,12 @@ logger = logging.getLogger(__name__) router = APIRouter() +CurrentUser = Annotated[dict, Depends(get_current_user)] + @router.get("/diagnostic/settings") @require_login -async def diagnostic_settings(request: Request, current_user: dict = Depends(get_current_user)): +async def diagnostic_settings(request: Request, current_user: CurrentUser): """ API endpoint to dump settings to the log and view basic config information This endpoint doesn't expose sensitive information like passwords or tokens diff --git a/app/api/dropbox.py b/app/api/dropbox.py index 711055cd..fde72f16 100644 --- a/app/api/dropbox.py +++ b/app/api/dropbox.py @@ -4,6 +4,7 @@ Dropbox API endpoints import logging import os +from typing import Annotated, Optional import requests from fastapi import APIRouter, Form, HTTPException, Request, status @@ -22,11 +23,11 @@ router = APIRouter() @require_login async def exchange_dropbox_token( request: Request, - client_id: str = Form(...), - client_secret: str = Form(...), - redirect_uri: str = Form(...), - code: str = Form(...), - folder_path: str = Form(None), + client_id: Annotated[str, Form(...)], + client_secret: Annotated[str, Form(...)], + redirect_uri: Annotated[str, Form(...)], + code: Annotated[str, Form(...)], + folder_path: Annotated[Optional[str], Form()] = None, ): """ Exchange an authorization code for a refresh token from Dropbox. @@ -58,10 +59,10 @@ async def exchange_dropbox_token( @require_login async def update_dropbox_settings( request: Request, - app_key: str = Form(None), - app_secret: str = Form(None), - refresh_token: str = Form(...), - folder_path: str = Form(None), + refresh_token: Annotated[str, Form(...)], + app_key: Annotated[Optional[str], Form()] = None, + app_secret: Annotated[Optional[str], Form()] = None, + folder_path: Annotated[Optional[str], Form()] = None, ): """ Update Dropbox settings in memory @@ -182,10 +183,10 @@ async def test_dropbox_token(request: Request): @require_login async def save_dropbox_settings( request: Request, - app_key: str = Form(None), - app_secret: str = Form(None), - refresh_token: str = Form(...), - folder_path: str = Form(None), + refresh_token: Annotated[str, Form(...)], + app_key: Annotated[Optional[str], Form()] = None, + app_secret: Annotated[Optional[str], Form()] = None, + folder_path: Annotated[Optional[str], Form()] = None, ): """ Save Dropbox settings to the .env file diff --git a/app/api/files.py b/app/api/files.py index 0e81f43b..abdc4ed5 100644 --- a/app/api/files.py +++ b/app/api/files.py @@ -6,7 +6,7 @@ import logging import mimetypes import os import uuid -from typing import List, Optional +from typing import Annotated, List, Optional from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile from sqlalchemy import asc, desc @@ -27,6 +27,8 @@ logger = logging.getLogger(__name__) router = APIRouter() +DbSession = Annotated[Session, Depends(get_db)] + def get_limiter(): """Get the limiter from app state.""" @@ -39,7 +41,7 @@ def get_limiter(): @require_login def list_files_api( request: Request, - db: Session = Depends(get_db), + db: DbSession, page: int = Query(1, ge=1, description="Page number"), per_page: int = Query(50, ge=1, le=200, description="Items per page"), sort_by: str = Query( @@ -152,7 +154,7 @@ def _get_file_processing_status(db: Session, file_id: int) -> dict: @router.get("/files/{file_id}") @require_login -def get_file_details(request: Request, file_id: int, db: Session = Depends(get_db)): +def get_file_details(request: Request, file_id: int, db: DbSession): """ Get detailed information about a specific file including processing history. """ @@ -205,7 +207,7 @@ def get_file_details(request: Request, file_id: int, db: Session = Depends(get_d @router.delete("/files/{file_id}") @require_login -def delete_file_record(request: Request, file_id: int, db: Session = Depends(get_db)): +def delete_file_record(request: Request, file_id: int, db: DbSession): """ Delete a file record from the database. This only removes the database entry, not the actual file. @@ -240,7 +242,7 @@ def delete_file_record(request: Request, file_id: int, db: Session = Depends(get @router.post("/files/bulk-delete") @require_login -def bulk_delete_files(request: Request, file_ids: List[int], db: Session = Depends(get_db)): +def bulk_delete_files(request: Request, file_ids: List[int], db: DbSession): """ Delete multiple file records from the database. This only removes the database entries, not the actual files. @@ -284,7 +286,7 @@ def bulk_delete_files(request: Request, file_ids: List[int], db: Session = Depen @router.post("/files/bulk-reprocess") @require_login -def bulk_reprocess_files(request: Request, file_ids: List[int], db: Session = Depends(get_db)): +def bulk_reprocess_files(request: Request, file_ids: List[int], db: DbSession): """ Reprocess multiple files by queuing them for processing. """ @@ -345,7 +347,7 @@ def bulk_reprocess_files(request: Request, file_ids: List[int], db: Session = De @router.post("/files/{file_id}/reprocess") @require_login -def reprocess_single_file(request: Request, file_id: int, db: Session = Depends(get_db)): +def reprocess_single_file(request: Request, file_id: int, db: DbSession): """ Reprocess a single file by queuing it for processing again. @@ -487,10 +489,10 @@ def _retry_pipeline_step(file_record: FileRecord, step_name: str, db: Session) - def retry_subtask( request: Request, file_id: int, + db: DbSession, subtask_name: str = Query( ..., description="Name of the subtask to retry (e.g., 'upload_to_dropbox', 'extract_metadata_with_gpt')" ), - db: Session = Depends(get_db), ): """ Retry a specific failed subtask for a file. @@ -603,8 +605,8 @@ def retry_subtask( def get_file_preview( request: Request, file_id: int, + db: DbSession, version: str = Query("original", description="original or processed"), - db: Session = Depends(get_db), ): """ Get file content for preview (original or processed version). @@ -675,8 +677,8 @@ def get_file_preview( def download_file( request: Request, file_id: int, + db: DbSession, version: str = Query("original", description="original or processed"), - db: Session = Depends(get_db), ): """ Download file (original or processed version) as attachment. diff --git a/app/api/google_drive.py b/app/api/google_drive.py index c2f74fab..e1942df4 100644 --- a/app/api/google_drive.py +++ b/app/api/google_drive.py @@ -5,7 +5,7 @@ Google Drive API endpoints import logging import os from datetime import datetime -from typing import Optional +from typing import Annotated, Optional from fastapi import APIRouter, Form, HTTPException, Request, status @@ -23,11 +23,11 @@ router = APIRouter() @require_login async def exchange_google_drive_token( request: Request, - client_id: str = Form(...), - client_secret: str = Form(...), - redirect_uri: str = Form(...), - code: str = Form(...), - folder_id: Optional[str] = Form(None), + client_id: Annotated[str, Form(...)], + client_secret: Annotated[str, Form(...)], + redirect_uri: Annotated[str, Form(...)], + code: Annotated[str, Form(...)], + folder_id: Annotated[Optional[str], Form()] = None, ): """ Exchange an authorization code for refresh and access tokens from Google. @@ -59,11 +59,11 @@ async def exchange_google_drive_token( @require_login async def update_google_drive_settings( request: Request, - client_id: str = Form(None), - client_secret: str = Form(None), - refresh_token: str = Form(...), - folder_id: str = Form(None), - use_oauth: str = Form("true"), + refresh_token: Annotated[str, Form(...)], + client_id: Annotated[Optional[str], Form()] = None, + client_secret: Annotated[Optional[str], Form()] = None, + folder_id: Annotated[Optional[str], Form()] = None, + use_oauth: Annotated[str, Form()] = "true", ): """ Update Google Drive settings in memory @@ -328,11 +328,11 @@ def format_time_remaining(time_delta): @require_login async def save_dropbox_settings( request: Request, - client_id: str = Form(None), - client_secret: str = Form(None), - refresh_token: str = Form(...), - folder_id: str = Form(None), - use_oauth: str = Form("true"), + refresh_token: Annotated[str, Form(...)], + client_id: Annotated[Optional[str], Form()] = None, + client_secret: Annotated[Optional[str], Form()] = None, + folder_id: Annotated[Optional[str], Form()] = None, + use_oauth: Annotated[str, Form()] = "true", ): """ Save Google Drive settings to the .env file diff --git a/app/api/logs.py b/app/api/logs.py index 7ef0c434..caeb1225 100644 --- a/app/api/logs.py +++ b/app/api/logs.py @@ -3,7 +3,7 @@ Processing logs API endpoints """ import logging -from typing import Optional +from typing import Annotated, Optional from fastapi import APIRouter, Depends, HTTPException, Query, Request from sqlalchemy import desc @@ -18,12 +18,14 @@ logger = logging.getLogger(__name__) router = APIRouter() +DbSession = Annotated[Session, Depends(get_db)] + @router.get("/logs") @require_login def list_processing_logs( request: Request, - db: Session = Depends(get_db), + db: DbSession, file_id: Optional[int] = Query(None, description="Filter by file ID"), task_id: Optional[str] = Query(None, description="Filter by task ID"), limit: int = Query(100, ge=1, le=1000, description="Number of logs to return"), @@ -81,7 +83,7 @@ def list_processing_logs( @router.get("/logs/file/{file_id}") @require_login -def get_file_processing_logs(request: Request, file_id: int, db: Session = Depends(get_db)): +def get_file_processing_logs(request: Request, file_id: int, db: DbSession): """ Get all processing logs for a specific file. Returns logs ordered by timestamp (oldest first to show processing flow). @@ -125,7 +127,7 @@ def get_file_processing_logs(request: Request, file_id: int, db: Session = Depen @router.get("/logs/task/{task_id}") @require_login -def get_task_processing_logs(request: Request, task_id: str, db: Session = Depends(get_db)): +def get_task_processing_logs(request: Request, task_id: str, db: DbSession): """ Get all processing logs for a specific task. Returns logs ordered by timestamp (oldest first to show processing flow). diff --git a/app/api/onedrive.py b/app/api/onedrive.py index bbaaef96..1b226768 100644 --- a/app/api/onedrive.py +++ b/app/api/onedrive.py @@ -5,6 +5,7 @@ OneDrive API endpoints import logging import os from datetime import datetime, timedelta +from typing import Annotated, Optional import requests from fastapi import APIRouter, Form, HTTPException, Request, status @@ -23,11 +24,11 @@ router = APIRouter() @require_login async def exchange_onedrive_token( request: Request, - client_id: str = Form(...), - client_secret: str = Form(...), - redirect_uri: str = Form(...), - code: str = Form(...), - tenant_id: str = Form(...), + client_id: Annotated[str, Form(...)], + client_secret: Annotated[str, Form(...)], + redirect_uri: Annotated[str, Form(...)], + code: Annotated[str, Form(...)], + tenant_id: Annotated[str, Form(...)], ): """ Exchange an authorization code for a refresh token. @@ -197,11 +198,11 @@ def format_time_remaining(time_delta): @require_login async def save_onedrive_settings( request: Request, - client_id: str = Form(None), - client_secret: str = Form(None), - refresh_token: str = Form(...), - tenant_id: str = Form("common"), - folder_path: str = Form(None), + refresh_token: Annotated[str, Form(...)], + client_id: Annotated[Optional[str], Form()] = None, + client_secret: Annotated[Optional[str], Form()] = None, + tenant_id: Annotated[str, Form()] = "common", + folder_path: Annotated[Optional[str], Form()] = None, ): """ Save OneDrive settings to the .env file @@ -292,11 +293,11 @@ async def save_onedrive_settings( @require_login async def update_onedrive_settings( request: Request, - client_id: str = Form(None), - client_secret: str = Form(None), - refresh_token: str = Form(...), - tenant_id: str = Form("common"), - folder_path: str = Form(None), + refresh_token: Annotated[str, Form(...)], + client_id: Annotated[Optional[str], Form()] = None, + client_secret: Annotated[Optional[str], Form()] = None, + tenant_id: Annotated[str, Form()] = "common", + folder_path: Annotated[Optional[str], Form()] = None, ): """ Update OneDrive settings in memory (without modifying .env file) diff --git a/app/api/settings.py b/app/api/settings.py index 5e9bd4af..7cf74a9e 100644 --- a/app/api/settings.py +++ b/app/api/settings.py @@ -3,7 +3,7 @@ API endpoints for managing application settings. """ import logging -from typing import Any, Dict, Optional +from typing import Annotated, Any, Dict, Optional from fastapi import APIRouter, Depends, HTTPException, Request, status from pydantic import BaseModel, Field @@ -39,6 +39,10 @@ def require_admin(request: Request) -> dict: return user +DbSession = Annotated[Session, Depends(get_db)] +AdminUser = Annotated[dict, Depends(require_admin)] + + class SettingUpdate(BaseModel): """Model for updating a setting""" @@ -63,7 +67,7 @@ class SettingsListResponse(BaseModel): @router.get("/", response_model=SettingsListResponse) -async def get_settings(request: Request, db: Session = Depends(get_db), admin: dict = Depends(require_admin)): +async def get_settings(request: Request, db: DbSession, admin: AdminUser): """ Get all application settings with metadata. Admin only. @@ -89,7 +93,7 @@ async def get_settings(request: Request, db: Session = Depends(get_db), admin: d @router.get("/{key}", response_model=SettingResponse) -async def get_setting(key: str, request: Request, db: Session = Depends(get_db), admin: dict = Depends(require_admin)): +async def get_setting(key: str, request: Request, db: DbSession, admin: AdminUser): """ Get a specific setting by key. Admin only. @@ -114,8 +118,8 @@ async def update_setting( key: str, setting: SettingUpdate, request: Request, - db: Session = Depends(get_db), - admin: dict = Depends(require_admin), + db: DbSession, + admin: AdminUser, ): """ Update a specific setting. @@ -156,9 +160,7 @@ async def update_setting( @router.delete("/{key}") -async def delete_setting( - key: str, request: Request, db: Session = Depends(get_db), admin: dict = Depends(require_admin) -): +async def delete_setting(key: str, request: Request, db: DbSession, admin: AdminUser): """ Delete a setting from the database (reverts to environment variable or default). Admin only. @@ -182,9 +184,7 @@ async def delete_setting( @router.post("/bulk-update") -async def bulk_update_settings( - updates: list[SettingUpdate], request: Request, db: Session = Depends(get_db), admin: dict = Depends(require_admin) -): +async def bulk_update_settings(updates: list[SettingUpdate], request: Request, db: DbSession, admin: AdminUser): """ Update multiple settings at once. Admin only. diff --git a/requirements-dev.txt b/requirements-dev.txt index b20fdb6b..765e2ae7 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -8,6 +8,7 @@ pytest-asyncio>=0.23.0 pytest-mock>=3.12.0 httpx>=0.26.0 # For async test client testcontainers>=3.7.1 # For integration tests with real containers +fpdf2>=2.8.0 # For generating test PDF documents in integration tests minio>=7.1.0 # For MinIO/S3 integration tests redis>=4.5.0 # For Redis integration tests boto3>=1.26.0 # For S3 integration tests diff --git a/tests/conftest.py b/tests/conftest.py index 03d8b6f4..20f395b3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,7 +4,7 @@ Pytest configuration and shared fixtures for DocuElevate tests. import os import tempfile -from typing import Generator +from typing import Dict, Generator, Optional import pytest from fastapi.testclient import TestClient @@ -12,6 +12,37 @@ from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool +# Capture original environment variables before overriding with test defaults. +# This allows integration tests to detect when real API credentials are available +# (e.g., injected via GitHub Actions secrets) and run live API verification. +_EXTERNAL_API_ENV_KEYS = [ + "OPENAI_API_KEY", + "OPENAI_BASE_URL", + "AZURE_AI_KEY", + "AZURE_ENDPOINT", + "AZURE_REGION", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "S3_BUCKET_NAME", + "S3_FOLDER_PREFIX", + "DROPBOX_APP_KEY", + "DROPBOX_APP_SECRET", + "DROPBOX_REFRESH_TOKEN", + "ONEDRIVE_CLIENT_ID", + "ONEDRIVE_CLIENT_SECRET", + "ONEDRIVE_REFRESH_TOKEN", + "ONEDRIVE_TENANT_ID", + "ONEDRIVE_FOLDER_PATH", + "GOOGLE_DRIVE_CREDENTIALS_JSON", + "GOOGLE_DRIVE_FOLDER_ID", + "AUTHENTIK_CLIENT_ID", + "AUTHENTIK_CLIENT_SECRET", + "AUTHENTIK_CONFIG_URL", + "SESSION_SECRET", +] +_PLACEHOLDER_VALUES = {"test-key", "test", "", "NOT_SET"} +_original_env: Dict[str, Optional[str]] = {key: os.environ.get(key) for key in _EXTERNAL_API_ENV_KEYS} + # Set test environment variables before importing app os.environ["DATABASE_URL"] = "sqlite:///:memory:" os.environ["REDIS_URL"] = "redis://localhost:6379/1" @@ -167,6 +198,26 @@ def mock_azure_response(): return {"analyzeResult": {"content": "Test document content extracted by OCR", "pages": [{"pageNumber": 1}]}} +def has_real_env(*keys: str) -> bool: + """Check if real (non-placeholder) environment variables were set before test overrides. + + Returns True only if ALL specified keys had non-placeholder values in the + original environment. Used by integration tests to decide whether to skip + when real credentials are unavailable. + """ + for key in keys: + value = _original_env.get(key) + if value is None or value in _PLACEHOLDER_VALUES: + return False + return True + + +@pytest.fixture(scope="session") +def original_env() -> Dict[str, Optional[str]]: + """Provide access to the original environment variables captured before test overrides.""" + return dict(_original_env) + + # Markers for categorizing tests def pytest_configure(config): """Configure custom pytest markers.""" diff --git a/tests/fixtures_integration.py b/tests/fixtures_integration.py index 94704eab..f1c534e9 100644 --- a/tests/fixtures_integration.py +++ b/tests/fixtures_integration.py @@ -11,6 +11,7 @@ This module provides fixtures for spinning up real infrastructure components: These tests exercise the full application stack end-to-end. """ + import os import time import pytest @@ -24,21 +25,23 @@ from testcontainers.postgres import PostgresContainer from testcontainers.redis import RedisContainer from testcontainers.minio import MinioContainer +_TEST_CREDENTIAL = "testpass" # noqa: S105 + @pytest.fixture(scope="session") def postgres_container() -> Generator: """ Start a real PostgreSQL database container for testing. - + This replaces the in-memory SQLite used in unit tests. """ with PostgresContainer("postgres:15-alpine") as postgres: # Wait for PostgreSQL to be ready time.sleep(2) - + # Set environment variable for the app to use os.environ["DATABASE_URL"] = postgres.get_connection_url() - + yield { "container": postgres, "url": postgres.get_connection_url(), @@ -54,23 +57,23 @@ def postgres_container() -> Generator: def redis_container() -> Generator: """ Start a real Redis container for Celery broker/backend. - + This provides actual message queueing and task result storage. """ with RedisContainer("redis:7-alpine") as redis: # Wait for Redis to be ready time.sleep(2) - + # Build Redis URL manually host = redis.get_container_host_ip() port = redis.get_exposed_port(6379) redis_url = f"redis://{host}:{port}/0" - + # Set environment variables for the app os.environ["REDIS_URL"] = redis_url os.environ["CELERY_BROKER_URL"] = redis_url os.environ["CELERY_RESULT_BACKEND"] = redis_url - + yield { "container": redis, "url": redis_url, @@ -83,32 +86,30 @@ def redis_container() -> Generator: def gotenberg_container() -> Generator: """ Start a real Gotenberg container for PDF conversion. - + This provides actual document conversion capabilities. """ container = DockerContainer("gotenberg/gotenberg:8") container.with_exposed_ports(3000) - container.with_command( - "gotenberg --chromium-disable-javascript=false --chromium-allow-list=file:///.*" - ) - + container.with_command("gotenberg --chromium-disable-javascript=false --chromium-allow-list=file:///.*") + container.start() time.sleep(5) # Gotenberg takes a bit longer to start - + host = container.get_container_host_ip() port = container.get_exposed_port(3000) gotenberg_url = f"http://{host}:{port}" - + # Set environment variable os.environ["GOTENBERG_URL"] = gotenberg_url - + yield { "container": container, "url": gotenberg_url, "host": host, "port": port, } - + container.stop() @@ -121,23 +122,23 @@ def webdav_container() -> Generator: container.with_exposed_ports(80) container.with_env("AUTH_TYPE", "Basic") container.with_env("USERNAME", "testuser") - container.with_env("PASSWORD", "testpass") - + container.with_env("PASSWORD", _TEST_CREDENTIAL) + container.start() time.sleep(2) - + host = container.get_container_host_ip() port = container.get_exposed_port(80) - + yield { "container": container, "url": f"http://{host}:{port}", "host": host, "port": port, "username": "testuser", - "password": "testpass", + "password": _TEST_CREDENTIAL, } - + container.stop() @@ -145,29 +146,29 @@ def webdav_container() -> Generator: def sftp_container() -> Generator: """ Start a real SFTP server for upload testing. - + Uses atmoz/sftp which provides a simple SSH/SFTP server. """ container = DockerContainer("atmoz/sftp:latest") container.with_exposed_ports(22) # Create user: username:password:uid:gid:directory - container.with_command("testuser:testpass:1001:1001:upload") - + container.with_command(f"testuser:{_TEST_CREDENTIAL}:1001:1001:upload") + container.start() time.sleep(3) # SFTP server needs time to initialize - + host = container.get_container_host_ip() port = container.get_exposed_port(22) - + yield { "container": container, "host": host, "port": port, "username": "testuser", - "password": "testpass", + "password": _TEST_CREDENTIAL, "folder": "/home/testuser/upload", } - + container.stop() @@ -175,16 +176,16 @@ def sftp_container() -> Generator: def minio_container() -> Generator: """ Start a real MinIO container (S3-compatible storage). - + MinIO provides S3-compatible API for testing S3 uploads. """ with MinioContainer() as minio: time.sleep(2) - + # MinIO uses random credentials, get them access_key = minio.access_key secret_key = minio.secret_key - + yield { "container": minio, "url": minio.get_config()["endpoint"], @@ -198,31 +199,31 @@ def minio_container() -> Generator: def ftp_container() -> Generator: """ Start a real FTP server for upload testing. - + Uses stilliard/pure-ftpd which provides a simple FTP server. """ container = DockerContainer("stilliard/pure-ftpd:latest") container.with_exposed_ports(21, 30000, 30001, 30002, 30003, 30004) container.with_env("PUBLICHOST", "localhost") container.with_env("FTP_USER_NAME", "testuser") - container.with_env("FTP_USER_PASS", "testpass") + container.with_env("FTP_USER_PASS", _TEST_CREDENTIAL) container.with_env("FTP_USER_HOME", "/home/testuser") - + container.start() time.sleep(3) - + host = container.get_container_host_ip() port = container.get_exposed_port(21) - + yield { "container": container, "host": host, "port": port, "username": "testuser", - "password": "testpass", + "password": _TEST_CREDENTIAL, "folder": "/", } - + container.stop() @@ -237,7 +238,7 @@ def full_infrastructure( ): """ Combined fixture that provides all infrastructure components. - + Use this fixture when you need the complete application stack. """ return { @@ -254,11 +255,11 @@ def full_infrastructure( def celery_app(redis_container): """ Create a Celery app configured to use the real Redis container. - + This allows testing actual task queueing and execution. """ from app.celery_app import celery - + # Update Celery configuration to use test Redis celery.conf.update( broker_url=redis_container["url"], @@ -267,7 +268,7 @@ def celery_app(redis_container): task_eager_propagates=True, result_expires=3600, ) - + return celery @@ -275,11 +276,11 @@ def celery_app(redis_container): def celery_worker(celery_app, redis_container): """ Start a real Celery worker for processing tasks. - + This runs tasks asynchronously like in production. """ from celery.contrib.testing.worker import start_worker - + # Start worker in test mode with start_worker( celery_app, @@ -294,23 +295,23 @@ def celery_worker(celery_app, redis_container): def db_session_real(postgres_container): """ Create a database session using the real PostgreSQL container. - + This replaces the in-memory SQLite session for integration tests. """ from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from app.database import Base - + # Create engine using PostgreSQL container engine = create_engine(postgres_container["url"]) - + # Create all tables Base.metadata.create_all(bind=engine) - + # Create session SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) session = SessionLocal() - + try: yield session finally: diff --git a/tests/test_auth.py b/tests/test_auth.py index aaa6f389..76bb9a4e 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -9,6 +9,8 @@ from starlette.responses import RedirectResponse from app.auth import get_current_user, get_gravatar_url, require_login +_TEST_CREDENTIAL = "test" # noqa: S105 + @pytest.mark.unit class TestGetCurrentUser: @@ -217,7 +219,7 @@ class TestAuthEndpoints: def test_auth_post_not_available_when_auth_disabled(self, client): """Test that POST /auth returns 404 when auth is disabled.""" - response = client.post("/auth", data={"username": "admin", "password": "test"}) + response = client.post("/auth", data={"username": "admin", "password": _TEST_CREDENTIAL}) assert response.status_code == 404 diff --git a/tests/test_e2e_full_stack.py b/tests/test_e2e_full_stack.py index 3bf673f0..842cdb7c 100644 --- a/tests/test_e2e_full_stack.py +++ b/tests/test_e2e_full_stack.py @@ -4,6 +4,7 @@ End-to-end integration tests using real infrastructure. These tests spin up actual services (PostgreSQL, Redis, Gotenberg, WebDAV, SFTP, MinIO) and test the complete application workflow from API request to file upload. """ + import os import time import pytest @@ -13,6 +14,13 @@ from unittest.mock import patch # Import testcontainers requirement pytest.importorskip("testcontainers", reason="testcontainers not installed") +try: + import psycopg2 # noqa: F401 + + _has_psycopg2 = True +except ModuleNotFoundError: + _has_psycopg2 = False + from tests.fixtures_integration import ( postgres_container, redis_container, @@ -26,6 +34,8 @@ from tests.fixtures_integration import ( db_session_real, ) +_TEST_CREDENTIAL = "pass" # noqa: S105 + @pytest.mark.integration @pytest.mark.requires_docker @@ -33,7 +43,7 @@ from tests.fixtures_integration import ( class TestEndToEndWithRedis: """ End-to-end tests with real Redis and Celery workers. - + These tests verify the complete task queueing and execution workflow. """ @@ -47,14 +57,16 @@ class TestEndToEndWithRedis: ): """ Test complete workflow: Queue task in Redis → Celery worker executes → Upload to WebDAV. - + This is the closest to production - actual message queueing and async execution. """ from app.tasks.upload_to_webdav import upload_to_webdav - - with patch("app.tasks.upload_to_webdav.settings") as mock_settings, \ - patch("app.tasks.upload_to_webdav.log_task_progress"): - + + with ( + patch("app.tasks.upload_to_webdav.settings") as mock_settings, + patch("app.tasks.upload_to_webdav.log_task_progress"), + ): + # Configure to use real WebDAV server mock_settings.webdav_url = webdav_container["url"] + "/" mock_settings.webdav_username = webdav_container["username"] @@ -62,10 +74,10 @@ class TestEndToEndWithRedis: mock_settings.webdav_folder = "" mock_settings.webdav_verify_ssl = False mock_settings.http_request_timeout = 30 - + # Queue the task (it goes to Redis) result = upload_to_webdav.delay(sample_text_file, file_id=1) - + # Wait for task to complete (worker picks it up from Redis) timeout = 30 start_time = time.time() @@ -73,26 +85,24 @@ class TestEndToEndWithRedis: if time.time() - start_time > timeout: pytest.fail(f"Task did not complete within {timeout} seconds") time.sleep(0.5) - + # Get the result task_result = result.get(timeout=10) - + # Verify task completed successfully assert task_result["status"] == "Completed" assert task_result["file"] == sample_text_file - + # Verify file was actually uploaded to WebDAV server filename = os.path.basename(sample_text_file) file_url = f"{webdav_container['url']}/{filename}" - + response = requests.get( - file_url, - auth=(webdav_container["username"], webdav_container["password"]), - timeout=5 + file_url, auth=(webdav_container["username"], webdav_container["password"]), timeout=5 ) - + assert response.status_code == 200 - + # Verify content matches with open(sample_text_file, "rb") as f: assert response.content == f.read() @@ -104,33 +114,30 @@ class TestEndToEndWithRedis: ): """ Test that tasks are properly queued in Redis. - + This verifies the Redis broker is working correctly. """ from app.tasks.upload_to_webdav import upload_to_webdav import redis - + # Connect to Redis directly r = redis.from_url(redis_container["url"]) - + # Check Redis is accessible assert r.ping() - + # Get current queue length initial_queue_length = r.llen("celery") - + # Queue a task (don't execute, just verify queueing) with patch("app.tasks.upload_to_webdav.settings") as mock_settings: mock_settings.webdav_url = "http://test.com" mock_settings.webdav_username = "user" - mock_settings.webdav_password = "pass" - + mock_settings.webdav_password = _TEST_CREDENTIAL + # This will queue the task in Redis - result = upload_to_webdav.apply_async( - args=["/tmp/test.txt"], - kwargs={"file_id": 1} - ) - + result = upload_to_webdav.apply_async(args=["/tmp/test.txt"], kwargs={"file_id": 1}) + # Verify task ID was generated assert result.id is not None @@ -144,67 +151,64 @@ class TestEndToEndWithRedis: ): """ Test multiple tasks executing in parallel through Redis/Celery. - + This tests concurrent task processing. """ from app.tasks.upload_to_webdav import upload_to_webdav - + # Create multiple test files files = [] for i in range(5): test_file = tmp_path / f"test_{i}.txt" test_file.write_text(f"Test file {i}") files.append(str(test_file)) - - with patch("app.tasks.upload_to_webdav.settings") as mock_settings, \ - patch("app.tasks.upload_to_webdav.log_task_progress"): - + + with ( + patch("app.tasks.upload_to_webdav.settings") as mock_settings, + patch("app.tasks.upload_to_webdav.log_task_progress"), + ): + mock_settings.webdav_url = webdav_container["url"] + "/" mock_settings.webdav_username = webdav_container["username"] mock_settings.webdav_password = webdav_container["password"] mock_settings.webdav_folder = "parallel-test" mock_settings.webdav_verify_ssl = False mock_settings.http_request_timeout = 30 - + # Create folder on WebDAV server folder_url = f"{webdav_container['url']}/parallel-test" requests.request( - "MKCOL", - folder_url, - auth=(webdav_container["username"], webdav_container["password"]), - timeout=5 + "MKCOL", folder_url, auth=(webdav_container["username"], webdav_container["password"]), timeout=5 ) - + # Queue all tasks results = [] for idx, file_path in enumerate(files): result = upload_to_webdav.delay(file_path, file_id=idx + 100) results.append((result, file_path)) - + # Wait for all tasks to complete timeout = 60 start_time = time.time() all_ready = False - + while not all_ready: if time.time() - start_time > timeout: pytest.fail("Tasks did not complete within timeout") - + all_ready = all(r.ready() for r, _ in results) time.sleep(0.5) - + # Verify all tasks succeeded for result, file_path in results: task_result = result.get(timeout=5) assert task_result["status"] == "Completed" - + # Verify file on server filename = os.path.basename(file_path) file_url = f"{webdav_container['url']}/parallel-test/{filename}" response = requests.get( - file_url, - auth=(webdav_container["username"], webdav_container["password"]), - timeout=5 + file_url, auth=(webdav_container["username"], webdav_container["password"]), timeout=5 ) assert response.status_code == 200 @@ -217,37 +221,39 @@ class TestEndToEndWithRedis: ): """ Test that tasks retry on failure using Redis. - + This verifies the retry mechanism works with real broker. """ from app.tasks.upload_to_webdav import upload_to_webdav - - with patch("app.tasks.upload_to_webdav.settings") as mock_settings, \ - patch("app.tasks.upload_to_webdav.log_task_progress"), \ - patch("app.tasks.upload_to_webdav.requests.put") as mock_put: - + + with ( + patch("app.tasks.upload_to_webdav.settings") as mock_settings, + patch("app.tasks.upload_to_webdav.log_task_progress"), + patch("app.tasks.upload_to_webdav.requests.put") as mock_put, + ): + mock_settings.webdav_url = "http://test.com/" mock_settings.webdav_username = "user" - mock_settings.webdav_password = "pass" + mock_settings.webdav_password = _TEST_CREDENTIAL mock_settings.webdav_folder = "" mock_settings.webdav_verify_ssl = False mock_settings.http_request_timeout = 30 - + # First attempt fails with 500 mock_response_fail = requests.Response() mock_response_fail.status_code = 500 mock_response_fail._content = b"Server Error" - + # Second attempt succeeds mock_response_success = requests.Response() mock_response_success.status_code = 201 - + # Configure mock to fail once, then succeed mock_put.side_effect = [mock_response_fail, mock_response_success] - + # Queue task result = upload_to_webdav.delay(sample_text_file, file_id=1) - + # Wait for completion (including retry) timeout = 30 start_time = time.time() @@ -263,7 +269,7 @@ class TestEndToEndWithRedis: class TestFullInfrastructure: """ Tests using the complete infrastructure stack. - + PostgreSQL + Redis + Gotenberg + Upload targets (WebDAV/SFTP/MinIO) """ @@ -272,38 +278,43 @@ class TestFullInfrastructure: Verify all infrastructure components are running. """ infra = full_infrastructure - + # Check PostgreSQL assert infra["postgres"]["url"] is not None assert "postgresql" in infra["postgres"]["url"] - + # Check Redis assert infra["redis"]["url"] is not None import redis + r = redis.from_url(infra["redis"]["url"]) assert r.ping() - + # Check Gotenberg assert infra["gotenberg"]["url"] is not None response = requests.get(f"{infra['gotenberg']['url']}/health", timeout=5) assert response.status_code == 200 - + # Check WebDAV assert infra["webdav"]["url"] is not None - + # Check SFTP assert infra["sftp"]["host"] is not None assert infra["sftp"]["port"] is not None - + # Check MinIO assert infra["minio"]["access_key"] is not None + @pytest.mark.skipif( + not _has_psycopg2, + reason="psycopg2 not installed", + ) def test_database_with_real_postgres(self, postgres_container, db_session_real): """ Test database operations with real PostgreSQL instead of SQLite. """ from app.models import FileRecord - + # Create a file record file_record = FileRecord( filename="test.pdf", @@ -311,13 +322,13 @@ class TestFullInfrastructure: file_size=1024, mime_type="application/pdf", ) - + db_session_real.add(file_record) db_session_real.commit() - + # Verify it was saved assert file_record.id is not None - + # Query it back queried = db_session_real.query(FileRecord).filter_by(filename="test.pdf").first() assert queried is not None @@ -333,26 +344,28 @@ class TestFullInfrastructure: ): """ Test uploading to multiple targets in parallel (WebDAV + SFTP). - + This simulates the send_to_all_destinations workflow. """ from app.tasks.upload_to_webdav import upload_to_webdav - + infra = full_infrastructure - - with patch("app.tasks.upload_to_webdav.settings") as mock_settings, \ - patch("app.tasks.upload_to_webdav.log_task_progress"): - + + with ( + patch("app.tasks.upload_to_webdav.settings") as mock_settings, + patch("app.tasks.upload_to_webdav.log_task_progress"), + ): + mock_settings.webdav_url = infra["webdav"]["url"] + "/" mock_settings.webdav_username = infra["webdav"]["username"] mock_settings.webdav_password = infra["webdav"]["password"] mock_settings.webdav_folder = "" mock_settings.webdav_verify_ssl = False mock_settings.http_request_timeout = 30 - + # Upload to WebDAV webdav_result = upload_to_webdav.delay(sample_text_file, file_id=1) - + # Wait for completion timeout = 30 start_time = time.time() @@ -360,25 +373,23 @@ class TestFullInfrastructure: if time.time() - start_time > timeout: pytest.fail("Task timeout") time.sleep(0.5) - + # Verify WebDAV upload result = webdav_result.get(timeout=10) assert result["status"] == "Completed" - + # Verify file on WebDAV server filename = os.path.basename(sample_text_file) file_url = f"{infra['webdav']['url']}/{filename}" response = requests.get( - file_url, - auth=(infra["webdav"]["username"], infra["webdav"]["password"]), - timeout=5 + file_url, auth=(infra["webdav"]["username"], infra["webdav"]["password"]), timeout=5 ) assert response.status_code == 200 def test_gotenberg_pdf_conversion(self, gotenberg_container, tmp_path): """ Test PDF conversion using real Gotenberg service. - + This verifies document processing capabilities. """ # Create a simple HTML file @@ -390,16 +401,14 @@ class TestFullInfrastructure:
This is a test document.