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

@@ -0,0 +1 @@
"""API integration tests for Gondulf IndieAuth server."""

View File

@@ -0,0 +1,337 @@
"""
Integration tests for authorization endpoint flow.
Tests the complete authorization endpoint behavior including parameter validation,
client metadata fetching, consent form rendering, and code generation.
"""
import tempfile
from pathlib import Path
from unittest.mock import AsyncMock, Mock, patch
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def auth_app(monkeypatch, tmp_path):
"""Create app for authorization testing."""
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
return app
@pytest.fixture
def auth_client(auth_app):
"""Create test client for authorization tests."""
with TestClient(auth_app) as client:
yield client
@pytest.fixture
def mock_happ_fetch():
"""Mock h-app parser to avoid network calls."""
from gondulf.services.happ_parser import ClientMetadata
metadata = ClientMetadata(
name="Test Application",
url="https://app.example.com",
logo="https://app.example.com/logo.png"
)
with patch('gondulf.services.happ_parser.HAppParser.fetch_and_parse', new_callable=AsyncMock) as mock:
mock.return_value = metadata
yield mock
class TestAuthorizationEndpointValidation:
"""Tests for authorization endpoint parameter validation."""
def test_missing_client_id_returns_error(self, auth_client):
"""Test that missing client_id returns 400 error."""
response = auth_client.get("/authorize", params={
"redirect_uri": "https://app.example.com/callback",
"response_type": "code",
"state": "test123",
})
assert response.status_code == 400
assert "client_id" in response.text.lower()
def test_missing_redirect_uri_returns_error(self, auth_client):
"""Test that missing redirect_uri returns 400 error."""
response = auth_client.get("/authorize", params={
"client_id": "https://app.example.com",
"response_type": "code",
"state": "test123",
})
assert response.status_code == 400
assert "redirect_uri" in response.text.lower()
def test_http_client_id_rejected(self, auth_client):
"""Test that HTTP client_id (non-HTTPS) is rejected."""
response = auth_client.get("/authorize", params={
"client_id": "http://app.example.com", # HTTP not allowed
"redirect_uri": "https://app.example.com/callback",
"response_type": "code",
"state": "test123",
})
assert response.status_code == 400
assert "https" in response.text.lower()
def test_mismatched_redirect_uri_rejected(self, auth_client):
"""Test that redirect_uri not matching client_id domain is rejected."""
response = auth_client.get("/authorize", params={
"client_id": "https://app.example.com",
"redirect_uri": "https://evil.example.com/callback", # Different domain
"response_type": "code",
"state": "test123",
})
assert response.status_code == 400
assert "redirect_uri" in response.text.lower()
class TestAuthorizationEndpointRedirectErrors:
"""Tests for errors that redirect back to the client."""
@pytest.fixture
def valid_params(self):
"""Valid base authorization parameters."""
return {
"client_id": "https://app.example.com",
"redirect_uri": "https://app.example.com/callback",
"state": "test123",
}
def test_invalid_response_type_redirects_with_error(self, auth_client, valid_params, mock_happ_fetch):
"""Test invalid response_type redirects with error parameter."""
params = valid_params.copy()
params["response_type"] = "token" # Invalid - only "code" is supported
response = auth_client.get("/authorize", params=params, follow_redirects=False)
assert response.status_code == 302
location = response.headers["location"]
assert "error=unsupported_response_type" in location
assert "state=test123" in location
def test_missing_code_challenge_redirects_with_error(self, auth_client, valid_params, mock_happ_fetch):
"""Test missing PKCE code_challenge redirects with error."""
params = valid_params.copy()
params["response_type"] = "code"
params["me"] = "https://user.example.com"
# Missing code_challenge
response = auth_client.get("/authorize", params=params, follow_redirects=False)
assert response.status_code == 302
location = response.headers["location"]
assert "error=invalid_request" in location
assert "code_challenge" in location.lower()
def test_invalid_code_challenge_method_redirects_with_error(self, auth_client, valid_params, mock_happ_fetch):
"""Test invalid code_challenge_method redirects with error."""
params = valid_params.copy()
params["response_type"] = "code"
params["me"] = "https://user.example.com"
params["code_challenge"] = "abc123"
params["code_challenge_method"] = "plain" # Invalid - only S256 supported
response = auth_client.get("/authorize", params=params, follow_redirects=False)
assert response.status_code == 302
location = response.headers["location"]
assert "error=invalid_request" in location
assert "S256" in location
def test_missing_me_parameter_redirects_with_error(self, auth_client, valid_params, mock_happ_fetch):
"""Test missing me parameter redirects with error."""
params = valid_params.copy()
params["response_type"] = "code"
params["code_challenge"] = "abc123"
params["code_challenge_method"] = "S256"
# Missing me parameter
response = auth_client.get("/authorize", params=params, follow_redirects=False)
assert response.status_code == 302
location = response.headers["location"]
assert "error=invalid_request" in location
assert "me" in location.lower()
def test_invalid_me_url_redirects_with_error(self, auth_client, valid_params, mock_happ_fetch):
"""Test invalid me URL redirects with error."""
params = valid_params.copy()
params["response_type"] = "code"
params["code_challenge"] = "abc123"
params["code_challenge_method"] = "S256"
params["me"] = "not-a-valid-url"
response = auth_client.get("/authorize", params=params, follow_redirects=False)
assert response.status_code == 302
location = response.headers["location"]
assert "error=invalid_request" in location
class TestAuthorizationConsentPage:
"""Tests for the consent page rendering."""
@pytest.fixture
def complete_params(self):
"""Complete valid authorization parameters."""
return {
"client_id": "https://app.example.com",
"redirect_uri": "https://app.example.com/callback",
"response_type": "code",
"state": "test123",
"code_challenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
"code_challenge_method": "S256",
"me": "https://user.example.com",
}
def test_valid_request_shows_consent_page(self, auth_client, complete_params, mock_happ_fetch):
"""Test valid authorization request shows consent page."""
response = auth_client.get("/authorize", params=complete_params)
assert response.status_code == 200
assert "text/html" in response.headers["content-type"]
# Page should contain client information
assert "app.example.com" in response.text or "Test Application" in response.text
def test_consent_page_contains_required_fields(self, auth_client, complete_params, mock_happ_fetch):
"""Test consent page contains all required form fields."""
response = auth_client.get("/authorize", params=complete_params)
assert response.status_code == 200
# Check for hidden form fields that will be POSTed
assert "client_id" in response.text
assert "redirect_uri" in response.text
assert "code_challenge" in response.text
def test_consent_page_displays_client_metadata(self, auth_client, complete_params, mock_happ_fetch):
"""Test consent page displays client h-app metadata."""
response = auth_client.get("/authorize", params=complete_params)
assert response.status_code == 200
# Should show client name from h-app
assert "Test Application" in response.text or "app.example.com" in response.text
def test_consent_page_preserves_state(self, auth_client, complete_params, mock_happ_fetch):
"""Test consent page preserves state parameter."""
response = auth_client.get("/authorize", params=complete_params)
assert response.status_code == 200
assert "test123" in response.text
class TestAuthorizationConsentSubmission:
"""Tests for consent form submission."""
@pytest.fixture
def consent_form_data(self):
"""Valid consent form data."""
return {
"client_id": "https://app.example.com",
"redirect_uri": "https://app.example.com/callback",
"state": "test123",
"code_challenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
"code_challenge_method": "S256",
"me": "https://user.example.com",
"scope": "",
}
def test_consent_submission_redirects_with_code(self, auth_client, consent_form_data):
"""Test consent submission redirects to client with authorization code."""
response = auth_client.post(
"/authorize/consent",
data=consent_form_data,
follow_redirects=False
)
assert response.status_code == 302
location = response.headers["location"]
assert location.startswith("https://app.example.com/callback")
assert "code=" in location
assert "state=test123" in location
def test_consent_submission_generates_unique_codes(self, auth_client, consent_form_data):
"""Test each consent generates a unique authorization code."""
# First submission
response1 = auth_client.post(
"/authorize/consent",
data=consent_form_data,
follow_redirects=False
)
location1 = response1.headers["location"]
# Second submission
response2 = auth_client.post(
"/authorize/consent",
data=consent_form_data,
follow_redirects=False
)
location2 = response2.headers["location"]
# Extract codes
from tests.conftest import extract_code_from_redirect
code1 = extract_code_from_redirect(location1)
code2 = extract_code_from_redirect(location2)
assert code1 != code2
def test_authorization_code_stored_for_exchange(self, auth_client, consent_form_data):
"""Test authorization code is stored for later token exchange."""
response = auth_client.post(
"/authorize/consent",
data=consent_form_data,
follow_redirects=False
)
from tests.conftest import extract_code_from_redirect
code = extract_code_from_redirect(response.headers["location"])
# Code should be non-empty and URL-safe
assert code is not None
assert len(code) > 20 # Should be a substantial code
class TestAuthorizationSecurityHeaders:
"""Tests for security headers on authorization endpoints."""
def test_authorization_page_has_security_headers(self, auth_client, mock_happ_fetch):
"""Test authorization page includes security headers."""
params = {
"client_id": "https://app.example.com",
"redirect_uri": "https://app.example.com/callback",
"response_type": "code",
"state": "test123",
"code_challenge": "abc123",
"code_challenge_method": "S256",
"me": "https://user.example.com",
}
response = auth_client.get("/authorize", params=params)
assert "X-Frame-Options" in response.headers
assert "X-Content-Type-Options" in response.headers
assert response.headers["X-Frame-Options"] == "DENY"
def test_error_pages_have_security_headers(self, auth_client):
"""Test error pages include security headers."""
# Request without client_id should return error page
response = auth_client.get("/authorize", params={
"redirect_uri": "https://app.example.com/callback"
})
assert response.status_code == 400
assert "X-Frame-Options" in response.headers
assert "X-Content-Type-Options" in response.headers

