Skip to content

Commit 866995c

Browse files
- Disallow editing default roles
- Always display roles table, not just when there are custom roles - Verify these new behaviors in the test suite
1 parent b09d636 commit 866995c

File tree

5 files changed

+198
-40
lines changed

5 files changed

+198
-40
lines changed

exceptions/http_exceptions.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,15 @@ def __init__(self):
118118
)
119119

120120

121+
class CannotModifyDefaultRoleError(HTTPException):
122+
"""Raised when attempting to modify or delete a default system role."""
123+
def __init__(self, action: str = "modify"):
124+
super().__init__(
125+
status_code=403,
126+
detail=f"Default system roles cannot be {action}d."
127+
)
128+
129+
121130
class DataIntegrityError(HTTPException):
122131
def __init__(
123132
self,

routers/role.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from utils.db import get_session
1010
from utils.dependencies import get_authenticated_user
1111
from utils.models import Role, Permission, ValidPermissions, utc_time, User, DataIntegrityError
12-
from exceptions.http_exceptions import InsufficientPermissionsError, InvalidPermissionError, RoleAlreadyExistsError, RoleNotFoundError, RoleHasUsersError
12+
from exceptions.http_exceptions import InsufficientPermissionsError, InvalidPermissionError, RoleAlreadyExistsError, RoleNotFoundError, RoleHasUsersError, CannotModifyDefaultRoleError
1313
from routers.organization import router as organization_router
1414

1515
logger = getLogger("uvicorn.error")
@@ -84,6 +84,10 @@ def update_role(
8484
if not db_role:
8585
raise RoleNotFoundError()
8686

87+
# Prevent modification of default roles
88+
if db_role.name in ["Owner", "Administrator", "Member"]:
89+
raise CannotModifyDefaultRoleError(action="update")
90+
8791
# If any user-selected permissions are not valid, raise an error
8892
for permission in permissions:
8993
if permission not in ValidPermissions:
@@ -148,6 +152,10 @@ def delete_role(
148152
if not db_role:
149153
raise RoleNotFoundError()
150154

155+
# Prevent deletion of default roles
156+
if db_role.name in ["Owner", "Administrator", "Member"]:
157+
raise CannotModifyDefaultRoleError(action="delete")
158+
151159
# Check that no users have the role
152160
if db_role.users:
153161
raise RoleHasUsersError()

templates/organization/modals/roles_card.html

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
{% endfor %}
1818

1919
{% if organization.roles %}
20-
{% if ns.custom_roles_exist %}
2120
<div class="table-responsive">
2221
<table class="table table-hover">
2322
<thead>
@@ -44,7 +43,7 @@
4443
</td>
4544
{% if ValidPermissions.EDIT_ROLE in user_permissions or ValidPermissions.DELETE_ROLE in user_permissions %}
4645
<td>
47-
{% if ValidPermissions.EDIT_ROLE in user_permissions and role.name != "Owner" %}
46+
{% if ValidPermissions.EDIT_ROLE in user_permissions and role.name not in ["Owner", "Administrator", "Member"] %}
4847
<button type="button" class="btn btn-sm btn-outline-primary me-1" data-bs-toggle="modal" data-bs-target="#editRoleModal{{ role.id }}">
4948
Edit Role
5049
</button>
@@ -66,9 +65,6 @@
6665
</tbody>
6766
</table>
6867
</div>
69-
{% else %}
70-
<p class="text-muted">No custom roles defined</p>
71-
{% endif %}
7268
{% else %}
7369
<p class="text-muted">No roles defined</p>
7470
{% endif %}
@@ -120,7 +116,7 @@ <h5 class="modal-title" id="createRoleModalLabel">Create New Role</h5>
120116
{# Edit Role Modals #}
121117
{% if ValidPermissions.EDIT_ROLE in user_permissions %}
122118
{% for role in organization.roles %}
123-
{% if role.name != "Owner" %}
119+
{% if role.name not in ["Owner", "Administrator", "Member"] %}
124120
<div class="modal fade" id="editRoleModal{{ role.id }}" tabindex="-1" aria-labelledby="editRoleModalLabel{{ role.id }}" aria-hidden="true">
125121
<div class="modal-dialog modal-lg">
126122
<div class="modal-content">

tests/routers/test_organization.py

Lines changed: 5 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -336,7 +336,7 @@ def test_delete_organization_cascade(auth_client, session, test_organization, te
336336
).all()
337337
assert len(roles) == 0
338338

339-
# --- Organization View Permission Tests ---
339+
# --- Organization View Tests ---
340340

341341
def test_read_organization_as_owner(auth_client_owner, test_organization):
342342
"""Test accessing organization page as an owner"""
@@ -348,13 +348,8 @@ def test_read_organization_as_owner(auth_client_owner, test_organization):
348348
assert response.status_code == 200
349349
assert test_organization.name in response.text
350350

351-
# Check for owner-specific actions
352-
assert "Invite Member" in response.text
353-
assert "Create Role" in response.text
354-
assert "Edit Role" in response.text
355-
assert "Delete Role" in response.text
356-
assert "Edit Organization" in response.text
357-
assert "Delete Organization" in response.text
351+
# Owner should have the permission to trigger the delete organization modal
352+
assert 'data-bs-target="#deleteOrganizationModal"' in response.text
358353

359354

360355
def test_read_organization_as_admin(auth_client_admin, test_organization):
@@ -366,11 +361,6 @@ def test_read_organization_as_admin(auth_client_admin, test_organization):
366361

367362
assert response.status_code == 200
368363
assert test_organization.name in response.text
369-
370-
# Check for admin-specific actions based on permissions
371-
assert "Invite Member" in response.text
372-
assert "Create Role" in response.text
373-
assert "Edit Role" in response.text
374364

375365
# Admin shouldn't have the permission to trigger the delete organization modal
376366
assert 'data-bs-target="#deleteOrganizationModal"' not in response.text
@@ -386,13 +376,8 @@ def test_read_organization_as_member(auth_client_member, test_organization):
386376
assert response.status_code == 200
387377
assert test_organization.name in response.text
388378

389-
# Member should not have permission buttons
390-
assert "Invite Member" not in response.text
391-
assert "Create Role" not in response.text
392-
assert "Edit Role" not in response.text
393-
assert "Delete Role" not in response.text
394-
assert "Edit Organization" not in response.text
395-
assert "Delete Organization" not in response.text
379+
# Member shouldn't have the permission to trigger the delete organization modal
380+
assert 'data-bs-target="#deleteOrganizationModal"' not in response.text
396381

397382

398383
def test_read_organization_as_non_member(auth_client_non_member, test_organization):

tests/routers/test_role.py

Lines changed: 173 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import pytest
44
from tests.conftest import SetupError
55
from utils.models import Role, Permission, ValidPermissions, User
6-
from sqlmodel import Session, select
6+
from sqlmodel import Session, select, col
77
import re
88
from main import app
99

@@ -479,50 +479,63 @@ def test_organization_page_role_creation_access(auth_client_owner, auth_client_a
479479
follow_redirects=False
480480
)
481481
assert owner_response.status_code == 200
482-
assert "Create Role" in owner_response.text
482+
# Check for the button's modal trigger specifically
483+
assert 'data-bs-target="#createRoleModal"' in owner_response.text
483484

484485
# Admin should see role creation
485486
admin_response = auth_client_admin.get(
486487
app.url_path_for("read_organization", org_id=test_organization.id),
487488
follow_redirects=False
488489
)
489490
assert admin_response.status_code == 200
490-
assert "Create Role" in admin_response.text
491+
# Check for the button's modal trigger specifically
492+
assert 'data-bs-target="#createRoleModal"' in admin_response.text
491493

492494
# Member should not see role creation
493495
member_response = auth_client_member.get(
494496
app.url_path_for("read_organization", org_id=test_organization.id),
495497
follow_redirects=False
496498
)
497499
assert member_response.status_code == 200
498-
assert "Create Role" not in member_response.text
500+
# Check that the button's modal trigger is NOT present
501+
assert 'data-bs-target="#createRoleModal"' not in member_response.text
499502

500503

501-
def test_organization_page_role_edit_access(auth_client_owner, auth_client_admin, auth_client_member, test_organization):
502-
"""Test that role editing UI elements are only shown to users with EDIT_ROLE permission"""
503-
# Owner should see role editing controls
504+
def test_organization_page_role_edit_access(auth_client_owner, auth_client_admin, auth_client_member, test_organization, session: Session):
505+
"""Test that the 'Edit Role' button appears for custom roles only for users with permission."""
506+
# Create a custom, editable role for the test
507+
custom_role = Role(name="Custom Role To Edit", organization_id=test_organization.id)
508+
session.add(custom_role)
509+
session.commit()
510+
session.refresh(custom_role)
511+
512+
# Define the regex pattern for the specific custom role's edit button
513+
edit_button_pattern = fr'<button[^>]*data-bs-target="#editRoleModal{custom_role.id}"[^>]*>\s*Edit Role\s*</button>'
514+
515+
# Owner should see the edit button for the custom role
504516
owner_response = auth_client_owner.get(
505517
app.url_path_for("read_organization", org_id=test_organization.id),
506518
follow_redirects=False
507519
)
508520
assert owner_response.status_code == 200
509-
assert "Edit Role" in owner_response.text
521+
assert re.search(edit_button_pattern, owner_response.text) is not None, "Owner should see edit button for custom role"
510522

511-
# Admin should see role editing controls
523+
# Admin should see the edit button for the custom role
512524
admin_response = auth_client_admin.get(
513525
app.url_path_for("read_organization", org_id=test_organization.id),
514526
follow_redirects=False
515527
)
516528
assert admin_response.status_code == 200
517-
assert "Edit Role" in admin_response.text
518-
519-
# Member should not see role editing controls
529+
assert re.search(edit_button_pattern, admin_response.text) is not None, "Admin should see edit button for custom role"
530+
531+
# Member should not see *any* edit role button
520532
member_response = auth_client_member.get(
521533
app.url_path_for("read_organization", org_id=test_organization.id),
522534
follow_redirects=False
523535
)
524536
assert member_response.status_code == 200
525-
assert "Edit Role" not in member_response.text
537+
# Use a general pattern to ensure no edit buttons are present for the member
538+
assert re.search(r'<button[^>]*>\\s*Edit Role\\s*</button>', member_response.text) is None, "Member should not see any edit buttons"
526539

527540

528541
def test_organization_page_role_delete_access(auth_client_owner, auth_client_admin, auth_client_member, test_organization, session: Session):
@@ -581,6 +594,153 @@ def test_organization_page_role_delete_access(auth_client_owner, auth_client_adm
581594
assert expected_member_delete_form not in member_response.text
582595

583596

597+
def test_organization_page_always_shows_default_roles(auth_client_member, test_organization, session: Session):
598+
"""
599+
Test that Owner, Administrator, and Member roles are always visible
600+
on the organization page, even if no custom roles exist.
601+
The default Member client is used as it has the least privileges but should still see the roles.
602+
"""
603+
# Ensure no custom roles exist for this test
604+
custom_roles = session.exec(
605+
select(Role).where(
606+
Role.organization_id == test_organization.id,
607+
col(Role.name).not_in(["Owner", "Administrator", "Member"])
608+
)
609+
).all()
610+
assert len(custom_roles) == 0, "Test setup failed: Custom roles exist unexpectedly."
611+
612+
response = auth_client_member.get(
613+
app.url_path_for("read_organization", org_id=test_organization.id),
614+
follow_redirects=False
615+
)
616+
assert response.status_code == 200
617+
618+
# Check if the default role names are present in the rendered table body
619+
# This implicitly checks if the table is rendered even without custom roles.
620+
assert "<td>Owner</td>" in response.text
621+
assert "<td>Administrator</td>" in response.text
622+
assert "<td>Member</td>" in response.text
623+
624+
625+
def test_organization_page_no_edit_for_default_roles(auth_client_owner, test_organization):
626+
"""
627+
Test that the 'Edit Role' button/modal trigger does not appear for default roles
628+
(Owner, Administrator, Member), even for the owner.
629+
"""
630+
response = auth_client_owner.get(
631+
app.url_path_for("read_organization", org_id=test_organization.id),
632+
follow_redirects=False
633+
)
634+
assert response.status_code == 200
635+
636+
# Owner role (ID 1) should not have an edit button
637+
owner_edit_button_pattern = r'<button[^>]*data-bs-target="#editRoleModal1"[^>]*>\\s*Edit Role\\s*</button>'
638+
assert re.search(owner_edit_button_pattern, response.text) is None, "Edit button found for Owner role"
639+
640+
# Administrator role (ID 2) should not have an edit button
641+
admin_edit_button_pattern = r'<button[^>]*data-bs-target="#editRoleModal2"[^>]*>\\s*Edit Role\\s*</button>'
642+
assert re.search(admin_edit_button_pattern, response.text) is None, "Edit button found for Administrator role"
643+
644+
# Member role (ID 3) should not have an edit button
645+
member_edit_button_pattern = r'<button[^>]*data-bs-target="#editRoleModal3"[^>]*>\\s*Edit Role\\s*</button>'
646+
assert re.search(member_edit_button_pattern, response.text) is None, "Edit button found for Member role"
647+
648+
# Check that the edit modals themselves are not generated for default roles
649+
assert 'id="editRoleModal1"' not in response.text, "Edit modal found for Owner role"
650+
assert 'id="editRoleModal2"' not in response.text, "Edit modal found for Administrator role"
651+
assert 'id="editRoleModal3"' not in response.text, "Edit modal found for Member role"
652+
653+
654+
def test_organization_page_no_delete_for_default_roles(auth_client_owner, test_organization):
655+
"""
656+
Test that the 'Delete Role' button/form does not appear for default roles
657+
(Owner, Administrator, Member), even for the owner.
658+
This re-verifies and centralizes checks from test_organization_page_role_delete_access.
659+
"""
660+
response = auth_client_owner.get(
661+
app.url_path_for("read_organization", org_id=test_organization.id),
662+
follow_redirects=False
663+
)
664+
assert response.status_code == 200
665+
666+
# Check that delete forms are NOT present for default roles (IDs 1, 2, 3)
667+
owner_delete_form_pattern = r'<form[^>]*action="[^"]*?/roles/delete"[^>]*>.*?<input[^>]*name="id"[^>]*value="1"[^>]*>.*?</form>'
668+
assert re.search(owner_delete_form_pattern, response.text, re.DOTALL) is None, "Delete form found for Owner role"
669+
670+
admin_delete_form_pattern = r'<form[^>]*action="[^"]*?/roles/delete"[^>]*>.*?<input[^>]*name="id"[^>]*value="2"[^>]*>.*?</form>'
671+
assert re.search(admin_delete_form_pattern, response.text, re.DOTALL) is None, "Delete form found for Administrator role"
672+
673+
member_delete_form_pattern = r'<form[^>]*action="[^"]*?/roles/delete"[^>]*>.*?<input[^>]*name="id"[^>]*value="3"[^>]*>.*?</form>'
674+
assert re.search(member_delete_form_pattern, response.text, re.DOTALL) is None, "Delete form found for Member role"
675+
676+
677+
# --- API Endpoint Tests for Default Roles ---
678+
679+
@pytest.mark.parametrize("default_role_name", ["Owner", "Administrator", "Member"])
680+
def test_update_default_role_api_forbidden(auth_client_owner, test_organization, session: Session, default_role_name):
681+
"""
682+
Test that attempting to update default roles via the API is forbidden (403).
683+
Uses the owner client which has EDIT_ROLE permission.
684+
Finds the role ID dynamically based on the name for the specific organization.
685+
"""
686+
# Find the actual role ID for the current test organization instance
687+
default_role = session.exec(
688+
select(Role)
689+
.where(Role.organization_id == test_organization.id)
690+
.where(Role.name == default_role_name)
691+
).first()
692+
693+
if not default_role:
694+
pytest.fail(f"Default role '{default_role_name}' not found for organization {test_organization.id}")
695+
696+
default_role_id = default_role.id
697+
698+
response = auth_client_owner.post(
699+
app.url_path_for("update_role"),
700+
data={
701+
"id": default_role_id, # Use dynamically fetched ID
702+
"name": f"Attempt to Change {default_role_name} Role",
703+
"organization_id": test_organization.id,
704+
"permissions": [ValidPermissions.EDIT_ROLE.value] # Arbitrary permission
705+
},
706+
follow_redirects=False # We expect a direct 403, not a redirect
707+
)
708+
709+
assert response.status_code == 403 # Expecting Forbidden
710+
711+
712+
@pytest.mark.parametrize("default_role_name", ["Owner", "Administrator", "Member"])
713+
def test_delete_default_role_api_forbidden(auth_client_owner, test_organization, session: Session, default_role_name):
714+
"""
715+
Test that attempting to delete default roles via the API is forbidden (403).
716+
Uses the owner client which has DELETE_ROLE permission.
717+
Finds the role ID dynamically based on the name for the specific organization.
718+
"""
719+
# Find the actual role ID for the current test organization instance
720+
default_role = session.exec(
721+
select(Role)
722+
.where(Role.organization_id == test_organization.id)
723+
.where(Role.name == default_role_name)
724+
).first()
725+
726+
if not default_role:
727+
pytest.fail(f"Default role '{default_role_name}' not found for organization {test_organization.id}")
728+
729+
default_role_id = default_role.id
730+
print(default_role_id)
731+
732+
response = auth_client_owner.post(
733+
app.url_path_for("delete_role"),
734+
data={
735+
"id": default_role_id, # Use dynamically fetched ID
736+
"organization_id": test_organization.id
737+
},
738+
follow_redirects=False # We expect a direct 403, not a redirect
739+
)
740+
741+
assert response.status_code == 403 # Expecting Forbidden
742+
743+
584744
def test_create_role_form_modal(auth_client_owner, test_organization):
585745
"""Test that the create role modal form contains all required elements"""
586746
response = auth_client_owner.get(

0 commit comments

Comments
 (0)