Files
thrillwiki_django_no_react/backend/tests/e2e/test_fsm_permissions.py
pacnpal 45d97b6e68 Add test utilities and state machine diagrams for FSM models
- Introduced reusable test utilities in `backend/tests/utils` for FSM transitions, HTMX interactions, and common scenarios.
- Added factory functions for creating test submissions, parks, rides, and photo submissions.
- Implemented assertion helpers for verifying state changes, toast notifications, and transition logs.
- Created comprehensive state machine diagrams for all FSM-enabled models in `docs/STATE_DIAGRAMS.md`, detailing states, transitions, and guard conditions.
2025-12-22 08:55:39 -05:00

506 lines
19 KiB
Python

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