test: implement mock OAuth2 server infrastructure for auth testing
- Add MockOAuth2ServerContainer using testcontainers - Create conftest_oauth.py with OAuth test fixtures - Add comprehensive OAuth integration tests - Support both mock (default) and real (CI secrets) OAuth modes - Add documentation for OAuth testing setup Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
"""
|
||||
Integration tests for OAuth authentication flows using mock OAuth2 server.
|
||||
|
||||
These tests use a real OIDC flow with a mock OAuth2 server to test:
|
||||
- OAuth login initiation
|
||||
- Authorization code exchange
|
||||
- Token validation
|
||||
- Userinfo retrieval
|
||||
- Session management
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestOAuthLoginFlow:
|
||||
"""Test the complete OAuth login flow with mock server."""
|
||||
|
||||
def test_login_page_shows_oauth_option(self, oauth_enabled_app: TestClient):
|
||||
"""Test that login page displays OAuth option when configured."""
|
||||
response = oauth_enabled_app.get("/login")
|
||||
assert response.status_code == 200
|
||||
# Check that OAuth option is shown
|
||||
assert b"oauth" in response.content.lower() or b"sign" in response.content.lower()
|
||||
|
||||
def test_oauth_login_redirects_to_provider(
|
||||
self, oauth_enabled_app: TestClient, oauth_config: dict
|
||||
):
|
||||
"""Test that /oauth-login redirects to the OAuth provider."""
|
||||
response = oauth_enabled_app.get("/oauth-login", follow_redirects=False)
|
||||
|
||||
# Should redirect to authorization endpoint
|
||||
assert response.status_code == 302
|
||||
|
||||
# Redirect location should contain the authorization endpoint
|
||||
location = response.headers.get("location", "")
|
||||
if oauth_config["mode"] == "mock":
|
||||
assert "authorize" in location
|
||||
assert oauth_config["client_id"] in location
|
||||
|
||||
def test_oauth_login_without_config_shows_error(self):
|
||||
"""Test that OAuth login fails gracefully when not configured."""
|
||||
# Test with OAuth disabled
|
||||
import os
|
||||
original = os.environ.get("AUTH_ENABLED")
|
||||
os.environ["AUTH_ENABLED"] = "False"
|
||||
|
||||
try:
|
||||
from fastapi.testclient import TestClient
|
||||
from app.main import app
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/oauth-login", follow_redirects=False)
|
||||
# Should either redirect to error page or show login page
|
||||
assert response.status_code in [302, 404]
|
||||
finally:
|
||||
if original:
|
||||
os.environ["AUTH_ENABLED"] = original
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestOAuthCallback:
|
||||
"""Test OAuth callback handling."""
|
||||
|
||||
@patch("app.auth.oauth.authentik.authorize_access_token")
|
||||
async def test_oauth_callback_with_valid_token(
|
||||
self, mock_authorize, oauth_enabled_app: TestClient, test_user_info: dict
|
||||
):
|
||||
"""Test OAuth callback with valid authorization code."""
|
||||
# Mock the token exchange response
|
||||
mock_authorize.return_value = {
|
||||
"access_token": "mock-access-token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"userinfo": test_user_info,
|
||||
}
|
||||
|
||||
# Simulate OAuth callback with authorization code
|
||||
response = oauth_enabled_app.get(
|
||||
"/oauth-callback?code=test-auth-code&state=test-state",
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
# Should redirect after successful login
|
||||
assert response.status_code == 302
|
||||
|
||||
@patch("app.auth.oauth.authentik.authorize_access_token")
|
||||
async def test_oauth_callback_stores_user_in_session(
|
||||
self, mock_authorize, oauth_enabled_app: TestClient, test_user_info: dict
|
||||
):
|
||||
"""Test that OAuth callback stores user info in session."""
|
||||
mock_authorize.return_value = {
|
||||
"access_token": "mock-access-token",
|
||||
"userinfo": test_user_info,
|
||||
}
|
||||
|
||||
# First, initiate OAuth flow to set up session
|
||||
oauth_enabled_app.get("/oauth-login", follow_redirects=False)
|
||||
|
||||
# Then handle callback
|
||||
response = oauth_enabled_app.get(
|
||||
"/oauth-callback?code=test-auth-code",
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
# Should set session cookie
|
||||
assert "set-cookie" in response.headers or response.status_code == 302
|
||||
|
||||
@patch("app.auth.oauth.authentik.authorize_access_token")
|
||||
async def test_oauth_callback_with_admin_user(
|
||||
self, mock_authorize, oauth_enabled_app: TestClient
|
||||
):
|
||||
"""Test OAuth callback with admin user group."""
|
||||
mock_authorize.return_value = {
|
||||
"access_token": "mock-access-token",
|
||||
"userinfo": {
|
||||
"sub": "admin-user",
|
||||
"email": "admin@example.com",
|
||||
"name": "Admin User",
|
||||
"groups": ["admin"],
|
||||
},
|
||||
}
|
||||
|
||||
response = oauth_enabled_app.get(
|
||||
"/oauth-callback?code=test-auth-code",
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
# Should successfully authenticate
|
||||
assert response.status_code == 302
|
||||
|
||||
@patch("app.auth.oauth.authentik.authorize_access_token")
|
||||
async def test_oauth_callback_rejects_non_admin(
|
||||
self, mock_authorize, oauth_enabled_app: TestClient
|
||||
):
|
||||
"""Test that OAuth callback rejects users without admin group."""
|
||||
mock_authorize.return_value = {
|
||||
"access_token": "mock-access-token",
|
||||
"userinfo": {
|
||||
"sub": "regular-user",
|
||||
"email": "user@example.com",
|
||||
"name": "Regular User",
|
||||
"groups": ["users"], # No admin group
|
||||
},
|
||||
}
|
||||
|
||||
response = oauth_enabled_app.get(
|
||||
"/oauth-callback?code=test-auth-code",
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
# Should redirect to error page
|
||||
assert response.status_code == 302
|
||||
assert "error" in response.headers.get("location", "").lower()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestOAuthSessionManagement:
|
||||
"""Test session management with OAuth authentication."""
|
||||
|
||||
@patch("app.auth.oauth.authentik.authorize_access_token")
|
||||
async def test_authenticated_user_can_access_protected_routes(
|
||||
self, mock_authorize, oauth_enabled_app: TestClient, test_user_info: dict
|
||||
):
|
||||
"""Test that authenticated users can access protected routes."""
|
||||
# Mock successful authentication
|
||||
mock_authorize.return_value = {
|
||||
"access_token": "mock-access-token",
|
||||
"userinfo": test_user_info,
|
||||
}
|
||||
|
||||
# Authenticate
|
||||
oauth_enabled_app.get("/oauth-callback?code=test-auth-code")
|
||||
|
||||
# Try to access a protected route (e.g., files page)
|
||||
response = oauth_enabled_app.get("/files")
|
||||
|
||||
# Should be able to access with valid session
|
||||
# Note: May redirect to login if session not properly set
|
||||
assert response.status_code in [200, 302]
|
||||
|
||||
def test_unauthenticated_user_redirected_to_login(
|
||||
self, oauth_enabled_app: TestClient
|
||||
):
|
||||
"""Test that unauthenticated users are redirected to login."""
|
||||
# Try to access protected route without authentication
|
||||
response = oauth_enabled_app.get("/files", follow_redirects=False)
|
||||
|
||||
# Should redirect to login page
|
||||
if response.status_code == 302:
|
||||
assert "/login" in response.headers.get("location", "")
|
||||
|
||||
@patch("app.auth.oauth.authentik.authorize_access_token")
|
||||
async def test_logout_clears_session(
|
||||
self, mock_authorize, oauth_enabled_app: TestClient, test_user_info: dict
|
||||
):
|
||||
"""Test that logout clears user session."""
|
||||
# Mock successful authentication
|
||||
mock_authorize.return_value = {
|
||||
"access_token": "mock-access-token",
|
||||
"userinfo": test_user_info,
|
||||
}
|
||||
|
||||
# Authenticate
|
||||
oauth_enabled_app.get("/oauth-callback?code=test-auth-code")
|
||||
|
||||
# Logout
|
||||
response = oauth_enabled_app.get("/logout", follow_redirects=False)
|
||||
|
||||
# Should redirect after logout
|
||||
assert response.status_code == 302
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestOAuthErrorHandling:
|
||||
"""Test error handling in OAuth flows."""
|
||||
|
||||
def test_oauth_callback_without_code_shows_error(
|
||||
self, oauth_enabled_app: TestClient
|
||||
):
|
||||
"""Test OAuth callback without authorization code."""
|
||||
response = oauth_enabled_app.get("/oauth-callback", follow_redirects=False)
|
||||
|
||||
# Should handle error gracefully
|
||||
assert response.status_code in [302, 400]
|
||||
|
||||
@patch("app.auth.oauth.authentik.authorize_access_token")
|
||||
async def test_oauth_callback_with_invalid_token(
|
||||
self, mock_authorize, oauth_enabled_app: TestClient
|
||||
):
|
||||
"""Test OAuth callback with invalid token."""
|
||||
# Mock token exchange failure
|
||||
mock_authorize.side_effect = Exception("Invalid authorization code")
|
||||
|
||||
response = oauth_enabled_app.get(
|
||||
"/oauth-callback?code=invalid-code",
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
# Should redirect to error page
|
||||
assert response.status_code == 302
|
||||
location = response.headers.get("location", "")
|
||||
assert "error" in location.lower() or "login" in location.lower()
|
||||
|
||||
@patch("app.auth.oauth.authentik.authorize_access_token")
|
||||
async def test_oauth_callback_without_userinfo(
|
||||
self, mock_authorize, oauth_enabled_app: TestClient
|
||||
):
|
||||
"""Test OAuth callback when userinfo is missing."""
|
||||
# Mock token without userinfo
|
||||
mock_authorize.return_value = {
|
||||
"access_token": "mock-access-token",
|
||||
"userinfo": None,
|
||||
}
|
||||
|
||||
response = oauth_enabled_app.get(
|
||||
"/oauth-callback?code=test-auth-code",
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
# Should handle missing userinfo
|
||||
assert response.status_code == 302
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.requires_external
|
||||
class TestRealOAuthIntegration:
|
||||
"""
|
||||
Integration tests using real OAuth credentials from GitHub Actions secrets.
|
||||
|
||||
These tests are skipped unless real OAuth credentials are available.
|
||||
"""
|
||||
|
||||
def test_real_oauth_well_known_endpoint(self, use_real_oauth: bool, oauth_config: dict):
|
||||
"""Test that real OAuth .well-known endpoint is accessible."""
|
||||
if not use_real_oauth:
|
||||
pytest.skip("Real OAuth credentials not available")
|
||||
|
||||
import requests
|
||||
response = requests.get(oauth_config["server_metadata_url"], timeout=10)
|
||||
assert response.status_code == 200
|
||||
|
||||
config = response.json()
|
||||
assert "authorization_endpoint" in config
|
||||
assert "token_endpoint" in config
|
||||
assert "userinfo_endpoint" in config
|
||||
|
||||
def test_real_oauth_jwks_endpoint(self, use_real_oauth: bool, oauth_config: dict):
|
||||
"""Test that real OAuth JWKS endpoint is accessible."""
|
||||
if not use_real_oauth:
|
||||
pytest.skip("Real OAuth credentials not available")
|
||||
|
||||
import requests
|
||||
# Get well-known config first
|
||||
response = requests.get(oauth_config["server_metadata_url"], timeout=10)
|
||||
config = response.json()
|
||||
|
||||
# Test JWKS endpoint
|
||||
jwks_response = requests.get(config["jwks_uri"], timeout=10)
|
||||
assert jwks_response.status_code == 200
|
||||
|
||||
jwks = jwks_response.json()
|
||||
assert "keys" in jwks
|
||||
assert len(jwks["keys"]) > 0
|
||||
Reference in New Issue
Block a user