View File

@@ -0,0 +1,137 @@
"""
Integration tests for OAuth 2.0 metadata endpoint.
Tests the /.well-known/oauth-authorization-server endpoint per RFC 8414.
"""
import json
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def metadata_app(monkeypatch, tmp_path):
"""Create app for metadata testing."""
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
return app
@pytest.fixture
def metadata_client(metadata_app):
"""Create test client for metadata tests."""
with TestClient(metadata_app) as client:
yield client
class TestMetadataEndpoint:
"""Tests for OAuth 2.0 Authorization Server Metadata endpoint."""
def test_metadata_returns_json(self, metadata_client):
"""Test metadata endpoint returns JSON response."""
response = metadata_client.get("/.well-known/oauth-authorization-server")
assert response.status_code == 200
assert "application/json" in response.headers["content-type"]
def test_metadata_includes_issuer(self, metadata_client):
"""Test metadata includes issuer field."""
response = metadata_client.get("/.well-known/oauth-authorization-server")
data = response.json()
assert "issuer" in data
assert data["issuer"] == "https://auth.example.com"
def test_metadata_includes_authorization_endpoint(self, metadata_client):
"""Test metadata includes authorization endpoint."""
response = metadata_client.get("/.well-known/oauth-authorization-server")
data = response.json()
assert "authorization_endpoint" in data
assert data["authorization_endpoint"] == "https://auth.example.com/authorize"
def test_metadata_includes_token_endpoint(self, metadata_client):
"""Test metadata includes token endpoint."""
response = metadata_client.get("/.well-known/oauth-authorization-server")
data = response.json()
assert "token_endpoint" in data
assert data["token_endpoint"] == "https://auth.example.com/token"
def test_metadata_includes_response_types(self, metadata_client):
"""Test metadata includes supported response types."""
response = metadata_client.get("/.well-known/oauth-authorization-server")
data = response.json()
assert "response_types_supported" in data
assert "code" in data["response_types_supported"]
def test_metadata_includes_grant_types(self, metadata_client):
"""Test metadata includes supported grant types."""
response = metadata_client.get("/.well-known/oauth-authorization-server")
data = response.json()
assert "grant_types_supported" in data
assert "authorization_code" in data["grant_types_supported"]
def test_metadata_includes_token_auth_methods(self, metadata_client):
"""Test metadata includes token endpoint auth methods."""
response = metadata_client.get("/.well-known/oauth-authorization-server")
data = response.json()
assert "token_endpoint_auth_methods_supported" in data
assert "none" in data["token_endpoint_auth_methods_supported"]
class TestMetadataCaching:
"""Tests for metadata endpoint caching behavior."""
def test_metadata_includes_cache_header(self, metadata_client):
"""Test metadata endpoint includes Cache-Control header."""
response = metadata_client.get("/.well-known/oauth-authorization-server")
assert "Cache-Control" in response.headers
# Should allow caching
assert "public" in response.headers["Cache-Control"]
assert "max-age" in response.headers["Cache-Control"]
def test_metadata_is_cacheable(self, metadata_client):
"""Test metadata endpoint allows public caching."""
response = metadata_client.get("/.well-known/oauth-authorization-server")
cache_control = response.headers["Cache-Control"]
# Should be cacheable for a reasonable time
assert "public" in cache_control
class TestMetadataSecurity:
"""Security tests for metadata endpoint."""
def test_metadata_includes_security_headers(self, metadata_client):
"""Test metadata endpoint includes security headers."""
response = metadata_client.get("/.well-known/oauth-authorization-server")
assert "X-Frame-Options" in response.headers
assert "X-Content-Type-Options" in response.headers
def test_metadata_requires_no_authentication(self, metadata_client):
"""Test metadata endpoint is publicly accessible."""
response = metadata_client.get("/.well-known/oauth-authorization-server")
# Should work without any authentication
assert response.status_code == 200
def test_metadata_returns_valid_json(self, metadata_client):
"""Test metadata returns valid parseable JSON."""
response = metadata_client.get("/.well-known/oauth-authorization-server")
# Should not raise
data = json.loads(response.content)
assert isinstance(data, dict)

