""" Pytest configuration and shared fixtures. This module provides comprehensive test fixtures for Phase 5b integration and E2E testing. Fixtures are organized by category for maintainability. """ import os import tempfile from pathlib import Path from typing import Any, Generator from unittest.mock import MagicMock, Mock, patch import pytest from fastapi.testclient import TestClient # ============================================================================= # ENVIRONMENT SETUP FIXTURES # ============================================================================= @pytest.fixture(scope="session", autouse=True) def setup_test_config(): """ Setup test configuration before any tests run. This ensures required environment variables are set for test execution. """ # Set required configuration os.environ.setdefault("GONDULF_SECRET_KEY", "test-secret-key-for-testing-only-32chars") os.environ.setdefault("GONDULF_BASE_URL", "http://localhost:8000") os.environ.setdefault("GONDULF_DEBUG", "true") os.environ.setdefault("GONDULF_DATABASE_URL", "sqlite:///:memory:") @pytest.fixture(autouse=True) def reset_config_before_test(monkeypatch): """ Reset configuration before each test. This prevents config from one test affecting another test. """ # Clear all GONDULF_ environment variables gondulf_vars = [key for key in os.environ.keys() if key.startswith("GONDULF_")] for var in gondulf_vars: monkeypatch.delenv(var, raising=False) # Re-set required test configuration monkeypatch.setenv("GONDULF_SECRET_KEY", "test-secret-key-for-testing-only-32chars") monkeypatch.setenv("GONDULF_BASE_URL", "http://localhost:8000") monkeypatch.setenv("GONDULF_DEBUG", "true") monkeypatch.setenv("GONDULF_DATABASE_URL", "sqlite:///:memory:") # ============================================================================= # DATABASE FIXTURES # ============================================================================= @pytest.fixture def test_db_path(tmp_path) -> Path: """Create a temporary database path.""" return tmp_path / "test.db" @pytest.fixture def test_database(test_db_path): """ Create and initialize a test database. Yields: Database: Initialized database instance with tables created """ from gondulf.database.connection import Database db = Database(f"sqlite:///{test_db_path}") db.ensure_database_directory() db.run_migrations() yield db @pytest.fixture def configured_test_app(monkeypatch, test_db_path): """ Create a fully configured FastAPI test app with temporary database. This fixture handles all environment configuration and creates a fresh app instance for each test. """ # Set required environment variables monkeypatch.setenv("GONDULF_SECRET_KEY", "a" * 32) monkeypatch.setenv("GONDULF_BASE_URL", "https://auth.example.com") monkeypatch.setenv("GONDULF_DATABASE_URL", f"sqlite:///{test_db_path}") monkeypatch.setenv("GONDULF_DEBUG", "true") # Import after environment is configured from gondulf.main import app yield app @pytest.fixture def test_client(configured_test_app) -> Generator[TestClient, None, None]: """ Create a TestClient with properly configured app. Yields: TestClient: FastAPI test client with startup events run """ with TestClient(configured_test_app) as client: yield client # ============================================================================= # CODE STORAGE FIXTURES # ============================================================================= @pytest.fixture def test_code_storage(): """ Create a test code storage instance. Returns: CodeStore: Fresh code storage for testing """ from gondulf.storage import CodeStore return CodeStore(ttl_seconds=600) @pytest.fixture def valid_auth_code(test_code_storage) -> tuple[str, dict]: """ Create a valid authorization code with metadata. Args: test_code_storage: Code storage fixture Returns: Tuple of (code, metadata) """ code = "test_auth_code_12345" metadata = { "client_id": "https://client.example.com", "redirect_uri": "https://client.example.com/callback", "state": "xyz123", "me": "https://user.example.com", "scope": "", "code_challenge": "abc123def456", "code_challenge_method": "S256", "created_at": 1234567890, "expires_at": 1234568490, "used": False } test_code_storage.store(f"authz:{code}", metadata) return code, metadata @pytest.fixture def expired_auth_code(test_code_storage) -> tuple[str, dict]: """ Create an expired authorization code. Returns: Tuple of (code, metadata) where the code is expired """ import time code = "expired_auth_code_12345" metadata = { "client_id": "https://client.example.com", "redirect_uri": "https://client.example.com/callback", "state": "xyz123", "me": "https://user.example.com", "scope": "", "code_challenge": "abc123def456", "code_challenge_method": "S256", "created_at": 1000000000, "expires_at": 1000000001, # Expired long ago "used": False } # Store with 0 TTL to make it immediately expired test_code_storage.store(f"authz:{code}", metadata, ttl=0) return code, metadata @pytest.fixture def used_auth_code(test_code_storage) -> tuple[str, dict]: """ Create an already-used authorization code. Returns: Tuple of (code, metadata) where the code is marked as used """ code = "used_auth_code_12345" metadata = { "client_id": "https://client.example.com", "redirect_uri": "https://client.example.com/callback", "state": "xyz123", "me": "https://user.example.com", "scope": "", "code_challenge": "abc123def456", "code_challenge_method": "S256", "created_at": 1234567890, "expires_at": 1234568490, "used": True # Already used } test_code_storage.store(f"authz:{code}", metadata) return code, metadata # ============================================================================= # SERVICE FIXTURES # ============================================================================= @pytest.fixture def test_token_service(test_database): """ Create a test token service with database. Args: test_database: Database fixture Returns: TokenService: Token service configured for testing """ from gondulf.services.token_service import TokenService return TokenService( database=test_database, token_length=32, token_ttl=3600 ) @pytest.fixture def mock_dns_service(): """ Create a mock DNS service. Returns: Mock: Mocked DNSService for testing """ mock = Mock() mock.verify_txt_record = Mock(return_value=True) mock.resolve_txt = Mock(return_value=["gondulf-verify-domain"]) return mock @pytest.fixture def mock_dns_service_failure(): """ Create a mock DNS service that returns failures. Returns: Mock: Mocked DNSService that simulates DNS failures """ mock = Mock() mock.verify_txt_record = Mock(return_value=False) mock.resolve_txt = Mock(return_value=[]) return mock @pytest.fixture def mock_email_service(): """ Create a mock email service. Returns: Mock: Mocked EmailService for testing """ mock = Mock() mock.send_verification_code = Mock(return_value=None) mock.messages_sent = [] def track_send(email, code, domain): mock.messages_sent.append({ "email": email, "code": code, "domain": domain }) mock.send_verification_code.side_effect = track_send return mock @pytest.fixture def mock_html_fetcher(): """ Create a mock HTML fetcher service. Returns: Mock: Mocked HTMLFetcherService """ mock = Mock() mock.fetch = Mock(return_value="") return mock @pytest.fixture def mock_html_fetcher_with_email(): """ Create a mock HTML fetcher that returns a page with rel=me email. Returns: Mock: Mocked HTMLFetcherService with email in page """ mock = Mock() html = ''' Email ''' mock.fetch = Mock(return_value=html) return mock @pytest.fixture def mock_happ_parser(): """ Create a mock h-app parser. Returns: Mock: Mocked HAppParser """ from gondulf.services.happ_parser import ClientMetadata mock = Mock() mock.fetch_and_parse = Mock(return_value=ClientMetadata( name="Test Application", url="https://app.example.com", logo="https://app.example.com/logo.png" )) return mock @pytest.fixture def mock_rate_limiter(): """ Create a mock rate limiter that always allows requests. Returns: Mock: Mocked RateLimiter """ mock = Mock() mock.check_rate_limit = Mock(return_value=True) mock.record_attempt = Mock() mock.reset = Mock() return mock @pytest.fixture def mock_rate_limiter_exceeded(): """ Create a mock rate limiter that blocks all requests. Returns: Mock: Mocked RateLimiter that simulates rate limit exceeded """ mock = Mock() mock.check_rate_limit = Mock(return_value=False) mock.record_attempt = Mock() return mock # ============================================================================= # DOMAIN VERIFICATION FIXTURES # ============================================================================= @pytest.fixture def verification_service(mock_dns_service, mock_email_service, mock_html_fetcher_with_email, test_code_storage): """ Create a domain verification service with all mocked dependencies. Args: mock_dns_service: Mock DNS service mock_email_service: Mock email service mock_html_fetcher_with_email: Mock HTML fetcher with email test_code_storage: Code storage fixture Returns: DomainVerificationService: Service configured with mocks """ from gondulf.services.domain_verification import DomainVerificationService from gondulf.services.relme_parser import RelMeParser return DomainVerificationService( dns_service=mock_dns_service, email_service=mock_email_service, code_storage=test_code_storage, html_fetcher=mock_html_fetcher_with_email, relme_parser=RelMeParser() ) @pytest.fixture def verification_service_dns_failure(mock_dns_service_failure, mock_email_service, mock_html_fetcher_with_email, test_code_storage): """ Create a verification service where DNS verification fails. Returns: DomainVerificationService: Service with failing DNS """ from gondulf.services.domain_verification import DomainVerificationService from gondulf.services.relme_parser import RelMeParser return DomainVerificationService( dns_service=mock_dns_service_failure, email_service=mock_email_service, code_storage=test_code_storage, html_fetcher=mock_html_fetcher_with_email, relme_parser=RelMeParser() ) # ============================================================================= # CLIENT CONFIGURATION FIXTURES # ============================================================================= @pytest.fixture def simple_client() -> dict[str, str]: """ Basic IndieAuth client configuration. Returns: Dict with client_id and redirect_uri """ return { "client_id": "https://app.example.com", "redirect_uri": "https://app.example.com/callback", } @pytest.fixture def client_with_metadata() -> dict[str, str]: """ Client configuration that would have h-app metadata. Returns: Dict with client configuration """ return { "client_id": "https://rich-app.example.com", "redirect_uri": "https://rich-app.example.com/auth/callback", "expected_name": "Rich Application", "expected_logo": "https://rich-app.example.com/logo.png" } @pytest.fixture def malicious_client() -> dict[str, Any]: """ Client with potentially malicious configuration for security testing. Returns: Dict with malicious inputs """ return { "client_id": "https://evil.example.com", "redirect_uri": "https://evil.example.com/steal", "state": "", "me": "javascript:alert('xss')" } # ============================================================================= # AUTHORIZATION REQUEST FIXTURES # ============================================================================= @pytest.fixture def valid_auth_request() -> dict[str, str]: """ Complete valid authorization request parameters. Returns: Dict with all required authorization parameters """ return { "response_type": "code", "client_id": "https://app.example.com", "redirect_uri": "https://app.example.com/callback", "state": "random_state_12345", "code_challenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", "code_challenge_method": "S256", "me": "https://user.example.com", "scope": "" } @pytest.fixture def auth_request_missing_client_id(valid_auth_request) -> dict[str, str]: """Authorization request missing client_id.""" request = valid_auth_request.copy() del request["client_id"] return request @pytest.fixture def auth_request_missing_redirect_uri(valid_auth_request) -> dict[str, str]: """Authorization request missing redirect_uri.""" request = valid_auth_request.copy() del request["redirect_uri"] return request @pytest.fixture def auth_request_invalid_response_type(valid_auth_request) -> dict[str, str]: """Authorization request with invalid response_type.""" request = valid_auth_request.copy() request["response_type"] = "token" # Invalid - we only support "code" return request @pytest.fixture def auth_request_missing_pkce(valid_auth_request) -> dict[str, str]: """Authorization request missing PKCE code_challenge.""" request = valid_auth_request.copy() del request["code_challenge"] return request # ============================================================================= # TOKEN FIXTURES # ============================================================================= @pytest.fixture def valid_token(test_token_service) -> tuple[str, dict]: """ Generate a valid access token. Args: test_token_service: Token service fixture Returns: Tuple of (token, metadata) """ token = test_token_service.generate_token( me="https://user.example.com", client_id="https://app.example.com", scope="" ) metadata = test_token_service.validate_token(token) return token, metadata @pytest.fixture def expired_token_metadata() -> dict[str, Any]: """ Metadata representing an expired token (for manual database insertion). Returns: Dict with expired token metadata """ from datetime import datetime, timedelta import hashlib token = "expired_test_token_12345" return { "token": token, "token_hash": hashlib.sha256(token.encode()).hexdigest(), "me": "https://user.example.com", "client_id": "https://app.example.com", "scope": "", "issued_at": datetime.utcnow() - timedelta(hours=2), "expires_at": datetime.utcnow() - timedelta(hours=1), # Already expired "revoked": False } # ============================================================================= # HTTP MOCKING FIXTURES (for urllib) # ============================================================================= @pytest.fixture def mock_urlopen(): """ Mock urllib.request.urlopen for HTTP request testing. Yields: MagicMock: Mock that can be configured per test """ with patch('gondulf.services.html_fetcher.urllib.request.urlopen') as mock: yield mock @pytest.fixture def mock_urlopen_success(mock_urlopen): """ Configure mock_urlopen to return a successful response. Args: mock_urlopen: Base mock fixture Returns: MagicMock: Configured mock """ mock_response = MagicMock() mock_response.read.return_value = b"Test" mock_response.status = 200 mock_response.__enter__ = Mock(return_value=mock_response) mock_response.__exit__ = Mock(return_value=False) mock_urlopen.return_value = mock_response return mock_urlopen @pytest.fixture def mock_urlopen_with_happ(mock_urlopen): """ Configure mock_urlopen to return a page with h-app metadata. Args: mock_urlopen: Base mock fixture Returns: MagicMock: Configured mock """ html = b''' Test App

