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:
1
tests/integration/services/__init__.py
Normal file
1
tests/integration/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Service integration tests for Gondulf IndieAuth server."""
|
||||
190
tests/integration/services/test_domain_verification.py
Normal file
190
tests/integration/services/test_domain_verification.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""
|
||||
Integration tests for domain verification service.
|
||||
|
||||
Tests the complete domain verification flow with mocked external services.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock
|
||||
|
||||
|
||||
class TestDomainVerificationIntegration:
|
||||
"""Integration tests for DomainVerificationService."""
|
||||
|
||||
def test_complete_verification_flow(self, verification_service, mock_email_service):
|
||||
"""Test complete DNS + email verification flow."""
|
||||
# Start verification
|
||||
result = verification_service.start_verification(
|
||||
domain="example.com",
|
||||
me_url="https://example.com/"
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert "email" in result
|
||||
assert result["verification_method"] == "email"
|
||||
|
||||
# Email should have been sent
|
||||
assert len(mock_email_service.messages_sent) == 1
|
||||
sent = mock_email_service.messages_sent[0]
|
||||
assert sent["email"] == "test@example.com"
|
||||
assert sent["domain"] == "example.com"
|
||||
assert len(sent["code"]) == 6
|
||||
|
||||
def test_dns_failure_blocks_verification(self, verification_service_dns_failure):
|
||||
"""Test that DNS verification failure stops the process."""
|
||||
result = verification_service_dns_failure.start_verification(
|
||||
domain="example.com",
|
||||
me_url="https://example.com/"
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error"] == "dns_verification_failed"
|
||||
|
||||
def test_email_discovery_failure(self, mock_dns_service, mock_email_service, mock_html_fetcher, test_code_storage):
|
||||
"""Test verification fails when no email is discovered."""
|
||||
from gondulf.services.domain_verification import DomainVerificationService
|
||||
from gondulf.services.relme_parser import RelMeParser
|
||||
|
||||
# HTML fetcher returns page without email
|
||||
mock_html_fetcher.fetch = Mock(return_value="<html><body>No email here</body></html>")
|
||||
|
||||
service = DomainVerificationService(
|
||||
dns_service=mock_dns_service,
|
||||
email_service=mock_email_service,
|
||||
code_storage=test_code_storage,
|
||||
html_fetcher=mock_html_fetcher,
|
||||
relme_parser=RelMeParser()
|
||||
)
|
||||
|
||||
result = service.start_verification(
|
||||
domain="example.com",
|
||||
me_url="https://example.com/"
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error"] == "email_discovery_failed"
|
||||
|
||||
def test_code_verification_success(self, verification_service, test_code_storage):
|
||||
"""Test successful code verification."""
|
||||
# Start verification to generate code
|
||||
verification_service.start_verification(
|
||||
domain="example.com",
|
||||
me_url="https://example.com/"
|
||||
)
|
||||
|
||||
# Get the stored code
|
||||
stored_code = test_code_storage.get("email_verify:example.com")
|
||||
assert stored_code is not None
|
||||
|
||||
# Verify the code
|
||||
result = verification_service.verify_email_code(
|
||||
domain="example.com",
|
||||
code=stored_code
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["email"] == "test@example.com"
|
||||
|
||||
def test_code_verification_invalid_code(self, verification_service, test_code_storage):
|
||||
"""Test code verification fails with wrong code."""
|
||||
# Start verification
|
||||
verification_service.start_verification(
|
||||
domain="example.com",
|
||||
me_url="https://example.com/"
|
||||
)
|
||||
|
||||
# Try to verify with wrong code
|
||||
result = verification_service.verify_email_code(
|
||||
domain="example.com",
|
||||
code="000000"
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error"] == "invalid_code"
|
||||
|
||||
def test_code_single_use(self, verification_service, test_code_storage):
|
||||
"""Test verification code can only be used once."""
|
||||
# Start verification
|
||||
verification_service.start_verification(
|
||||
domain="example.com",
|
||||
me_url="https://example.com/"
|
||||
)
|
||||
|
||||
# Get the stored code
|
||||
stored_code = test_code_storage.get("email_verify:example.com")
|
||||
|
||||
# First verification should succeed
|
||||
result1 = verification_service.verify_email_code(
|
||||
domain="example.com",
|
||||
code=stored_code
|
||||
)
|
||||
assert result1["success"] is True
|
||||
|
||||
# Second verification should fail
|
||||
result2 = verification_service.verify_email_code(
|
||||
domain="example.com",
|
||||
code=stored_code
|
||||
)
|
||||
assert result2["success"] is False
|
||||
|
||||
|
||||
class TestAuthorizationCodeGeneration:
|
||||
"""Integration tests for authorization code generation."""
|
||||
|
||||
def test_create_authorization_code(self, verification_service):
|
||||
"""Test authorization code creation stores metadata."""
|
||||
code = verification_service.create_authorization_code(
|
||||
client_id="https://app.example.com",
|
||||
redirect_uri="https://app.example.com/callback",
|
||||
state="test123",
|
||||
code_challenge="abc123",
|
||||
code_challenge_method="S256",
|
||||
scope="",
|
||||
me="https://user.example.com"
|
||||
)
|
||||
|
||||
assert code is not None
|
||||
assert len(code) > 20 # Should be a substantial code
|
||||
|
||||
def test_authorization_code_unique(self, verification_service):
|
||||
"""Test each authorization code is unique."""
|
||||
codes = set()
|
||||
for _ in range(100):
|
||||
code = verification_service.create_authorization_code(
|
||||
client_id="https://app.example.com",
|
||||
redirect_uri="https://app.example.com/callback",
|
||||
state="test123",
|
||||
code_challenge="abc123",
|
||||
code_challenge_method="S256",
|
||||
scope="",
|
||||
me="https://user.example.com"
|
||||
)
|
||||
codes.add(code)
|
||||
|
||||
# All 100 codes should be unique
|
||||
assert len(codes) == 100
|
||||
|
||||
def test_authorization_code_stored_with_metadata(self, verification_service, test_code_storage):
|
||||
"""Test authorization code metadata is stored correctly."""
|
||||
code = verification_service.create_authorization_code(
|
||||
client_id="https://app.example.com",
|
||||
redirect_uri="https://app.example.com/callback",
|
||||
state="test123",
|
||||
code_challenge="abc123",
|
||||
code_challenge_method="S256",
|
||||
scope="profile",
|
||||
me="https://user.example.com"
|
||||
)
|
||||
|
||||
# Retrieve stored metadata
|
||||
metadata = test_code_storage.get(f"authz:{code}")
|
||||
|
||||
assert metadata is not None
|
||||
assert metadata["client_id"] == "https://app.example.com"
|
||||
assert metadata["redirect_uri"] == "https://app.example.com/callback"
|
||||
assert metadata["state"] == "test123"
|
||||
assert metadata["code_challenge"] == "abc123"
|
||||
assert metadata["code_challenge_method"] == "S256"
|
||||
assert metadata["scope"] == "profile"
|
||||
assert metadata["me"] == "https://user.example.com"
|
||||
assert metadata["used"] is False
|
||||
170
tests/integration/services/test_happ_parser.py
Normal file
170
tests/integration/services/test_happ_parser.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
Integration tests for h-app parser service.
|
||||
|
||||
Tests client metadata fetching with mocked HTTP responses.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
|
||||
class TestHAppParserIntegration:
|
||||
"""Integration tests for h-app metadata parsing."""
|
||||
|
||||
@pytest.fixture
|
||||
def happ_parser_with_mock_fetcher(self):
|
||||
"""Create h-app parser with mocked HTML fetcher."""
|
||||
from gondulf.services.happ_parser import HAppParser
|
||||
|
||||
html = '''
|
||||
<!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_fetcher = Mock()
|
||||
mock_fetcher.fetch = Mock(return_value=html)
|
||||
|
||||
return HAppParser(html_fetcher=mock_fetcher)
|
||||
|
||||
def test_fetch_and_parse_happ_metadata(self, happ_parser_with_mock_fetcher):
|
||||
"""Test fetching and parsing h-app microformat."""
|
||||
import asyncio
|
||||
result = asyncio.get_event_loop().run_until_complete(
|
||||
happ_parser_with_mock_fetcher.fetch_and_parse("https://app.example.com")
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result.name == "Example Application"
|
||||
assert result.logo == "https://app.example.com/logo.png"
|
||||
|
||||
def test_parse_page_without_happ(self, mock_urlopen):
|
||||
"""Test parsing page without h-app returns fallback."""
|
||||
from gondulf.services.happ_parser import HAppParser
|
||||
from gondulf.services.html_fetcher import HTMLFetcherService
|
||||
|
||||
# Setup mock to return page without h-app
|
||||
html = b'<html><head><title>Plain Page</title></head><body>No h-app</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
|
||||
|
||||
fetcher = HTMLFetcherService()
|
||||
parser = HAppParser(html_fetcher=fetcher)
|
||||
|
||||
import asyncio
|
||||
result = asyncio.get_event_loop().run_until_complete(
|
||||
parser.fetch_and_parse("https://app.example.com")
|
||||
)
|
||||
|
||||
# Should return fallback metadata using domain
|
||||
assert result is not None
|
||||
assert "example.com" in result.name.lower() or result.name == "Plain Page"
|
||||
|
||||
def test_fetch_timeout_returns_fallback(self, mock_urlopen_timeout):
|
||||
"""Test HTTP timeout returns fallback metadata."""
|
||||
from gondulf.services.happ_parser import HAppParser
|
||||
from gondulf.services.html_fetcher import HTMLFetcherService
|
||||
|
||||
fetcher = HTMLFetcherService()
|
||||
parser = HAppParser(html_fetcher=fetcher)
|
||||
|
||||
import asyncio
|
||||
result = asyncio.get_event_loop().run_until_complete(
|
||||
parser.fetch_and_parse("https://slow-app.example.com")
|
||||
)
|
||||
|
||||
# Should return fallback metadata
|
||||
assert result is not None
|
||||
# Should use domain as fallback name
|
||||
assert "slow-app.example.com" in result.name or result.url == "https://slow-app.example.com"
|
||||
|
||||
|
||||
class TestClientMetadataCaching:
|
||||
"""Tests for client metadata caching behavior."""
|
||||
|
||||
def test_metadata_fetched_from_url(self, mock_urlopen_with_happ):
|
||||
"""Test metadata is actually fetched from URL."""
|
||||
from gondulf.services.happ_parser import HAppParser
|
||||
from gondulf.services.html_fetcher import HTMLFetcherService
|
||||
|
||||
fetcher = HTMLFetcherService()
|
||||
parser = HAppParser(html_fetcher=fetcher)
|
||||
|
||||
import asyncio
|
||||
result = asyncio.get_event_loop().run_until_complete(
|
||||
parser.fetch_and_parse("https://app.example.com")
|
||||
)
|
||||
|
||||
# urlopen should have been called
|
||||
mock_urlopen_with_happ.assert_called()
|
||||
|
||||
|
||||
class TestHAppMicroformatVariants:
|
||||
"""Tests for various h-app microformat formats."""
|
||||
|
||||
@pytest.fixture
|
||||
def create_parser_with_html(self):
|
||||
"""Factory to create parser with specific HTML content."""
|
||||
def _create(html_content):
|
||||
from gondulf.services.happ_parser import HAppParser
|
||||
|
||||
mock_fetcher = Mock()
|
||||
mock_fetcher.fetch = Mock(return_value=html_content)
|
||||
|
||||
return HAppParser(html_fetcher=mock_fetcher)
|
||||
return _create
|
||||
|
||||
def test_parse_happ_with_minimal_data(self, create_parser_with_html):
|
||||
"""Test parsing h-app with only name."""
|
||||
html = '''
|
||||
<html>
|
||||
<body>
|
||||
<div class="h-app">
|
||||
<span class="p-name">Minimal App</span>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
parser = create_parser_with_html(html)
|
||||
|
||||
import asyncio
|
||||
result = asyncio.get_event_loop().run_until_complete(
|
||||
parser.fetch_and_parse("https://minimal.example.com")
|
||||
)
|
||||
|
||||
assert result.name == "Minimal App"
|
||||
|
||||
def test_parse_happ_with_logo_relative_url(self, create_parser_with_html):
|
||||
"""Test parsing h-app with relative logo URL."""
|
||||
html = '''
|
||||
<html>
|
||||
<body>
|
||||
<div class="h-app">
|
||||
<span class="p-name">Relative Logo App</span>
|
||||
<img class="u-logo" src="/logo.png">
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
parser = create_parser_with_html(html)
|
||||
|
||||
import asyncio
|
||||
result = asyncio.get_event_loop().run_until_complete(
|
||||
parser.fetch_and_parse("https://relative.example.com")
|
||||
)
|
||||
|
||||
assert result.name == "Relative Logo App"
|
||||
# Logo should be resolved to absolute URL
|
||||
assert result.logo is not None
|
||||
Reference in New Issue
Block a user