Files
blog.pyaqa.ru/tests/api/test_authorization.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

448 lines
15 KiB
Python

"""API authorization and role-based access control tests."""
from datetime import datetime
from unittest.mock import AsyncMock, patch
from uuid import uuid4
from httpx import ASGITransport, AsyncClient
from app.application.dtos import PostResponseDTO
from app.domain.exceptions import ForbiddenException
from app.infrastructure.auth.models import TokenInfo
from app.main import app_factory
class TestCreatePostAuthorization:
"""Test suite for POST /api/v1/posts authorization."""
async def test_create_post_with_user_role_success(
self,
auth_client: AsyncClient,
) -> None:
"""Test authenticated user can create post."""
post_id = uuid4()
mock_result = PostResponseDTO(
id=post_id,
title="New Post",
content="Post content here",
slug="new-post",
author_id="test-user-id",
published=False,
tags=[],
created_at=datetime.now(),
updated_at=datetime.now(),
)
with patch(
"app.application.use_cases.create_post.CreatePostUseCase.execute",
return_value=mock_result,
):
response = await auth_client.post(
"/api/v1/posts",
json={
"title": "New Post",
"content": "Post content here",
"tags": [],
},
)
assert response.status_code == 201
data = response.json()
assert data["title"] == "New Post"
assert data["author_id"] == "test-user-id"
async def test_create_post_without_auth_returns_401(self) -> None:
"""Test unauthenticated request returns 401."""
app = app_factory()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/posts",
json={
"title": "New Post",
"content": "Post content here",
},
)
assert response.status_code == 401
class TestUpdatePostAuthorization:
"""Test suite for PATCH /api/v1/posts/{post_id} authorization."""
async def test_update_own_post_with_user_role_success(
self,
auth_client: AsyncClient,
) -> None:
"""Test user can update their own post."""
post_id = uuid4()
mock_result = PostResponseDTO(
id=post_id,
title="Updated Title",
content="Original content",
slug="updated-title",
author_id="test-user-id",
published=True,
tags=[],
created_at=datetime.now(),
updated_at=datetime.now(),
)
with patch(
"app.application.use_cases.update_post.UpdatePostUseCase.execute",
return_value=mock_result,
):
response = await auth_client.patch(
f"/api/v1/posts/{post_id}",
json={"title": "Updated Title"},
)
assert response.status_code == 200
data = response.json()
assert data["title"] == "Updated Title"
async def test_update_post_without_auth_returns_401(self) -> None:
"""Test unauthenticated request returns 401."""
app = app_factory()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.patch(
f"/api/v1/posts/{uuid4()}",
json={"title": "Updated Title"},
)
assert response.status_code == 401
async def test_update_other_user_post_returns_403(
self,
auth_client: AsyncClient,
) -> None:
"""Test user cannot update another user's post."""
post_id = uuid4()
with patch(
"app.application.use_cases.update_post.UpdatePostUseCase.execute",
side_effect=ForbiddenException("Can only update own posts"),
):
response = await auth_client.patch(
f"/api/v1/posts/{post_id}",
json={"title": "Updated Title"},
)
assert response.status_code == 403
class TestDeletePostAuthorization:
"""Test suite for DELETE /api/v1/posts/{post_id} authorization."""
async def test_delete_own_post_with_user_role_success(
self,
auth_client: AsyncClient,
) -> None:
"""Test user can delete their own post."""
post_id = uuid4()
with patch(
"app.application.use_cases.delete_post.DeletePostUseCase.execute",
return_value=None,
):
response = await auth_client.delete(f"/api/v1/posts/{post_id}")
assert response.status_code == 204
async def test_delete_post_without_auth_returns_401(self) -> None:
"""Test unauthenticated request returns 401."""
app = app_factory()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.delete(f"/api/v1/posts/{uuid4()}")
assert response.status_code == 401
async def test_delete_other_user_post_returns_403(
self,
auth_client: AsyncClient,
) -> None:
"""Test user cannot delete another user's post."""
post_id = uuid4()
with patch(
"app.application.use_cases.delete_post.DeletePostUseCase.execute",
side_effect=ForbiddenException("Can only delete own posts"),
):
response = await auth_client.delete(f"/api/v1/posts/{post_id}")
assert response.status_code == 403
class TestPublishUnpublishAuthorization:
"""Test suite for publish/unpublish endpoints authorization."""
async def test_publish_own_post_with_user_role_success(
self,
auth_client: AsyncClient,
) -> None:
"""Test user can publish their own post."""
post_id = uuid4()
mock_result = PostResponseDTO(
id=post_id,
title="Test Post",
content="Content",
slug="test-post",
author_id="test-user-id",
published=True,
tags=[],
created_at=datetime.now(),
updated_at=datetime.now(),
)
with patch(
"app.application.use_cases.publish_post.PublishPostUseCase.publish",
return_value=mock_result,
):
response = await auth_client.post(f"/api/v1/posts/{post_id}/publish")
assert response.status_code == 200
data = response.json()
assert data["published"] is True
async def test_unpublish_own_post_with_user_role_success(
self,
auth_client: AsyncClient,
) -> None:
"""Test user can unpublish their own post."""
post_id = uuid4()
mock_result = PostResponseDTO(
id=post_id,
title="Test Post",
content="Content",
slug="test-post",
author_id="test-user-id",
published=False,
tags=[],
created_at=datetime.now(),
updated_at=datetime.now(),
)
with patch(
"app.application.use_cases.publish_post.PublishPostUseCase.unpublish",
return_value=mock_result,
):
response = await auth_client.post(f"/api/v1/posts/{post_id}/unpublish")
assert response.status_code == 200
data = response.json()
assert data["published"] is False
async def test_publish_post_without_auth_returns_401(self) -> None:
"""Test unauthenticated publish request returns 401."""
app = app_factory()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(f"/api/v1/posts/{uuid4()}/publish")
assert response.status_code == 401
async def test_unpublish_post_without_auth_returns_401(self) -> None:
"""Test unauthenticated unpublish request returns 401."""
app = app_factory()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(f"/api/v1/posts/{uuid4()}/unpublish")
assert response.status_code == 401
class TestRoleBasedAccessControl:
"""Test suite for role-based permissions."""
async def test_admin_can_view_unpublished_posts(
self,
admin_client: AsyncClient,
) -> None:
"""Test admin can use include_unpublished parameter."""
mock_posts = [
PostResponseDTO(
id=uuid4(),
title="Published Post",
content="Content",
slug="published-post",
author_id="test-user-id",
published=True,
tags=[],
created_at=datetime.now(),
updated_at=datetime.now(),
),
PostResponseDTO(
id=uuid4(),
title="Draft Post",
content="Draft content",
slug="draft-post",
author_id="test-user-id",
published=False,
tags=[],
created_at=datetime.now(),
updated_at=datetime.now(),
),
]
with patch(
"app.application.use_cases.list_posts.ListPostsUseCase.all_posts",
return_value=mock_posts,
):
response = await admin_client.get("/api/v1/posts?include_unpublished=true")
assert response.status_code == 200
data = response.json()
assert data["total"] == 2
async def test_user_cannot_view_unpublished_posts(
self,
auth_client: AsyncClient,
) -> None:
"""Test regular user cannot use include_unpublished parameter."""
response = await auth_client.get("/api/v1/posts?include_unpublished=true")
assert response.status_code == 403
data = response.json()
assert "message" in data
assert "Only admins can view unpublished posts" in data["message"]
async def test_admin_can_update_any_post(
self,
admin_client: AsyncClient,
) -> None:
"""Test admin can update any post regardless of ownership."""
post_id = uuid4()
mock_result = PostResponseDTO(
id=post_id,
title="Admin Updated Title",
content="Content",
slug="admin-updated-title",
author_id="other-user-id",
published=True,
tags=[],
created_at=datetime.now(),
updated_at=datetime.now(),
)
with patch(
"app.application.use_cases.update_post.UpdatePostUseCase.execute",
return_value=mock_result,
):
response = await admin_client.patch(
f"/api/v1/posts/{post_id}",
json={"title": "Admin Updated Title"},
)
assert response.status_code == 200
data = response.json()
assert data["title"] == "Admin Updated Title"
async def test_admin_can_delete_any_post(
self,
admin_client: AsyncClient,
) -> None:
"""Test admin can delete any post regardless of ownership."""
post_id = uuid4()
with patch(
"app.application.use_cases.delete_post.DeletePostUseCase.execute",
return_value=None,
):
response = await admin_client.delete(f"/api/v1/posts/{post_id}")
assert response.status_code == 204
class TestTokenValidation:
"""Test suite for token validation scenarios."""
async def test_expired_token_returns_401(self) -> None:
mock_client = AsyncMock()
mock_client.introspect_token.return_value = TokenInfo(
active=False,
user_id="test-user-id",
username="testuser",
email="test@example.com",
roles=["user"],
)
with patch(
"app.presentation.api.deps.get_keycloak_client",
return_value=mock_client,
):
app = app_factory()
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport,
base_url="http://test",
headers={"Authorization": "Bearer expired_token"},
) as client:
response = await client.post(
"/api/v1/posts",
json={"title": "Test", "content": "Content"},
)
assert response.status_code == 401
async def test_invalid_token_format_returns_401(self) -> None:
"""Test invalid token format returns 401."""
app = app_factory()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/posts",
json={"title": "Test", "content": "Content"},
headers={"Authorization": "InvalidFormat token"},
)
assert response.status_code == 401
async def test_missing_token_returns_401(self) -> None:
"""Test request without token returns 401."""
app = app_factory()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/posts",
json={"title": "Test", "content": "Content"},
)
assert response.status_code == 401
class TestAuthorizationErrorResponses:
"""Test suite for authorization error response formats."""
async def test_401_response_format(self) -> None:
"""Test 401 error has correct format."""
app = app_factory()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/posts",
json={"title": "Test", "content": "Content"},
)
assert response.status_code == 401
data = response.json()
assert "message" in data
assert "Authentication required" in data["message"]
async def test_403_response_format(
self,
auth_client: AsyncClient,
) -> None:
"""Test 403 error has correct format."""
with patch(
"app.application.use_cases.list_posts.ListPostsUseCase.all_posts",
side_effect=ForbiddenException("Only admins can view unpublished posts"),
):
response = await auth_client.get("/api/v1/posts?include_unpublished=true")
assert response.status_code == 403
data = response.json()
assert "message" in data
assert "Only admins can view unpublished posts" in data["message"]