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
228 lines
6.1 KiB
Python
228 lines
6.1 KiB
Python
"""Fake Keycloak client for E2E testing.
|
|
|
|
This module provides a mock implementation of KeycloakAuthClient
|
|
that doesn't require a real Keycloak server. Stores users and tokens
|
|
in memory for fast, isolated testing.
|
|
"""
|
|
|
|
import secrets
|
|
import time
|
|
import uuid
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
from app.infrastructure.auth.models import KeycloakUser, TokenInfo
|
|
|
|
|
|
@dataclass
|
|
class TestUser:
|
|
"""Test user data for fake Keycloak.
|
|
|
|
Stores user credentials and profile information.
|
|
|
|
Attributes:
|
|
id: Unique user identifier.
|
|
username: User login name.
|
|
email: User email address.
|
|
password: User password (plaintext for testing).
|
|
roles: List of user roles.
|
|
first_name: User first name.
|
|
last_name: User last name.
|
|
"""
|
|
|
|
id: str
|
|
username: str
|
|
email: str
|
|
password: str
|
|
roles: list[str] = field(default_factory=list)
|
|
first_name: str = ""
|
|
last_name: str = ""
|
|
|
|
|
|
class FakeKeycloakClient:
|
|
"""In-memory Keycloak client for E2E testing.
|
|
|
|
Mimics KeycloakAuthClient interface without external dependencies.
|
|
Stores users and tokens in memory. Tokens are simple strings
|
|
that can be validated locally.
|
|
|
|
Attributes:
|
|
_users: Dictionary of users by username.
|
|
_tokens: Dictionary of active tokens to user IDs.
|
|
_token_ttl: Token time-to-live in seconds.
|
|
|
|
Example:
|
|
>>> client = FakeKeycloakClient()
|
|
>>> user = client.create_user("john", "pass", ["user"])
|
|
>>> token = client.login("john", "pass")
|
|
>>> info = await client.introspect_token(token)
|
|
>>> assert info.active
|
|
"""
|
|
|
|
def __init__(self, token_ttl: int = 3600) -> None:
|
|
"""Initialize fake Keycloak client.
|
|
|
|
Args:
|
|
token_ttl: Token lifetime in seconds (default: 1 hour).
|
|
"""
|
|
self._users: dict[str, TestUser] = {}
|
|
self._tokens: dict[str, tuple[str, float]] = {} # token -> (user_id, issued_at)
|
|
self._token_ttl = token_ttl
|
|
|
|
def create_user(
|
|
self,
|
|
username: str,
|
|
password: str,
|
|
roles: list[str] | None = None,
|
|
email: str | None = None,
|
|
first_name: str = "",
|
|
last_name: str = "",
|
|
) -> TestUser:
|
|
"""Create a new test user.
|
|
|
|
Args:
|
|
username: Unique username.
|
|
password: User password.
|
|
roles: List of roles (default: ["user"]).
|
|
email: User email (default: username@test.com).
|
|
first_name: First name.
|
|
last_name: Last name.
|
|
|
|
Returns:
|
|
Created TestUser instance.
|
|
|
|
Raises:
|
|
ValueError: If username already exists.
|
|
"""
|
|
if username in self._users:
|
|
raise ValueError(f"User {username} already exists")
|
|
|
|
user = TestUser(
|
|
id=str(uuid.uuid4()),
|
|
username=username,
|
|
email=email or f"{username}@test.com",
|
|
password=password,
|
|
roles=roles or ["user"],
|
|
first_name=first_name,
|
|
last_name=last_name,
|
|
)
|
|
self._users[username] = user
|
|
return user
|
|
|
|
def login(self, username: str, password: str) -> str:
|
|
"""Authenticate user and return access token.
|
|
|
|
Args:
|
|
username: User login name.
|
|
password: User password.
|
|
|
|
Returns:
|
|
Access token string.
|
|
|
|
Raises:
|
|
ValueError: If credentials are invalid.
|
|
"""
|
|
user = self._users.get(username)
|
|
if not user or user.password != password:
|
|
raise ValueError("Invalid credentials")
|
|
|
|
token = secrets.token_urlsafe(32)
|
|
self._tokens[token] = (user.id, time.time())
|
|
return token
|
|
|
|
def logout(self, token: str) -> None:
|
|
"""Invalidate access token.
|
|
|
|
Args:
|
|
token: Token to invalidate.
|
|
"""
|
|
self._tokens.pop(token, None)
|
|
|
|
def _get_token_user(self, token: str) -> TestUser | None:
|
|
"""Get user associated with token if valid.
|
|
|
|
Args:
|
|
token: Access token to validate.
|
|
|
|
Returns:
|
|
User if token is valid and not expired, None otherwise.
|
|
"""
|
|
if token not in self._tokens:
|
|
return None
|
|
|
|
user_id, issued_at = self._tokens[token]
|
|
if time.time() - issued_at > self._token_ttl:
|
|
del self._tokens[token]
|
|
return None
|
|
|
|
for user in self._users.values():
|
|
if user.id == user_id:
|
|
return user
|
|
|
|
return None
|
|
|
|
async def introspect_token(self, token: str) -> TokenInfo:
|
|
"""Validate token and return token info.
|
|
|
|
Mimics Keycloak token introspection endpoint.
|
|
|
|
Args:
|
|
token: Access token to validate.
|
|
|
|
Returns:
|
|
TokenInfo with validation result.
|
|
"""
|
|
user = self._get_token_user(token)
|
|
|
|
if not user:
|
|
return TokenInfo(active=False, raw_claims={"error": "invalid_token"})
|
|
|
|
raw_claims: dict[str, Any] = {
|
|
"sub": user.id,
|
|
"preferred_username": user.username,
|
|
"email": user.email,
|
|
"realm_access": {"roles": user.roles},
|
|
}
|
|
|
|
return TokenInfo(
|
|
active=True,
|
|
user_id=user.id,
|
|
username=user.username,
|
|
email=user.email,
|
|
roles=user.roles,
|
|
raw_claims=raw_claims,
|
|
)
|
|
|
|
async def get_userinfo(self, token: str) -> KeycloakUser | None:
|
|
"""Get user info from token.
|
|
|
|
Mimics Keycloak userinfo endpoint.
|
|
|
|
Args:
|
|
token: Valid access token.
|
|
|
|
Returns:
|
|
KeycloakUser if token is valid, None otherwise.
|
|
"""
|
|
user = self._get_token_user(token)
|
|
|
|
if not user:
|
|
return None
|
|
|
|
return KeycloakUser(
|
|
id=user.id,
|
|
username=user.username,
|
|
email=user.email,
|
|
first_name=user.first_name,
|
|
last_name=user.last_name,
|
|
roles=user.roles,
|
|
)
|
|
|
|
def clear(self) -> None:
|
|
"""Clear all users and tokens.
|
|
|
|
Useful for cleanup between tests.
|
|
"""
|
|
self._users.clear()
|
|
self._tokens.clear()
|