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
448 lines
15 KiB
Python
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"]
|