View File

@@ -0,0 +1,328 @@
"""
Integration tests for token endpoint flow.
Tests the complete token exchange flow including authorization code validation,
PKCE verification, token generation, and error handling.
"""
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def token_app(monkeypatch, tmp_path):
"""Create app for token testing."""
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
return app
@pytest.fixture
def token_client(token_app):
"""Create test client for token tests."""
with TestClient(token_app) as client:
yield client
@pytest.fixture
def setup_auth_code(token_app, test_code_storage):
"""Setup a valid authorization code for testing."""
from gondulf.dependencies import get_code_storage
code = "integration_test_code_12345"
metadata = {
"client_id": "https://app.example.com",
"redirect_uri": "https://app.example.com/callback",
"state": "xyz123",
"me": "https://user.example.com",
"scope": "",
"code_challenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
"code_challenge_method": "S256",
"created_at": 1234567890,
"expires_at": 1234568490,
"used": False
}
# Override the code storage dependency
token_app.dependency_overrides[get_code_storage] = lambda: test_code_storage
test_code_storage.store(f"authz:{code}", metadata)
yield code, metadata, test_code_storage
token_app.dependency_overrides.clear()
class TestTokenExchangeIntegration:
"""Integration tests for successful token exchange."""
def test_valid_code_exchange_returns_token(self, token_client, setup_auth_code):
"""Test valid authorization code exchange returns access token."""
code, metadata, _ = setup_auth_code
response = token_client.post("/token", data={
"grant_type": "authorization_code",
"code": code,
"client_id": metadata["client_id"],
"redirect_uri": metadata["redirect_uri"],
})
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert data["token_type"] == "Bearer"
assert data["me"] == metadata["me"]
def test_token_response_format_matches_oauth2(self, token_client, setup_auth_code):
"""Test token response matches OAuth 2.0 specification format."""
code, metadata, _ = setup_auth_code
response = token_client.post("/token", data={
"grant_type": "authorization_code",
"code": code,
"client_id": metadata["client_id"],
"redirect_uri": metadata["redirect_uri"],
})
assert response.status_code == 200
data = response.json()
# Required fields per OAuth 2.0 / IndieAuth
assert "access_token" in data
assert "token_type" in data
assert "me" in data
# Token should be substantial
assert len(data["access_token"]) >= 32
def test_token_response_includes_cache_headers(self, token_client, setup_auth_code):
"""Test token response includes required cache headers."""
code, metadata, _ = setup_auth_code
response = token_client.post("/token", data={
"grant_type": "authorization_code",
"code": code,
"client_id": metadata["client_id"],
"redirect_uri": metadata["redirect_uri"],
})
assert response.status_code == 200
# OAuth 2.0 requires no-store
assert response.headers["Cache-Control"] == "no-store"
assert response.headers["Pragma"] == "no-cache"
def test_authorization_code_single_use(self, token_client, setup_auth_code):
"""Test authorization code cannot be used twice."""
code, metadata, _ = setup_auth_code
# First exchange should succeed
response1 = token_client.post("/token", data={
"grant_type": "authorization_code",
"code": code,
"client_id": metadata["client_id"],
"redirect_uri": metadata["redirect_uri"],
})
assert response1.status_code == 200
# Second exchange should fail
response2 = token_client.post("/token", data={
"grant_type": "authorization_code",
"code": code,
"client_id": metadata["client_id"],
"redirect_uri": metadata["redirect_uri"],
})
assert response2.status_code == 400
data = response2.json()
assert data["detail"]["error"] == "invalid_grant"
class TestTokenExchangeErrors:
"""Integration tests for token exchange error conditions."""
def test_invalid_grant_type_rejected(self, token_client, setup_auth_code):
"""Test invalid grant_type returns error."""
code, metadata, _ = setup_auth_code
response = token_client.post("/token", data={
"grant_type": "password", # Invalid grant type
"code": code,
"client_id": metadata["client_id"],
"redirect_uri": metadata["redirect_uri"],
})
assert response.status_code == 400
data = response.json()
assert data["detail"]["error"] == "unsupported_grant_type"
def test_invalid_code_rejected(self, token_client, setup_auth_code):
"""Test invalid authorization code returns error."""
_, metadata, _ = setup_auth_code
response = token_client.post("/token", data={
"grant_type": "authorization_code",
"code": "nonexistent_code_12345",
"client_id": metadata["client_id"],
"redirect_uri": metadata["redirect_uri"],
})
assert response.status_code == 400
data = response.json()
assert data["detail"]["error"] == "invalid_grant"
def test_client_id_mismatch_rejected(self, token_client, setup_auth_code):
"""Test mismatched client_id returns error."""
code, metadata, _ = setup_auth_code
response = token_client.post("/token", data={
"grant_type": "authorization_code",
"code": code,
"client_id": "https://different-client.example.com", # Wrong client
"redirect_uri": metadata["redirect_uri"],
})
assert response.status_code == 400
data = response.json()
assert data["detail"]["error"] == "invalid_client"
def test_redirect_uri_mismatch_rejected(self, token_client, setup_auth_code):
"""Test mismatched redirect_uri returns error."""
code, metadata, _ = setup_auth_code
response = token_client.post("/token", data={
"grant_type": "authorization_code",
"code": code,
"client_id": metadata["client_id"],
"redirect_uri": "https://app.example.com/different-callback", # Wrong URI
})
assert response.status_code == 400
data = response.json()
assert data["detail"]["error"] == "invalid_grant"
def test_used_code_rejected(self, token_client, token_app, test_code_storage):
"""Test already-used authorization code returns error."""
from gondulf.dependencies import get_code_storage
code = "used_code_test_12345"
metadata = {
"client_id": "https://app.example.com",
"redirect_uri": "https://app.example.com/callback",
"state": "xyz123",
"me": "https://user.example.com",
"scope": "",
"code_challenge": "abc123",
"code_challenge_method": "S256",
"created_at": 1234567890,
"expires_at": 1234568490,
"used": True # Already used
}
token_app.dependency_overrides[get_code_storage] = lambda: test_code_storage
test_code_storage.store(f"authz:{code}", metadata)
response = token_client.post("/token", data={
"grant_type": "authorization_code",
"code": code,
"client_id": metadata["client_id"],
"redirect_uri": metadata["redirect_uri"],
})
assert response.status_code == 400
data = response.json()
assert data["detail"]["error"] == "invalid_grant"
token_app.dependency_overrides.clear()
class TestTokenEndpointSecurity:
"""Security tests for token endpoint."""
def test_token_endpoint_requires_post(self, token_client):
"""Test token endpoint only accepts POST requests."""
response = token_client.get("/token")
assert response.status_code == 405 # Method Not Allowed
def test_token_endpoint_requires_form_data(self, token_client, setup_auth_code):
"""Test token endpoint requires form-encoded data."""
code, metadata, _ = setup_auth_code
# Send JSON instead of form data
response = token_client.post("/token", json={
"grant_type": "authorization_code",
"code": code,
"client_id": metadata["client_id"],
"redirect_uri": metadata["redirect_uri"],
})
# Should fail because it expects form data
assert response.status_code == 422 # Unprocessable Entity
def test_token_response_security_headers(self, token_client, setup_auth_code):
"""Test token response includes security headers."""
code, metadata, _ = setup_auth_code
response = token_client.post("/token", data={
"grant_type": "authorization_code",
"code": code,
"client_id": metadata["client_id"],
"redirect_uri": metadata["redirect_uri"],
})
# Security headers should be present
assert "X-Frame-Options" in response.headers
assert "X-Content-Type-Options" in response.headers
def test_error_response_format_matches_oauth2(self, token_client):
"""Test error responses match OAuth 2.0 format."""
response = token_client.post("/token", data={
"grant_type": "authorization_code",
"code": "invalid_code",
"client_id": "https://app.example.com",
"redirect_uri": "https://app.example.com/callback",
})
assert response.status_code == 400
data = response.json()
# OAuth 2.0 error format
assert "detail" in data
assert "error" in data["detail"]
class TestPKCEHandling:
"""Tests for PKCE code_verifier handling."""
def test_code_verifier_accepted(self, token_client, setup_auth_code):
"""Test code_verifier parameter is accepted."""
code, metadata, _ = setup_auth_code
response = token_client.post("/token", data={
"grant_type": "authorization_code",
"code": code,
"client_id": metadata["client_id"],
"redirect_uri": metadata["redirect_uri"],
"code_verifier": "some_verifier_value", # PKCE verifier
})
# Should succeed (PKCE validation deferred per design)
assert response.status_code == 200
def test_token_exchange_works_without_verifier(self, token_client, setup_auth_code):
"""Test token exchange works without code_verifier in v1.0.0."""
code, metadata, _ = setup_auth_code
response = token_client.post("/token", data={
"grant_type": "authorization_code",
"code": code,
"client_id": metadata["client_id"],
"redirect_uri": metadata["redirect_uri"],
# No code_verifier
})
# Should succeed (PKCE not enforced in v1.0.0)
assert response.status_code == 200

