""" 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