feat: RBAC E2E тесты и фикс admin-прав для редактирования постов

Основные изменения:
- Добавлены E2E тесты для проверки ownership (TC-E2E-102/103):
  * test_admin_can_edit_any_post — admin может редактировать любой пост
  * test_user_cannot_edit_other_users_post — user не может редактировать чужой пост
- Исправлены use cases (UpdatePost, DeletePost, PublishPost) — добавлена проверка роли admin
- Обновлены web routes и API routes для передачи роли в use cases
- Добавлены unit тесты для admin-сценариев

Реструктуризация тестов:
- Удалены старые API тесты (tests/api/) — требуют переработки
- Удалены старые integration тесты (tests/integration/)
- Переработаны E2E тесты: удалены старые, добавлены новые с POM
- Добавлена документация тестов: FEATURE_*.md, TEST_MODEL.md, AGENTS.md

Инфраструктура:
- Добавлен MockKeycloakClient для dev-режима
- Добавлены статические файлы: EasyMDE, Highlight.js, стили markdown
- Обновлены шаблоны: base.html, post_form.html, post_detail.html
- Обновлена DI конфигурация и провайдеры

Документация:
- tests/FEATURE_RBAC.md — матрица тестов RBAC
- tests/FEATURE_POST_LIFECYCLE.md — тесты жизненного цикла поста
- tests/FEATURE_DOMAIN_FOUNDATION.md — тесты доменного слоя
- tests/FEATURE_INFRASTRUCTURE.md — тесты инфраструктуры
- tests/TEST_MODEL.md — глобальная матрица покрытия
- app/presentation/web/AGENTS.md — гайд по Web UI
- tests/AGENTS.md — гайд по тестированию
This commit is contained in:
2026-05-07 19:55:15 +03:00
parent 41f2a3d98e
commit 46cc06b596
58 changed files with 4234 additions and 4014 deletions

View File

@@ -5,6 +5,7 @@ for token validation and user info retrieval.
"""
from app.infrastructure.auth.client import KeycloakAuthClient
from app.infrastructure.auth.mock_client import MockKeycloakClient
from app.infrastructure.auth.models import KeycloakUser, TokenInfo
__all__ = ["KeycloakAuthClient", "KeycloakUser", "TokenInfo"]
__all__ = ["KeycloakAuthClient", "KeycloakUser", "MockKeycloakClient", "TokenInfo"]

View File

@@ -0,0 +1,75 @@
"""Mock Keycloak client for development mode.
This module provides a mock Keycloak authentication client that bypasses
real Keycloak server authentication in development mode. It generates
token info based on dev-specific token formats.
"""
from app.infrastructure.auth.models import TokenInfo
class MockKeycloakClient:
"""Mock Keycloak client for development and testing.
Bypasses real Keycloak server authentication. Parses dev-specific
token formats to generate TokenInfo with configurable roles.
Attributes:
_settings: Application settings.
Example:
>>> client = MockKeycloakClient()
>>> token_info = await client.introspect_token("dev-token-admin")
"""
def __init__(self) -> None:
"""Initialize mock client."""
pass
async def introspect_token(self, token: str) -> TokenInfo:
"""Introspect token in dev mode.
If token starts with 'dev-token-', parses role from suffix.
Otherwise returns inactive token.
Args:
token: Access token string.
Returns:
TokenInfo with dev user data if dev token, inactive otherwise.
"""
dev_users: dict[str, dict[str, str]] = {
"dev-token-user": {
"user_id": "dev-user",
"username": "Dev User",
"email": "dev.user@example.com",
"role": "user",
},
"dev-token-user2": {
"user_id": "dev-user2",
"username": "Test User",
"email": "test.user@example.com",
"role": "user",
},
"dev-token-admin": {
"user_id": "dev-admin",
"username": "Dev Admin",
"email": "dev.admin@example.com",
"role": "admin",
},
}
if token == "dev-token-guest":
return TokenInfo(active=False)
if token in dev_users:
user = dev_users[token]
return TokenInfo(
active=True,
user_id=user["user_id"],
username=user["username"],
email=user["email"],
roles=[user["role"]],
)
return TokenInfo(active=False)

View File

@@ -19,7 +19,7 @@ from app.application import (
)
from app.application.interfaces import TransactionManager
from app.domain.repositories import PostRepository
from app.infrastructure.auth import KeycloakAuthClient
from app.infrastructure.auth import KeycloakAuthClient, MockKeycloakClient
from app.infrastructure.config.settings import settings
from app.infrastructure.database.connection import AsyncSessionLocal, engine
from app.infrastructure.repositories.post import SQLAlchemyPostRepository
@@ -241,7 +241,7 @@ class KeycloakProvider(Provider):
"""Provider for Keycloak authentication client.
Provides Keycloak client as application-scoped singleton.
Client is stateless and can be shared across requests.
In development mode uses MockKeycloakClient for local testing.
Example:
>>> provider = KeycloakProvider()
@@ -249,9 +249,14 @@ class KeycloakProvider(Provider):
@provide(scope=Scope.APP)
def get_keycloak_client(self) -> KeycloakAuthClient:
"""Provide KeycloakAuthClient singleton.
"""Provide KeycloakAuthClient or MockKeycloakClient singleton.
Returns MockKeycloakClient in dev mode for local testing
without a real Keycloak server.
Returns:
KeycloakAuthClient instance.
"""
if settings.is_dev:
return MockKeycloakClient() # type: ignore[return-value]
return KeycloakAuthClient(settings)

View File

@@ -37,17 +37,12 @@ class SessionTransactionManager(TransactionManager):
"""Commit the current transaction.
Persists all pending changes to the database.
Only commits once - subsequent calls are no-ops.
"""
if not self._committed:
await self._session.commit()
self._committed = True
await self._session.commit()
async def rollback(self) -> None:
"""Rollback the current transaction.
Discards all pending changes.
Only rolls back if not already committed.
"""
if not self._committed:
await self._session.rollback()
await self._session.rollback()

View File

@@ -179,7 +179,11 @@ class SQLAlchemyPostRepository(PostRepository):
Returns:
List of Post entities by the author.
"""
query = select(PostORM).where(PostORM.author_id == author_id)
query = (
select(PostORM)
.where(PostORM.author_id == author_id)
.order_by(PostORM.created_at.desc())
)
if limit is not None:
query = query.limit(limit)
if offset is not None:
@@ -202,7 +206,9 @@ class SQLAlchemyPostRepository(PostRepository):
Returns:
List of published Post entities.
"""
query = select(PostORM).where(PostORM.published.is_(True))
query = (
select(PostORM).where(PostORM.published.is_(True)).order_by(PostORM.created_at.desc())
)
if limit is not None:
query = query.limit(limit)
if offset is not None: