mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-22 21:11:11 -05:00
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.
This commit is contained in:
505
backend/tests/e2e/test_fsm_permissions.py
Normal file
505
backend/tests/e2e/test_fsm_permissions.py
Normal file
@@ -0,0 +1,505 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user