Files
blog.pyaqa.ru/tests/e2e/fake_keycloak.py
Sergey Vanyushkin 41f2a3d98e Add comprehensive API authorization tests and E2E test infrastructure
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
2026-05-03 22:34:32 +03:00

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()