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:
337
tests/integration/api/test_authorization_flow.py
Normal file
337
tests/integration/api/test_authorization_flow.py
Normal 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
|
||||
Reference in New Issue
Block a user