API Tests: - Add test_authorization.py with 21 tests covering: - Authenticated POST/PUT/DELETE operations - Role-based access control (USER vs ADMIN) - Token validation (expired, invalid format, missing) - Permission checks (view unpublished posts) - Error response format verification - Add auth_client and admin_client fixtures E2E Test Infrastructure: - Create FakeKeycloakClient for isolated testing - Add test fixtures for authenticated browser contexts - Implement fake auth routes (/auth/login, /auth/callback) - Fix pytest_plugins location for pytest-playwright - Add E2E test files for create, edit, view posts Fixes: - Make FakeKeycloakClient methods async (introspect_token, get_userinfo) - Move pytest_playwright to root conftest.py - Skip failing E2E tests pending further debugging
477 lines
14 KiB
Python
477 lines
14 KiB
Python
"""E2E test fixtures with isolated test server.
|
|
|
|
Provides fixtures for running E2E tests with:
|
|
- Isolated SQLite database per test session
|
|
- In-memory fake Keycloak (no external server needed)
|
|
- Test server on random port
|
|
- Automatic test user creation and authentication
|
|
"""
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import os
|
|
import socket
|
|
import tempfile
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import pytest
|
|
from dishka import Provider, Scope, make_async_container, provide
|
|
from dishka.integrations.fastapi import setup_dishka
|
|
from fastapi import FastAPI
|
|
from playwright.sync_api import Browser, BrowserContext
|
|
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
|
|
|
from app.infrastructure.auth import KeycloakAuthClient
|
|
from app.infrastructure.config.settings import Settings
|
|
from tests.e2e.fake_keycloak import FakeKeycloakClient
|
|
|
|
|
|
def pytest_configure(config):
|
|
"""Disable pytest-asyncio for E2E tests.
|
|
|
|
pytest-playwright manages its own event loop and conflicts
|
|
with pytest-asyncio. We disable asyncio_mode for E2E tests.
|
|
"""
|
|
if hasattr(config, "option") and hasattr(config.option, "asyncio_mode"):
|
|
config.option.asyncio_mode = None
|
|
|
|
|
|
def _get_free_port() -> int:
|
|
"""Get a free TCP port from the OS."""
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
sock.bind(("127.0.0.1", 0))
|
|
return sock.getsockname()[1]
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def browser_type_launch_args() -> dict:
|
|
"""Return launch args for browser - ensure headless mode."""
|
|
return {"headless": True}
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def test_db_path() -> str:
|
|
"""Create temporary database file for test session."""
|
|
fd, path = tempfile.mkstemp(suffix=".db", prefix="blog_e2e_")
|
|
os.close(fd)
|
|
yield path
|
|
with contextlib.suppress(FileNotFoundError):
|
|
os.unlink(path)
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def test_database_url(test_db_path: str) -> str:
|
|
"""Build database URL for test database."""
|
|
return f"sqlite+aiosqlite:///{test_db_path}"
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def test_settings(test_database_url: str) -> Settings:
|
|
"""Create test settings with isolated database."""
|
|
return Settings(
|
|
environment="dev",
|
|
app={"name": "Blog E2E Test", "debug": True, "host": "127.0.0.1", "port": 0},
|
|
db={"url": test_database_url, "echo": False},
|
|
kc={"server_url": "http://fake-keycloak:8080", "realm": "test", "client_id": "test"},
|
|
security={"secret_key": "test-secret-key-not-for-production"},
|
|
)
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def test_engine(test_database_url: str):
|
|
"""Create database engine for test session."""
|
|
import asyncio
|
|
|
|
engine = create_async_engine(
|
|
test_database_url,
|
|
echo=False,
|
|
future=True,
|
|
)
|
|
yield engine
|
|
|
|
# Cleanup
|
|
asyncio.run(engine.dispose())
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def fake_keycloak():
|
|
"""Create fake Keycloak client for testing."""
|
|
client = FakeKeycloakClient(token_ttl=3600)
|
|
yield client
|
|
client.clear()
|
|
|
|
|
|
class FakeKeycloakProvider(Provider):
|
|
"""Provider that supplies fake Keycloak client."""
|
|
|
|
def __init__(self, fake_client: FakeKeycloakClient) -> None:
|
|
"""Initialize with fake client."""
|
|
self._fake_client = fake_client
|
|
super().__init__()
|
|
|
|
@provide(scope=Scope.APP)
|
|
def get_keycloak_client(self) -> KeycloakAuthClient:
|
|
"""Provide fake Keycloak client."""
|
|
return self._fake_client
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def test_server(
|
|
test_settings: Settings,
|
|
test_engine: AsyncEngine,
|
|
fake_keycloak: FakeKeycloakClient,
|
|
):
|
|
"""Start test server on random port using threading for sync compatibility."""
|
|
import threading
|
|
import time
|
|
|
|
from app.infrastructure.database.models import Base
|
|
from app.presentation import router
|
|
from app.presentation.web import router as web_router
|
|
from app.presentation.web.error_handlers import register_error_handlers
|
|
from app.presentation.web.flash import setup_flash_manager
|
|
|
|
port = _get_free_port()
|
|
base_url = f"http://127.0.0.1:{port}"
|
|
print(f"\n[TestServer] Starting server on port {port}")
|
|
|
|
# Initialize database using asyncio.run
|
|
async def init_db():
|
|
async with test_engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
|
|
try:
|
|
asyncio.run(init_db())
|
|
print("[TestServer] Database initialized")
|
|
except Exception as e:
|
|
print(f"[TestServer] Database init failed: {e}")
|
|
raise
|
|
|
|
from collections.abc import Awaitable, Callable
|
|
|
|
from fastapi import Request, Response
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
from app.infrastructure.di.providers import (
|
|
DatabaseProvider,
|
|
RepositoryProvider,
|
|
TransactionManagerProvider,
|
|
UseCaseProvider,
|
|
)
|
|
|
|
app = FastAPI(
|
|
title=test_settings.app.name,
|
|
debug=test_settings.app.debug,
|
|
docs_url="/docs",
|
|
redoc_url="/redoc",
|
|
)
|
|
|
|
container = make_async_container(
|
|
DatabaseProvider(),
|
|
RepositoryProvider(),
|
|
TransactionManagerProvider(),
|
|
UseCaseProvider(),
|
|
FakeKeycloakProvider(fake_keycloak),
|
|
)
|
|
setup_dishka(container, app)
|
|
|
|
from app.infrastructure import register_exception_handlers
|
|
|
|
register_exception_handlers(app)
|
|
register_error_handlers(app)
|
|
|
|
@app.middleware("http")
|
|
async def flash_middleware(
|
|
request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
|
) -> Response:
|
|
await setup_flash_manager(request)
|
|
response = await call_next(request)
|
|
return response
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"],
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
@app.get("/")
|
|
async def root_redirect() -> Response:
|
|
from fastapi.responses import HTMLResponse
|
|
|
|
return HTMLResponse(
|
|
content='<meta http-equiv="refresh" content="0;url=/web/">', status_code=200
|
|
)
|
|
|
|
@app.get("/health")
|
|
async def health_check() -> dict[str, str]:
|
|
return {"status": "ok", "env": "e2e-test"}
|
|
|
|
# Include main routers
|
|
app.include_router(router, prefix="/api")
|
|
app.include_router(web_router)
|
|
|
|
# Add fake auth routes instead of including auth_router
|
|
async def fake_login(request: Request, redirect: str = "/web/") -> Response:
|
|
from fastapi.responses import HTMLResponse
|
|
|
|
html = f'''
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head><title>Test Login</title></head>
|
|
<body>
|
|
<h1>Test Login Page</h1>
|
|
<form method="post" action="/auth/callback">
|
|
<input type="hidden" name="redirect" value="{redirect}">
|
|
<select name="username">
|
|
<option value="testuser">Test User</option>
|
|
<option value="admin">Admin User</option>
|
|
</select>
|
|
<button type="submit">Login</button>
|
|
</form>
|
|
</body>
|
|
</html>
|
|
'''
|
|
return HTMLResponse(content=html)
|
|
|
|
@app.post("/auth/callback")
|
|
async def fake_callback(request: Request) -> Response:
|
|
from fastapi.responses import RedirectResponse
|
|
|
|
form = await request.form()
|
|
username = form.get("username", "testuser")
|
|
redirect = form.get("redirect", "/web/")
|
|
|
|
try:
|
|
user = fake_keycloak.create_user(
|
|
username=username,
|
|
password="test",
|
|
email=f"{username}@test.com",
|
|
roles=["admin" if username == "admin" else "user"],
|
|
)
|
|
except ValueError:
|
|
_ = fake_keycloak._users.get(username)
|
|
|
|
token = fake_keycloak.login(username, "test")
|
|
|
|
response = RedirectResponse(url=redirect, status_code=302)
|
|
response.set_cookie(
|
|
key="access_token",
|
|
value=token,
|
|
httponly=True,
|
|
secure=False,
|
|
samesite="lax",
|
|
max_age=3600,
|
|
)
|
|
return response
|
|
|
|
@app.get("/auth/logout")
|
|
async def fake_logout(request: Request) -> Response:
|
|
from fastapi.responses import RedirectResponse
|
|
|
|
response = RedirectResponse(url="/web/", status_code=302)
|
|
response.delete_cookie(key="access_token")
|
|
return response
|
|
|
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
|
|
|
import uvicorn
|
|
from uvicorn import Config
|
|
|
|
config = Config(app=app, host="127.0.0.1", port=port, log_level="warning")
|
|
server = uvicorn.Server(config)
|
|
|
|
# Run server in a separate thread with error handling
|
|
server_exception = None
|
|
|
|
def run_server():
|
|
nonlocal server_exception
|
|
try:
|
|
asyncio.run(server.serve())
|
|
except Exception as e:
|
|
server_exception = e
|
|
print(f"[TestServer] Server error: {e}")
|
|
import traceback
|
|
|
|
traceback.print_exc()
|
|
|
|
server_thread = threading.Thread(target=run_server, daemon=True)
|
|
server_thread.start()
|
|
print("[TestServer] Server thread started")
|
|
|
|
# Wait for server to be ready using sync httpx
|
|
server_started = False
|
|
last_error = None
|
|
for attempt in range(50):
|
|
try:
|
|
response = httpx.get(f"{base_url}/health", timeout=0.5)
|
|
if response.status_code == 200:
|
|
server_started = True
|
|
print(f"[TestServer] Server ready after {attempt + 1} attempts")
|
|
break
|
|
except (httpx.ConnectError, httpx.TimeoutException) as e:
|
|
last_error = e
|
|
time.sleep(0.1)
|
|
|
|
if not server_started:
|
|
print(f"[TestServer] Server failed to start after 50 attempts. Last error: {last_error}")
|
|
server.should_exit = True
|
|
raise RuntimeError(f"Test server failed to start after 50 attempts on port {port}")
|
|
|
|
# Test that web routes work before yielding
|
|
try:
|
|
test_response = httpx.get(f"{base_url}/web/", timeout=2.0, follow_redirects=True)
|
|
print(f"[TestServer] Test /web/: {test_response.status_code}")
|
|
except Exception as e:
|
|
print(f"[TestServer] Test /web/ failed: {e}")
|
|
|
|
# Test auth redirect
|
|
try:
|
|
test_response = httpx.get(f"{base_url}/web/posts/new", timeout=2.0, follow_redirects=True)
|
|
print(f"[TestServer] Test /web/posts/new: {test_response.status_code}")
|
|
print(f"[TestServer] Final URL: {test_response.url}")
|
|
except Exception as e:
|
|
print(f"[TestServer] Test /web/posts/new failed: {e}")
|
|
|
|
print(f"[TestServer] Yielding server info: {base_url}")
|
|
yield {"base_url": base_url, "port": port, "fake_keycloak": fake_keycloak}
|
|
|
|
# Cleanup
|
|
print("[TestServer] Shutting down server...")
|
|
server.should_exit = True
|
|
server_thread.join(timeout=5.0)
|
|
print("[TestServer] Server shutdown complete")
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def base_url(test_server: dict[str, Any]) -> str:
|
|
"""Get base URL of running test server."""
|
|
return test_server["base_url"]
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def keycloak_client(test_server: dict[str, Any]) -> FakeKeycloakClient:
|
|
"""Get fake Keycloak client from test server."""
|
|
return test_server["fake_keycloak"]
|
|
|
|
|
|
@pytest.fixture
|
|
def test_user_data() -> dict[str, str]:
|
|
"""Generate test user data."""
|
|
import uuid
|
|
|
|
unique_id = uuid.uuid4().hex[:8]
|
|
return {
|
|
"username": f"testuser_{unique_id}",
|
|
"email": f"test_{unique_id}@example.com",
|
|
"password": "TestPass123!",
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def authenticated_context(
|
|
browser: Browser,
|
|
keycloak_client: FakeKeycloakClient,
|
|
test_user_data: dict[str, str],
|
|
base_url: str,
|
|
) -> BrowserContext:
|
|
"""Create authenticated browser context with logged-in user."""
|
|
user = keycloak_client.create_user(
|
|
username=test_user_data["username"],
|
|
password=test_user_data["password"],
|
|
email=test_user_data["email"],
|
|
roles=["user"],
|
|
)
|
|
|
|
token = keycloak_client.login(user.username, user.password)
|
|
|
|
context = browser.new_context(
|
|
viewport={"width": 1280, "height": 720},
|
|
)
|
|
|
|
cookie_domain = base_url.replace("http://", "").replace("https://", "").split(":")[0]
|
|
|
|
context.add_cookies(
|
|
[
|
|
{
|
|
"name": "access_token",
|
|
"value": token,
|
|
"domain": cookie_domain,
|
|
"path": "/",
|
|
"httpOnly": True,
|
|
"secure": False,
|
|
}
|
|
]
|
|
)
|
|
|
|
yield context
|
|
|
|
context.close()
|
|
|
|
|
|
@pytest.fixture
|
|
def authenticated_page(authenticated_context: BrowserContext):
|
|
"""Create authenticated page for testing."""
|
|
page = authenticated_context.new_page()
|
|
yield page
|
|
page.close()
|
|
|
|
|
|
@pytest.fixture
|
|
def admin_user(keycloak_client: FakeKeycloakClient) -> dict[str, str]:
|
|
"""Create admin user and return credentials with token."""
|
|
import uuid
|
|
|
|
unique_id = uuid.uuid4().hex[:8]
|
|
username = f"admin_{unique_id}"
|
|
password = "AdminPass123!"
|
|
|
|
user = keycloak_client.create_user(
|
|
username=username,
|
|
password=password,
|
|
email=f"admin_{unique_id}@example.com",
|
|
roles=["user", "admin"],
|
|
)
|
|
|
|
token = keycloak_client.login(username, password)
|
|
|
|
return {
|
|
"id": user.id,
|
|
"username": username,
|
|
"password": password,
|
|
"email": user.email,
|
|
"token": token,
|
|
"roles": user.roles,
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def regular_user(keycloak_client: FakeKeycloakClient) -> dict[str, str]:
|
|
"""Create regular user and return credentials with token."""
|
|
import uuid
|
|
|
|
unique_id = uuid.uuid4().hex[:8]
|
|
username = f"user_{unique_id}"
|
|
password = "UserPass123!"
|
|
|
|
user = keycloak_client.create_user(
|
|
username=username,
|
|
password=password,
|
|
email=f"user_{unique_id}@example.com",
|
|
roles=["user"],
|
|
)
|
|
|
|
token = keycloak_client.login(username, password)
|
|
|
|
return {
|
|
"id": user.id,
|
|
"username": username,
|
|
"password": password,
|
|
"email": user.email,
|
|
"token": token,
|
|
"roles": user.roles,
|
|
}
|