"""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"]