View File

@@ -0,0 +1,243 @@
"""
Integration tests for domain verification flow.
Tests the complete domain verification flow including DNS verification,
email discovery, and code verification.
"""
import pytest
from fastapi.testclient import TestClient
from unittest.mock import Mock
@pytest.fixture
def verification_app(monkeypatch, tmp_path):
"""Create app for verification testing."""
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
return app
@pytest.fixture
def verification_client(verification_app):
"""Create test client for verification tests."""
with TestClient(verification_app) as client:
yield client
@pytest.fixture
def mock_verification_deps(verification_app, mock_dns_service, mock_email_service, mock_html_fetcher_with_email, mock_rate_limiter, test_code_storage):
"""Setup mock dependencies for verification."""
from gondulf.dependencies import get_verification_service, get_rate_limiter
from gondulf.services.domain_verification import DomainVerificationService
from gondulf.services.relme_parser import RelMeParser
service = 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()
)
verification_app.dependency_overrides[get_verification_service] = lambda: service
verification_app.dependency_overrides[get_rate_limiter] = lambda: mock_rate_limiter
yield service, test_code_storage
verification_app.dependency_overrides.clear()
class TestStartVerification:
"""Tests for starting domain verification."""
def test_start_verification_success(self, verification_client, mock_verification_deps):
"""Test successful start of domain verification."""
response = verification_client.post(
"/api/verify/start",
data={"me": "https://user.example.com"}
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert "email" in data
# Email should be masked
assert "*" in data["email"]
def test_start_verification_invalid_me_url(self, verification_client, mock_verification_deps):
"""Test verification fails with invalid me URL."""
response = verification_client.post(
"/api/verify/start",
data={"me": "not-a-valid-url"}
)
assert response.status_code == 200
data = response.json()
assert data["success"] is False
assert data["error"] == "invalid_me_url"
def test_start_verification_rate_limited(self, verification_app, verification_client, mock_rate_limiter_exceeded, verification_service):
"""Test verification fails when rate limited."""
from gondulf.dependencies import get_rate_limiter, get_verification_service
verification_app.dependency_overrides[get_rate_limiter] = lambda: mock_rate_limiter_exceeded
verification_app.dependency_overrides[get_verification_service] = lambda: verification_service
response = verification_client.post(
"/api/verify/start",
data={"me": "https://user.example.com"}
)
assert response.status_code == 200
data = response.json()
assert data["success"] is False
assert data["error"] == "rate_limit_exceeded"
verification_app.dependency_overrides.clear()
def test_start_verification_dns_failure(self, verification_app, verification_client, verification_service_dns_failure, mock_rate_limiter):
"""Test verification fails when DNS check fails."""
from gondulf.dependencies import get_rate_limiter, get_verification_service
verification_app.dependency_overrides[get_rate_limiter] = lambda: mock_rate_limiter
verification_app.dependency_overrides[get_verification_service] = lambda: verification_service_dns_failure
response = verification_client.post(
"/api/verify/start",
data={"me": "https://user.example.com"}
)
assert response.status_code == 200
data = response.json()
assert data["success"] is False
assert data["error"] == "dns_verification_failed"
verification_app.dependency_overrides.clear()
class TestVerifyCode:
"""Tests for verifying email code."""
def test_verify_code_success(self, verification_client, mock_verification_deps):
"""Test successful code verification."""
service, code_storage = mock_verification_deps
# First start verification to store the code
verification_client.post(
"/api/verify/start",
data={"me": "https://example.com/"}
)
# Get the stored code
stored_code = code_storage.get("email_verify:example.com")
assert stored_code is not None
# Verify the code
response = verification_client.post(
"/api/verify/code",
data={"domain": "example.com", "code": stored_code}
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert "email" in data
def test_verify_code_invalid_code(self, verification_client, mock_verification_deps):
"""Test verification fails with invalid code."""
response = verification_client.post(
"/api/verify/code",
data={"domain": "example.com", "code": "000000"}
)
assert response.status_code == 200
data = response.json()
assert data["success"] is False
assert data["error"] == "invalid_code"
def test_verify_code_wrong_domain(self, verification_client, mock_verification_deps):
"""Test verification fails with wrong domain."""
service, code_storage = mock_verification_deps
# Start verification for one domain
verification_client.post(
"/api/verify/start",
data={"me": "https://example.com/"}
)
# Get the stored code
stored_code = code_storage.get("email_verify:example.com")
# Try to verify with different domain
response = verification_client.post(
"/api/verify/code",
data={"domain": "other.example.com", "code": stored_code}
)
assert response.status_code == 200
data = response.json()
assert data["success"] is False
class TestVerificationSecurityHeaders:
"""Security tests for verification endpoints."""
def test_start_verification_security_headers(self, verification_client, mock_verification_deps):
"""Test verification endpoints include security headers."""
response = verification_client.post(
"/api/verify/start",
data={"me": "https://user.example.com"}
)
assert "X-Frame-Options" in response.headers
assert "X-Content-Type-Options" in response.headers
def test_verify_code_security_headers(self, verification_client, mock_verification_deps):
"""Test code verification endpoint includes security headers."""
response = verification_client.post(
"/api/verify/code",
data={"domain": "example.com", "code": "123456"}
)
assert "X-Frame-Options" in response.headers
assert "X-Content-Type-Options" in response.headers
class TestVerificationResponseFormat:
"""Tests for verification endpoint response formats."""
def test_start_verification_returns_json(self, verification_client, mock_verification_deps):
"""Test start verification returns JSON."""
response = verification_client.post(
"/api/verify/start",
data={"me": "https://user.example.com"}
)
assert "application/json" in response.headers["content-type"]
def test_verify_code_returns_json(self, verification_client, mock_verification_deps):
"""Test code verification returns JSON."""
response = verification_client.post(
"/api/verify/code",
data={"domain": "example.com", "code": "123456"}
)
assert "application/json" in response.headers["content-type"]
def test_success_response_includes_method(self, verification_client, mock_verification_deps):
"""Test successful verification includes verification method."""
response = verification_client.post(
"/api/verify/start",
data={"me": "https://user.example.com"}
)
data = response.json()
assert data["success"] is True
assert "verification_method" in data

View File

@@ -0,0 +1 @@
"""Middleware integration tests for Gondulf IndieAuth server."""

View File

@@ -0,0 +1,219 @@
"""
Integration tests for middleware chain.
Tests that security headers and HTTPS enforcement middleware work together.
"""
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def middleware_app_debug(monkeypatch, tmp_path):
"""Create app in debug mode for middleware testing."""
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
return app
@pytest.fixture
def middleware_app_production(monkeypatch, tmp_path):
"""Create app in production mode for middleware testing."""
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", "false")
from gondulf.main import app
return app
@pytest.fixture
def debug_client(middleware_app_debug):
"""Test client in debug mode."""
with TestClient(middleware_app_debug) as client:
yield client
@pytest.fixture
def production_client(middleware_app_production):
"""Test client in production mode."""
with TestClient(middleware_app_production) as client:
yield client
class TestSecurityHeadersChain:
"""Tests for security headers middleware."""
def test_all_security_headers_present(self, debug_client):
"""Test all required security headers are present."""
response = debug_client.get("/")
# Required security headers
assert response.headers["X-Frame-Options"] == "DENY"
assert response.headers["X-Content-Type-Options"] == "nosniff"
assert response.headers["X-XSS-Protection"] == "1; mode=block"
assert "Content-Security-Policy" in response.headers
assert "Referrer-Policy" in response.headers
assert "Permissions-Policy" in response.headers
def test_csp_header_format(self, debug_client):
"""Test CSP header has correct format."""
response = debug_client.get("/")
csp = response.headers["Content-Security-Policy"]
assert "default-src 'self'" in csp
assert "frame-ancestors 'none'" in csp
def test_referrer_policy_value(self, debug_client):
"""Test Referrer-Policy has correct value."""
response = debug_client.get("/")
assert response.headers["Referrer-Policy"] == "strict-origin-when-cross-origin"
def test_permissions_policy_value(self, debug_client):
"""Test Permissions-Policy disables unnecessary features."""
response = debug_client.get("/")
permissions = response.headers["Permissions-Policy"]
assert "geolocation=()" in permissions
assert "microphone=()" in permissions
assert "camera=()" in permissions
def test_hsts_not_in_debug_mode(self, debug_client):
"""Test HSTS header is not present in debug mode."""
response = debug_client.get("/")
# HSTS should not be set in debug mode
assert "Strict-Transport-Security" not in response.headers
class TestMiddlewareOnAllEndpoints:
"""Tests that middleware applies to all endpoints."""
@pytest.mark.parametrize("endpoint", [
"/",
"/health",
"/.well-known/oauth-authorization-server",
])
def test_security_headers_on_endpoint(self, debug_client, endpoint):
"""Test security headers present on various endpoints."""
response = debug_client.get(endpoint)
assert "X-Frame-Options" in response.headers
assert "X-Content-Type-Options" in response.headers
def test_security_headers_on_post_endpoint(self, debug_client):
"""Test security headers on POST endpoints."""
response = debug_client.post(
"/api/verify/start",
data={"me": "https://example.com"}
)
assert "X-Frame-Options" in response.headers
assert "X-Content-Type-Options" in response.headers
def test_security_headers_on_error_response(self, debug_client):
"""Test security headers on 4xx error responses."""
response = debug_client.get("/authorize") # Missing required params
assert response.status_code == 400
assert "X-Frame-Options" in response.headers
assert "X-Content-Type-Options" in response.headers
class TestHTTPSEnforcementMiddleware:
"""Tests for HTTPS enforcement middleware."""
def test_http_localhost_allowed_in_debug(self, debug_client):
"""Test HTTP to localhost is allowed in debug mode."""
# TestClient defaults to http
response = debug_client.get("http://localhost/")
# Should work in debug mode
assert response.status_code == 200
def test_https_always_allowed(self, debug_client):
"""Test HTTPS requests are always allowed."""
response = debug_client.get("/")
assert response.status_code == 200
class TestMiddlewareOrdering:
"""Tests for correct middleware ordering."""
def test_security_headers_applied_to_redirects(self, debug_client):
"""Test security headers are applied even on redirect responses."""
# This request should trigger a redirect due to error
response = debug_client.get(
"/authorize",
params={
"client_id": "https://app.example.com",
"redirect_uri": "https://app.example.com/callback",
"response_type": "token", # Invalid - should redirect with error
"state": "test"
},
follow_redirects=False
)
# Even on redirect, security headers should be present
if response.status_code in (301, 302, 307, 308):
assert "X-Frame-Options" in response.headers
def test_middleware_chain_complete(self, debug_client):
"""Test full middleware chain processes correctly."""
response = debug_client.get("/")
# Response should be successful
assert response.status_code == 200
# Security headers from SecurityHeadersMiddleware
assert "X-Frame-Options" in response.headers
assert "X-Content-Type-Options" in response.headers
# Application response should be JSON
data = response.json()
assert "service" in data
class TestContentSecurityPolicy:
"""Tests for CSP header configuration."""
def test_csp_allows_self(self, debug_client):
"""Test CSP allows resources from same origin."""
response = debug_client.get("/")
csp = response.headers["Content-Security-Policy"]
assert "default-src 'self'" in csp
def test_csp_allows_inline_styles(self, debug_client):
"""Test CSP allows inline styles for templates."""
response = debug_client.get("/")
csp = response.headers["Content-Security-Policy"]
assert "style-src" in csp
assert "'unsafe-inline'" in csp
def test_csp_allows_https_images(self, debug_client):
"""Test CSP allows HTTPS images for h-app logos."""
response = debug_client.get("/")
csp = response.headers["Content-Security-Policy"]
assert "img-src" in csp
assert "https:" in csp
def test_csp_prevents_framing(self, debug_client):
"""Test CSP prevents page from being framed."""
response = debug_client.get("/")
csp = response.headers["Content-Security-Policy"]
assert "frame-ancestors 'none'" in csp

View File

@@ -0,0 +1 @@
"""Service integration tests for Gondulf IndieAuth server."""

View 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

View 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