mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-22 16:31:09 -05:00
- 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.
506 lines
19 KiB
Python
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
|