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