""" E2E Tests for FSM Permission Guards Tests that unauthorized users cannot execute FSM transitions: - Unauthenticated users cannot see transition buttons - Regular users cannot approve submissions - Regular users cannot change park/ride status - Moderators can approve but not admin-only transitions - Transition buttons hidden when not allowed These tests verify: - Transition buttons are NOT visible for unauthorized users - Direct POST requests return 403 Forbidden - Database state does NOT change after failed transition attempt - Error toast displays "Permission denied" message """ import pytest from playwright.sync_api import Page, expect class TestUnauthenticatedUserPermissions: """Tests for unauthenticated user permission guards.""" def test_unauthenticated_user_cannot_see_moderation_dashboard( self, page: Page, live_server ): """Test that unauthenticated users are redirected from moderation dashboard.""" # Navigate to moderation dashboard without logging in response = page.goto(f"{live_server.url}/moderation/dashboard/") # Should be redirected to login page or see access denied # Check URL contains login or access denied current_url = page.url assert "login" in current_url or "denied" in current_url or response.status == 403 def test_unauthenticated_user_cannot_see_transition_buttons( self, page: Page, live_server, db ): """Test that unauthenticated users cannot see transition buttons on park detail.""" from apps.parks.models import Park park = Park.objects.filter(status="OPERATING").first() if not park: pytest.skip("No operating park available") page.goto(f"{live_server.url}/parks/{park.slug}/") page.wait_for_load_state("networkidle") # Status action buttons should NOT be visible status_actions = page.locator('[data-park-status-actions]') # Either the section doesn't exist or the buttons are not there if status_actions.is_visible(): close_temp_btn = status_actions.get_by_role( "button", name="Close Temporarily" ) expect(close_temp_btn).not_to_be_visible() def test_unauthenticated_direct_post_returns_403( self, page: Page, live_server, db ): """Test that direct POST to FSM endpoint returns 403 for unauthenticated user.""" from apps.parks.models import Park park = Park.objects.filter(status="OPERATING").first() if not park: pytest.skip("No operating park available") # Attempt to POST directly to FSM transition endpoint response = page.request.post( f"{live_server.url}/core/fsm/parks/park/{park.pk}/transition/transition_to_closed_temp/", headers={"HX-Request": "true"} ) # Should get 403 Forbidden assert response.status == 403 or response.status == 302 # 302 redirect to login # Verify database state did NOT change park.refresh_from_db() assert park.status == "OPERATING" class TestRegularUserPermissions: """Tests for regular (non-moderator) user permission guards.""" def test_regular_user_cannot_approve_submission( self, auth_page: Page, live_server, db ): """Test that regular users cannot approve submissions.""" from django.contrib.auth import get_user_model from apps.moderation.models import EditSubmission from apps.parks.models import Park from django.contrib.contenttypes.models import ContentType User = get_user_model() # Create a pending submission user = User.objects.filter(username="testuser").first() if not user: pytest.skip("Test user not found") park = Park.objects.first() if not park: pytest.skip("No park available") content_type = ContentType.objects.get_for_model(Park) submission = EditSubmission.objects.create( user=user, content_type=content_type, object_id=park.pk, submission_type="EDIT", changes={"description": "Test change"}, reason="Permission test", status="PENDING" ) try: # Navigate to moderation dashboard as regular user auth_page.goto(f"{live_server.url}/moderation/dashboard/") # Regular user should be redirected or denied current_url = auth_page.url # If somehow on dashboard, verify no approve button if "dashboard" in current_url: submission_row = auth_page.locator( f'[data-submission-id="{submission.pk}"]' ) if submission_row.is_visible(): approve_btn = submission_row.get_by_role("button", name="Approve") expect(approve_btn).not_to_be_visible() # Try direct POST - should be denied response = auth_page.request.post( f"{live_server.url}/core/fsm/moderation/editsubmission/{submission.pk}/transition/transition_to_approved/", headers={"HX-Request": "true"} ) # Should be denied (403 or 302 redirect) assert response.status in [302, 403] # Verify database state did NOT change submission.refresh_from_db() assert submission.status == "PENDING" finally: submission.delete() def test_regular_user_cannot_change_park_status( self, auth_page: Page, live_server, db ): """Test that regular users cannot change park status.""" from apps.parks.models import Park park = Park.objects.filter(status="OPERATING").first() if not park: pytest.skip("No operating park available") auth_page.goto(f"{live_server.url}/parks/{park.slug}/") auth_page.wait_for_load_state("networkidle") # Status action buttons should NOT be visible to regular user status_actions = auth_page.locator('[data-park-status-actions]') if status_actions.is_visible(): close_temp_btn = status_actions.get_by_role( "button", name="Close Temporarily" ) expect(close_temp_btn).not_to_be_visible() # Try direct POST - should be denied response = auth_page.request.post( f"{live_server.url}/core/fsm/parks/park/{park.pk}/transition/transition_to_closed_temp/", headers={"HX-Request": "true"} ) # Should be denied assert response.status in [302, 400, 403] # Verify database state did NOT change park.refresh_from_db() assert park.status == "OPERATING" def test_regular_user_cannot_change_ride_status( self, auth_page: Page, live_server, db ): """Test that regular users cannot change ride status.""" from apps.rides.models import Ride ride = Ride.objects.filter(status="OPERATING").first() if not ride: pytest.skip("No operating ride available") auth_page.goto( f"{live_server.url}/parks/{ride.park.slug}/rides/{ride.slug}/" ) auth_page.wait_for_load_state("networkidle") # Status action buttons should NOT be visible to regular user status_actions = auth_page.locator('[data-ride-status-actions]') if status_actions.is_visible(): close_temp_btn = status_actions.get_by_role( "button", name="Close Temporarily" ) expect(close_temp_btn).not_to_be_visible() # Try direct POST - should be denied response = auth_page.request.post( f"{live_server.url}/core/fsm/rides/ride/{ride.pk}/transition/transition_to_closed_temp/", headers={"HX-Request": "true"} ) # Should be denied assert response.status in [302, 400, 403] # Verify database state did NOT change ride.refresh_from_db() assert ride.status == "OPERATING" class TestModeratorPermissions: """Tests for moderator-specific permission guards.""" def test_moderator_can_approve_submission( self, mod_page: Page, live_server, db ): """Test that moderators CAN see and use approve button.""" from django.contrib.auth import get_user_model from apps.moderation.models import EditSubmission from apps.parks.models import Park from django.contrib.contenttypes.models import ContentType User = get_user_model() # Create a pending submission user = User.objects.filter(username="testuser").first() if not user: user = User.objects.create_user( username="testuser", email="testuser@example.com", password="testpass123" ) park = Park.objects.first() if not park: pytest.skip("No park available") content_type = ContentType.objects.get_for_model(Park) submission = EditSubmission.objects.create( user=user, content_type=content_type, object_id=park.pk, submission_type="EDIT", changes={"description": "Test change for moderator"}, reason="Moderator permission test", status="PENDING" ) try: mod_page.goto(f"{live_server.url}/moderation/dashboard/") mod_page.wait_for_load_state("networkidle") # Moderator should be able to see the submission submission_row = mod_page.locator( f'[data-submission-id="{submission.pk}"]' ) if submission_row.is_visible(): # Should see approve button approve_btn = submission_row.get_by_role("button", name="Approve") expect(approve_btn).to_be_visible() finally: submission.delete() def test_moderator_can_change_park_status( self, mod_page: Page, live_server, db ): """Test that moderators CAN see and use park status change buttons.""" from apps.parks.models import Park park = Park.objects.filter(status="OPERATING").first() if not park: pytest.skip("No operating park available") mod_page.goto(f"{live_server.url}/parks/{park.slug}/") mod_page.wait_for_load_state("networkidle") # Status action buttons SHOULD be visible to moderator status_actions = mod_page.locator('[data-park-status-actions]') if status_actions.is_visible(): # Should see close temporarily button close_temp_btn = status_actions.get_by_role( "button", name="Close Temporarily" ) expect(close_temp_btn).to_be_visible() def test_moderator_cannot_access_admin_only_transitions( self, mod_page: Page, live_server, db ): """Test that moderators CANNOT access admin-only transitions.""" # This test verifies that certain transitions require admin privileges # Specific transitions depend on the FSM configuration from apps.parks.models import Park # Get a permanently closed park for testing admin-only demolish park = Park.objects.filter(status="CLOSED_PERM").first() if not park: # Create one park = Park.objects.filter(status="OPERATING").first() if park: park.status = "CLOSED_PERM" park.save() else: pytest.skip("No park available for testing") mod_page.goto(f"{live_server.url}/parks/{park.slug}/") mod_page.wait_for_load_state("networkidle") # Check for admin-only buttons (if any are configured) # The specific buttons that should be hidden depend on the FSM configuration status_actions = mod_page.locator('[data-park-status-actions]') # If there are admin-only transitions, verify they're hidden # This is a placeholder - actual admin-only transitions depend on configuration admin_only_btn = status_actions.get_by_role( "button", name="Force Delete" # Example admin-only action ) expect(admin_only_btn).not_to_be_visible() class TestPermissionDeniedErrorHandling: """Tests for error handling when permission is denied.""" def test_permission_denied_shows_error_toast( self, auth_page: Page, live_server, db ): """Test that permission denied errors show appropriate toast.""" from apps.parks.models import Park park = Park.objects.filter(status="OPERATING").first() if not park: pytest.skip("No operating park available") # Navigate to the page first auth_page.goto(f"{live_server.url}/parks/{park.slug}/") auth_page.wait_for_load_state("networkidle") # Make the request programmatically with HTMX header response = auth_page.evaluate(""" async () => { const response = await fetch('/core/fsm/parks/park/""" + str(park.pk) + """/transition/transition_to_closed_temp/', { method: 'POST', headers: { 'HX-Request': 'true', 'Content-Type': 'application/x-www-form-urlencoded' }, credentials: 'include' }); return { status: response.status, hxTrigger: response.headers.get('HX-Trigger') }; } """) # Check if error toast was triggered if response and response.get('status') in [400, 403]: hx_trigger = response.get('hxTrigger') if hx_trigger: assert 'showToast' in hx_trigger assert 'error' in hx_trigger.lower() or 'denied' in hx_trigger.lower() def test_database_state_unchanged_on_permission_denied( self, auth_page: Page, live_server, db ): """Test that database state is unchanged when permission is denied.""" from apps.parks.models import Park park = Park.objects.filter(status="OPERATING").first() if not park: pytest.skip("No operating park available") original_status = park.status # Attempt unauthorized transition via direct fetch auth_page.goto(f"{live_server.url}/parks/{park.slug}/") auth_page.wait_for_load_state("networkidle") auth_page.evaluate(""" async () => { await fetch('/core/fsm/parks/park/""" + str(park.pk) + """/transition/transition_to_closed_temp/', { method: 'POST', headers: { 'HX-Request': 'true', 'Content-Type': 'application/x-www-form-urlencoded' }, credentials: 'include' }); } """) # Verify database state did NOT change park.refresh_from_db() assert park.status == original_status class TestTransitionButtonVisibility: """Tests for correct transition button visibility based on permissions and state.""" def test_transition_button_hidden_when_state_invalid( self, mod_page: Page, live_server, db ): """Test that transition buttons are hidden when the current state is invalid.""" from apps.parks.models import Park # Get an operating park park = Park.objects.filter(status="OPERATING").first() if not park: pytest.skip("No operating park available") mod_page.goto(f"{live_server.url}/parks/{park.slug}/") mod_page.wait_for_load_state("networkidle") status_actions = mod_page.locator('[data-park-status-actions]') # Reopen button should NOT be visible for operating park # (can't reopen something that's already operating) reopen_btn = status_actions.get_by_role("button", name="Reopen") expect(reopen_btn).not_to_be_visible() # Demolish should NOT be visible for operating park # (can only demolish from CLOSED_PERM) demolish_btn = status_actions.get_by_role("button", name="Mark as Demolished") expect(demolish_btn).not_to_be_visible() def test_correct_buttons_shown_for_closed_temp_state( self, mod_page: Page, live_server, db ): """Test that correct buttons are shown for temporarily closed state.""" from apps.parks.models import Park park = Park.objects.filter(status="CLOSED_TEMP").first() if not park: # Create one park = Park.objects.filter(status="OPERATING").first() if park: park.status = "CLOSED_TEMP" park.save() else: pytest.skip("No park available for testing") mod_page.goto(f"{live_server.url}/parks/{park.slug}/") mod_page.wait_for_load_state("networkidle") status_actions = mod_page.locator('[data-park-status-actions]') # Reopen button SHOULD be visible reopen_btn = status_actions.get_by_role("button", name="Reopen") expect(reopen_btn).to_be_visible() # Close Temporarily should NOT be visible (already closed) close_temp_btn = status_actions.get_by_role( "button", name="Close Temporarily" ) expect(close_temp_btn).not_to_be_visible() def test_correct_buttons_shown_for_closed_perm_state( self, mod_page: Page, live_server, db ): """Test that correct buttons are shown for permanently closed state.""" from apps.parks.models import Park park = Park.objects.filter(status="CLOSED_PERM").first() if not park: # Create one park = Park.objects.filter(status="OPERATING").first() if park: park.status = "CLOSED_PERM" park.save() else: pytest.skip("No park available for testing") mod_page.goto(f"{live_server.url}/parks/{park.slug}/") mod_page.wait_for_load_state("networkidle") status_actions = mod_page.locator('[data-park-status-actions]') # Demolish/Relocate buttons SHOULD be visible demolish_btn = status_actions.get_by_role("button", name="Mark as Demolished") relocate_btn = status_actions.get_by_role("button", name="Mark as Relocated") # At least one of these should be visible for CLOSED_PERM visible = demolish_btn.is_visible() or relocate_btn.is_visible() assert visible, "Expected demolish or relocate button for CLOSED_PERM state" # Reopen should still be visible to restore to operating reopen_btn = status_actions.get_by_role("button", name="Reopen") # May or may not be visible depending on FSM configuration