feat(test): add Phase 5b integration and E2E tests

Add comprehensive integration and end-to-end test suites:
- Integration tests for API flows (authorization, token, verification)
- Integration tests for middleware chain and security headers
- Integration tests for domain verification services
- E2E tests for complete authentication flows
- E2E tests for error scenarios and edge cases
- Shared test fixtures and utilities in conftest.py
- Rename Dockerfile to Containerfile for Podman compatibility

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-21 22:22:04 -07:00
parent 01dcaba86b
commit e1f79af347
19 changed files with 4387 additions and 0 deletions

View File

@@ -1,10 +1,23 @@
"""
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)
@@ -38,3 +51,675 @@ def reset_config_before_test(monkeypatch):
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="<html><body></body></html>")
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 = '''
<html>
<body>
<a href="mailto:test@example.com" rel="me">Email</a>
</body>
</html>
'''
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": "<script>alert('xss')</script>",
"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"<html><body>Test</body></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_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'''
<!DOCTYPE html>
<html>
<head><title>Test App</title></head>
<body>
<div class="h-app">
<h1 class="p-name">Example Application</h1>
<img class="u-logo" src="https://app.example.com/logo.png" alt="Logo">
<a class="u-url" href="https://app.example.com">Home</a>
</div>
</body>
</html>
'''
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]
}