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

1
tests/e2e/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""End-to-end tests for Gondulf IndieAuth server."""

View File

@@ -0,0 +1,390 @@
"""
End-to-end tests for complete IndieAuth authentication flow.
Tests the full authorization code flow from initial request through token exchange.
Uses TestClient-based flow simulation per Phase 5b clarifications.
"""
import pytest
from fastapi.testclient import TestClient
from unittest.mock import AsyncMock, Mock, patch
from tests.conftest import extract_code_from_redirect
@pytest.fixture
def e2e_app(monkeypatch, tmp_path):
"""Create app for E2E 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 e2e_client(e2e_app):
"""Create test client for E2E tests."""
with TestClient(e2e_app) as client:
yield client
@pytest.fixture
def mock_happ_for_e2e():
"""Mock h-app parser for E2E tests."""
from gondulf.services.happ_parser import ClientMetadata
metadata = ClientMetadata(
name="E2E Test App",
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
@pytest.mark.e2e
class TestCompleteAuthorizationFlow:
"""E2E tests for complete authorization code flow."""
def test_full_authorization_to_token_flow(self, e2e_client, mock_happ_for_e2e):
"""Test complete flow: authorization request -> consent -> token exchange."""
# Step 1: Authorization request
auth_params = {
"response_type": "code",
"client_id": "https://app.example.com",
"redirect_uri": "https://app.example.com/callback",
"state": "e2e_test_state_12345",
"code_challenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
"code_challenge_method": "S256",
"me": "https://user.example.com",
}
auth_response = e2e_client.get("/authorize", params=auth_params)
# Should show consent page
assert auth_response.status_code == 200
assert "text/html" in auth_response.headers["content-type"]
# Step 2: Submit consent form
consent_data = {
"client_id": "https://app.example.com",
"redirect_uri": "https://app.example.com/callback",
"state": "e2e_test_state_12345",
"code_challenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
"code_challenge_method": "S256",
"me": "https://user.example.com",
"scope": "",
}
consent_response = e2e_client.post(
"/authorize/consent",
data=consent_data,
follow_redirects=False
)
# Should redirect with authorization code
assert consent_response.status_code == 302
location = consent_response.headers["location"]
assert location.startswith("https://app.example.com/callback")
assert "code=" in location
assert "state=e2e_test_state_12345" in location
# Step 3: Extract authorization code
auth_code = extract_code_from_redirect(location)
assert auth_code is not None
# Step 4: Exchange code for token
token_response = e2e_client.post("/token", data={
"grant_type": "authorization_code",
"code": auth_code,
"client_id": "https://app.example.com",
"redirect_uri": "https://app.example.com/callback",
})
# Should receive access token
assert token_response.status_code == 200
token_data = token_response.json()
assert "access_token" in token_data
assert token_data["token_type"] == "Bearer"
assert token_data["me"] == "https://user.example.com"
def test_authorization_flow_preserves_state(self, e2e_client, mock_happ_for_e2e):
"""Test that state parameter is preserved throughout the flow."""
state = "unique_state_for_csrf_protection"
# Authorization request
auth_response = e2e_client.get("/authorize", params={
"response_type": "code",
"client_id": "https://app.example.com",
"redirect_uri": "https://app.example.com/callback",
"state": state,
"code_challenge": "abc123",
"code_challenge_method": "S256",
"me": "https://user.example.com",
})
assert auth_response.status_code == 200
assert state in auth_response.text
# Consent submission
consent_response = e2e_client.post(
"/authorize/consent",
data={
"client_id": "https://app.example.com",
"redirect_uri": "https://app.example.com/callback",
"state": state,
"code_challenge": "abc123",
"code_challenge_method": "S256",
"me": "https://user.example.com",
"scope": "",
},
follow_redirects=False
)
# State should be in redirect
location = consent_response.headers["location"]
assert f"state={state}" in location
def test_multiple_concurrent_flows(self, e2e_client, mock_happ_for_e2e):
"""Test multiple authorization flows can run concurrently."""
flows = []
# Start 3 authorization flows
for i in range(3):
consent_response = e2e_client.post(
"/authorize/consent",
data={
"client_id": "https://app.example.com",
"redirect_uri": "https://app.example.com/callback",
"state": f"flow_{i}",
"code_challenge": "abc123",
"code_challenge_method": "S256",
"me": f"https://user{i}.example.com",
"scope": "",
},
follow_redirects=False
)
code = extract_code_from_redirect(consent_response.headers["location"])
flows.append((code, f"https://user{i}.example.com"))
# Exchange all codes - each should work
for code, expected_me in flows:
token_response = e2e_client.post("/token", data={
"grant_type": "authorization_code",
"code": code,
"client_id": "https://app.example.com",
"redirect_uri": "https://app.example.com/callback",
})
assert token_response.status_code == 200
assert token_response.json()["me"] == expected_me
@pytest.mark.e2e
class TestErrorScenariosE2E:
"""E2E tests for error scenarios."""
def test_invalid_client_id_error_page(self, e2e_client):
"""Test invalid client_id shows error page."""
response = e2e_client.get("/authorize", params={
"client_id": "http://insecure.example.com", # HTTP not allowed
"redirect_uri": "http://insecure.example.com/callback",
"response_type": "code",
})
assert response.status_code == 400
# Should show error page, not redirect
assert "text/html" in response.headers["content-type"]
def test_expired_code_rejected(self, e2e_client, e2e_app, mock_happ_for_e2e):
"""Test expired authorization code is rejected."""
from gondulf.dependencies import get_code_storage
from gondulf.storage import CodeStore
# Create code storage with very short TTL
short_ttl_storage = CodeStore(ttl_seconds=0) # Expire immediately
# Store a code that will expire immediately
code = "expired_test_code_12345"
metadata = {
"client_id": "https://app.example.com",
"redirect_uri": "https://app.example.com/callback",
"state": "test",
"me": "https://user.example.com",
"scope": "",
"code_challenge": "abc123",
"code_challenge_method": "S256",
"created_at": 1000000000,
"expires_at": 1000000001,
"used": False
}
short_ttl_storage.store(f"authz:{code}", metadata, ttl=0)
e2e_app.dependency_overrides[get_code_storage] = lambda: short_ttl_storage
# Wait a tiny bit for expiration
import time
time.sleep(0.01)
# Try to exchange expired code
response = e2e_client.post("/token", data={
"grant_type": "authorization_code",
"code": code,
"client_id": "https://app.example.com",
"redirect_uri": "https://app.example.com/callback",
})
assert response.status_code == 400
assert response.json()["detail"]["error"] == "invalid_grant"
e2e_app.dependency_overrides.clear()
def test_code_cannot_be_reused(self, e2e_client, mock_happ_for_e2e):
"""Test authorization code single-use enforcement."""
# Get a valid code
consent_response = e2e_client.post(
"/authorize/consent",
data={
"client_id": "https://app.example.com",
"redirect_uri": "https://app.example.com/callback",
"state": "test",
"code_challenge": "abc123",
"code_challenge_method": "S256",
"me": "https://user.example.com",
"scope": "",
},
follow_redirects=False
)
code = extract_code_from_redirect(consent_response.headers["location"])
# First exchange should succeed
response1 = e2e_client.post("/token", data={
"grant_type": "authorization_code",
"code": code,
"client_id": "https://app.example.com",
"redirect_uri": "https://app.example.com/callback",
})
assert response1.status_code == 200
# Second exchange should fail
response2 = e2e_client.post("/token", data={
"grant_type": "authorization_code",
"code": code,
"client_id": "https://app.example.com",
"redirect_uri": "https://app.example.com/callback",
})
assert response2.status_code == 400
def test_wrong_client_id_rejected(self, e2e_client, mock_happ_for_e2e):
"""Test token exchange with wrong client_id is rejected."""
# Get a code for one client
consent_response = e2e_client.post(
"/authorize/consent",
data={
"client_id": "https://app.example.com",
"redirect_uri": "https://app.example.com/callback",
"state": "test",
"code_challenge": "abc123",
"code_challenge_method": "S256",
"me": "https://user.example.com",
"scope": "",
},
follow_redirects=False
)
code = extract_code_from_redirect(consent_response.headers["location"])
# Try to exchange with different client_id
response = e2e_client.post("/token", data={
"grant_type": "authorization_code",
"code": code,
"client_id": "https://different-app.example.com", # Wrong client
"redirect_uri": "https://app.example.com/callback",
})
assert response.status_code == 400
assert response.json()["detail"]["error"] == "invalid_client"
@pytest.mark.e2e
class TestTokenUsageE2E:
"""E2E tests for token usage after obtaining it."""
def test_obtained_token_has_correct_format(self, e2e_client, mock_happ_for_e2e):
"""Test the token obtained through E2E flow has correct format."""
# Complete the flow
consent_response = e2e_client.post(
"/authorize/consent",
data={
"client_id": "https://app.example.com",
"redirect_uri": "https://app.example.com/callback",
"state": "test",
"code_challenge": "abc123",
"code_challenge_method": "S256",
"me": "https://user.example.com",
"scope": "",
},
follow_redirects=False
)
code = extract_code_from_redirect(consent_response.headers["location"])
token_response = e2e_client.post("/token", data={
"grant_type": "authorization_code",
"code": code,
"client_id": "https://app.example.com",
"redirect_uri": "https://app.example.com/callback",
})
assert token_response.status_code == 200
token_data = token_response.json()
# Verify token has correct format
assert "access_token" in token_data
assert len(token_data["access_token"]) >= 32 # Should be substantial
assert token_data["token_type"] == "Bearer"
assert token_data["me"] == "https://user.example.com"
def test_token_response_includes_all_fields(self, e2e_client, mock_happ_for_e2e):
"""Test token response includes all required IndieAuth fields."""
# Complete the flow
consent_response = e2e_client.post(
"/authorize/consent",
data={
"client_id": "https://app.example.com",
"redirect_uri": "https://app.example.com/callback",
"state": "test",
"code_challenge": "abc123",
"code_challenge_method": "S256",
"me": "https://user.example.com",
"scope": "profile",
},
follow_redirects=False
)
code = extract_code_from_redirect(consent_response.headers["location"])
token_response = e2e_client.post("/token", data={
"grant_type": "authorization_code",
"code": code,
"client_id": "https://app.example.com",
"redirect_uri": "https://app.example.com/callback",
})
assert token_response.status_code == 200
token_data = token_response.json()
# All required IndieAuth fields
assert "access_token" in token_data
assert "token_type" in token_data
assert "me" in token_data
assert "scope" in token_data

View File

@@ -0,0 +1,260 @@
"""
End-to-end tests for error scenarios and edge cases.
Tests various error conditions and ensures proper error handling throughout the system.
"""
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def error_app(monkeypatch, tmp_path):
"""Create app for error scenario 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 error_client(error_app):
"""Create test client for error scenario tests."""
with TestClient(error_app) as client:
yield client
@pytest.mark.e2e
class TestAuthorizationErrors:
"""E2E tests for authorization endpoint errors."""
def test_missing_all_parameters(self, error_client):
"""Test authorization request with no parameters."""
response = error_client.get("/authorize")
assert response.status_code == 400
def test_http_client_id_rejected(self, error_client):
"""Test HTTP (non-HTTPS) client_id is rejected."""
response = error_client.get("/authorize", params={
"client_id": "http://insecure.example.com",
"redirect_uri": "http://insecure.example.com/callback",
"response_type": "code",
"state": "test",
})
assert response.status_code == 400
assert "https" in response.text.lower()
def test_mismatched_redirect_uri_domain(self, error_client):
"""Test redirect_uri must match client_id domain."""
response = error_client.get("/authorize", params={
"client_id": "https://legitimate-app.example.com",
"redirect_uri": "https://evil-site.example.com/steal",
"response_type": "code",
"state": "test",
})
assert response.status_code == 400
def test_invalid_response_type_redirects(self, error_client):
"""Test invalid response_type redirects with error."""
response = error_client.get("/authorize", params={
"client_id": "https://app.example.com",
"redirect_uri": "https://app.example.com/callback",
"response_type": "implicit", # Not supported
"state": "test123",
}, follow_redirects=False)
assert response.status_code == 302
location = response.headers["location"]
assert "error=unsupported_response_type" in location
assert "state=test123" in location
@pytest.mark.e2e
class TestTokenEndpointErrors:
"""E2E tests for token endpoint errors."""
def test_invalid_grant_type(self, error_client):
"""Test unsupported grant_type returns error."""
response = error_client.post("/token", data={
"grant_type": "client_credentials",
"code": "some_code",
"client_id": "https://app.example.com",
"redirect_uri": "https://app.example.com/callback",
})
assert response.status_code == 400
data = response.json()
assert data["detail"]["error"] == "unsupported_grant_type"
def test_missing_grant_type(self, error_client):
"""Test missing grant_type returns validation error."""
response = error_client.post("/token", data={
"code": "some_code",
"client_id": "https://app.example.com",
"redirect_uri": "https://app.example.com/callback",
})
# FastAPI validation error
assert response.status_code == 422
def test_nonexistent_code(self, error_client):
"""Test nonexistent authorization code returns error."""
response = error_client.post("/token", data={
"grant_type": "authorization_code",
"code": "completely_made_up_code_12345",
"client_id": "https://app.example.com",
"redirect_uri": "https://app.example.com/callback",
})
assert response.status_code == 400
data = response.json()
assert data["detail"]["error"] == "invalid_grant"
def test_get_method_not_allowed(self, error_client):
"""Test GET method not allowed on token endpoint."""
response = error_client.get("/token")
assert response.status_code == 405
@pytest.mark.e2e
class TestVerificationErrors:
"""E2E tests for verification endpoint errors."""
def test_invalid_me_url(self, error_client):
"""Test invalid me URL format."""
response = error_client.post(
"/api/verify/start",
data={"me": "not-a-url"}
)
assert response.status_code == 200
data = response.json()
assert data["success"] is False
assert data["error"] == "invalid_me_url"
def test_invalid_code_verification(self, error_client):
"""Test verification with invalid code."""
response = error_client.post(
"/api/verify/code",
data={"domain": "example.com", "code": "000000"}
)
assert response.status_code == 200
data = response.json()
assert data["success"] is False
@pytest.mark.e2e
class TestSecurityErrorHandling:
"""E2E tests for security-related error handling."""
def test_xss_in_state_escaped(self, error_client):
"""Test XSS attempt in state parameter is escaped."""
xss_payload = "<script>alert('xss')</script>"
response = error_client.get("/authorize", params={
"client_id": "https://app.example.com",
"redirect_uri": "https://app.example.com/callback",
"response_type": "token", # Will error and redirect
"state": xss_payload,
}, follow_redirects=False)
# Should redirect with error
assert response.status_code == 302
location = response.headers["location"]
# Script tags should be URL encoded, not raw
assert "<script>" not in location
def test_errors_have_security_headers(self, error_client):
"""Test error responses include security headers."""
response = error_client.get("/authorize") # Missing params = error
assert response.status_code == 400
assert "X-Frame-Options" in response.headers
assert response.headers["X-Frame-Options"] == "DENY"
def test_error_response_is_json_for_api(self, error_client):
"""Test API error responses are JSON formatted."""
response = error_client.post("/token", data={
"grant_type": "authorization_code",
"code": "invalid",
"client_id": "https://app.example.com",
"redirect_uri": "https://app.example.com/callback",
})
assert response.status_code == 400
# Should be JSON
assert "application/json" in response.headers["content-type"]
data = response.json()
assert "detail" in data
@pytest.mark.e2e
class TestEdgeCases:
"""E2E tests for edge cases."""
def test_empty_scope_accepted(self, error_client):
"""Test empty scope is accepted."""
from unittest.mock import AsyncMock, patch
from gondulf.services.happ_parser import ClientMetadata
metadata = ClientMetadata(
name="Test App",
url="https://app.example.com",
logo=None
)
with patch('gondulf.services.happ_parser.HAppParser.fetch_and_parse', new_callable=AsyncMock) as mock:
mock.return_value = metadata
response = error_client.get("/authorize", params={
"client_id": "https://app.example.com",
"redirect_uri": "https://app.example.com/callback",
"response_type": "code",
"state": "test",
"code_challenge": "abc123",
"code_challenge_method": "S256",
"me": "https://user.example.com",
"scope": "", # Empty scope
})
# Should show consent page
assert response.status_code == 200
def test_very_long_state_handled(self, error_client):
"""Test very long state parameter is handled."""
from unittest.mock import AsyncMock, patch
from gondulf.services.happ_parser import ClientMetadata
metadata = ClientMetadata(
name="Test App",
url="https://app.example.com",
logo=None
)
long_state = "x" * 1000
with patch('gondulf.services.happ_parser.HAppParser.fetch_and_parse', new_callable=AsyncMock) as mock:
mock.return_value = metadata
response = error_client.get("/authorize", params={
"client_id": "https://app.example.com",
"redirect_uri": "https://app.example.com/callback",
"response_type": "code",
"state": long_state,
"code_challenge": "abc123",
"code_challenge_method": "S256",
"me": "https://user.example.com",
})
# Should handle without error
assert response.status_code == 200