Example Application

Home
''' mock_response = MagicMock() mock_response.read.return_value = html mock_response.status = 200 mock_response.__enter__ = Mock(return_value=mock_response) mock_response.__exit__ = Mock(return_value=False) mock_urlopen.return_value = mock_response return mock_urlopen @pytest.fixture def mock_urlopen_timeout(mock_urlopen): """ Configure mock_urlopen to simulate a timeout. Args: mock_urlopen: Base mock fixture Returns: MagicMock: Configured mock that raises timeout """ import urllib.error mock_urlopen.side_effect = urllib.error.URLError("Connection timed out") return mock_urlopen # ============================================================================= # HELPER FUNCTIONS # ============================================================================= def create_app_with_overrides(monkeypatch, tmp_path, **overrides): """ Helper to create a test app with custom dependency overrides. Args: monkeypatch: pytest monkeypatch fixture tmp_path: temporary path for database **overrides: Dependency override functions Returns: tuple: (app, client, overrides_applied) """ db_path = tmp_path / "test.db" monkeypatch.setenv("GONDULF_SECRET_KEY", "a" * 32) monkeypatch.setenv("GONDULF_BASE_URL", "https://auth.example.com") monkeypatch.setenv("GONDULF_DATABASE_URL", f"sqlite:///{db_path}") monkeypatch.setenv("GONDULF_DEBUG", "true") from gondulf.main import app for dependency, override in overrides.items(): app.dependency_overrides[dependency] = override return app def extract_code_from_redirect(location: str) -> str: """ Extract authorization code from redirect URL. Args: location: Redirect URL with code parameter Returns: str: Authorization code """ from urllib.parse import parse_qs, urlparse parsed = urlparse(location) params = parse_qs(parsed.query) return params.get("code", [None])[0] def extract_error_from_redirect(location: str) -> dict[str, str]: """ Extract error parameters from redirect URL. Args: location: Redirect URL with error parameters Returns: Dict with error and error_description """ from urllib.parse import parse_qs, urlparse parsed = urlparse(location) params = parse_qs(parsed.query) return { "error": params.get("error", [None])[0], "error_description": params.get("error_description", [None])[0] }