""" Copyright 2024-2026 ReplyPier Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. """ import pytest from fastapi.testclient import TestClient from sqlalchemy.orm import Session from app.database import Base, get_db from fastapi import FastAPI from app.models.user import User from app.models.role import Role from app.models.permission import Permission, role_permissions from uuid import uuid4 from app.api import roles as roles_router from app.core.auth import get_current_user from app.main import app from app.core.config import settings from tests.conftest import engine, TestingSessionLocal # Create a test FastAPI app app = FastAPI() app.include_router( roles_router.router, prefix=f"{settings.API_V1_STR}/roles", tags=["roles"] ) @pytest.fixture(scope="function") def db(): """Create a fresh database for each test.""" Base.metadata.create_all(bind=engine) db = TestingSessionLocal() try: yield db finally: db.close() Base.metadata.drop_all(bind=engine) @pytest.fixture def test_permissions(db) -> list[Permission]: """Create test permissions""" permissions = [] for name in ["manage_roles", "manage_users", "manage_chats"]: perm = Permission( name=name, description=f"Can {name}" ) db.add(perm) permissions.append(perm) db.commit() for p in permissions: db.refresh(p) return permissions @pytest.fixture def test_role(db, test_organization, test_permissions) -> Role: """Create a test role with required permissions""" role = Role( id=1, name="Test Role", description="Test Role Description", organization_id=test_organization.id, is_default=False ) db.add(role) db.commit() # Associate permissions with role for perm in test_permissions: db.execute( role_permissions.insert().values( role_id=role.id, permission_id=perm.id ) ) db.commit() db.refresh(role) return role @pytest.fixture def test_user(db: Session, test_organization, test_role: Role) -> User: """Create a test user with required permissions""" user = User( id=uuid4(), email="test@test.com", hashed_password="hashed_password", organization_id=test_organization.id, role_id=test_role.id, is_active=True, full_name="Test User" ) db.add(user) db.commit() db.refresh(user) return user @pytest.fixture def client(test_user: User) -> TestClient: """Client authenticated as a user whose role really holds manage_roles. There used to be a `dependency_overrides[require_permissions]` line here. It never did anything — require_permissions is a factory and FastAPI keys overrides on the closure it returns — so it read as coverage while the real gates ran unchecked. The fixture role grants the permissions instead. """ async def override_get_current_user(): return test_user def override_get_db(): try: db = TestingSessionLocal() yield db finally: db.close() app.dependency_overrides[get_current_user] = override_get_current_user app.dependency_overrides[get_db] = override_get_db yield TestClient(app) app.dependency_overrides.clear() def test_create_role(client: TestClient, test_permissions): """Test creating a new role""" role_data = { "name": "New Role", "description": "New role description", "is_default": False, "permissions": [{"id": perm.id, "name": perm.name, "description": perm.description} for perm in test_permissions[:2]] } response = client.post("/api/v1/roles", json=role_data) assert response.status_code == 200 data = response.json() assert data["name"] == role_data["name"] assert data["description"] == role_data["description"] assert data["is_default"] == role_data["is_default"] assert len(data["permissions"]) == 2 def test_create_default_role_when_exists(client: TestClient, test_role, db): """Test creating a default role when one already exists""" # First make the test_role default test_role.is_default = True db.commit() role_data = { "name": "Another Default Role", "description": "This should fail", "is_default": True, "permissions": [] } response = client.post("/api/v1/roles", json=role_data) assert response.status_code == 400 assert "Organization already has a default role" in response.json()["detail"] def test_list_roles(client: TestClient, test_role): """Test listing all roles""" response = client.get("/api/v1/roles") assert response.status_code == 200 data = response.json() assert isinstance(data, list) assert len(data) == 1 assert data[0]["name"] == test_role.name assert data[0]["description"] == test_role.description def test_get_role(client: TestClient, test_role): """Test getting a specific role""" response = client.get(f"/api/v1/roles/{test_role.id}") assert response.status_code == 200 data = response.json() assert data["name"] == test_role.name assert data["description"] == test_role.description def test_update_role(client: TestClient, test_role, test_permissions): """Test updating a role""" update_data = { "name": "Updated Role Name", "description": "Updated description", "permissions": [{"id": perm.id, "name": perm.name, "description": perm.description} for perm in test_permissions[:1]] } response = client.put(f"/api/v1/roles/{test_role.id}", json=update_data) assert response.status_code == 200 data = response.json() assert data["name"] == update_data["name"] assert data["description"] == update_data["description"] assert len(data["permissions"]) == 1 def test_delete_role(client: TestClient, db, test_organization): """Test deleting a role""" # Create a new role that won't be used by any users role = Role( name="Role To Delete", description="This role will be deleted", organization_id=test_organization.id, is_default=False ) db.add(role) db.commit() db.refresh(role) response = client.delete(f"/api/v1/roles/{role.id}") assert response.status_code == 204 def test_add_permission_to_role(client: TestClient, test_role, test_permissions): """Test adding a permission to a role""" permission = test_permissions[0] response = client.post(f"/api/v1/roles/{test_role.id}/permissions/{permission.name}") assert response.status_code == 200 assert response.json()["message"] == "Permission added to role" def test_remove_permission_from_role(client: TestClient, test_role, test_permissions): """Test removing a permission from a role""" permission = test_permissions[0] response = client.delete(f"/api/v1/roles/{test_role.id}/permissions/{permission.name}") assert response.status_code == 200 assert response.json()["message"] == "Permission removed from role" def test_list_permissions(client: TestClient, test_permissions): """Test listing all available permissions""" response = client.get("/api/v1/roles/permissions/all") assert response.status_code == 200 data = response.json() assert isinstance(data, list) assert len(data) == len(test_permissions) # Negative test cases def test_create_role_duplicate_name(client: TestClient, test_role): """Test creating a role with duplicate name""" role_data = { "name": test_role.name, # Same name as existing role "description": "This should fail", "is_default": False, "permissions": [] } response = client.post("/api/v1/roles", json=role_data) assert response.status_code == 400 assert "Role with this name already exists" in response.json()["detail"] def test_update_default_role(client: TestClient, test_role, db): """Test updating a default role""" # Make the role default test_role.is_default = True db.commit() update_data = { "name": "Updated Name", "description": "This should fail" } response = client.put(f"/api/v1/roles/{test_role.id}", json=update_data) assert response.status_code == 400 assert "Cannot modify default role" in response.json()["detail"] def test_delete_default_role(client: TestClient, test_role, db): """Test deleting a default role""" # Make the role default test_role.is_default = True db.commit() response = client.delete(f"/api/v1/roles/{test_role.id}") assert response.status_code == 400 assert "Cannot delete default role" in response.json()["detail"] def test_delete_role_in_use(client: TestClient, test_role, test_user): """Test deleting a role that is assigned to users""" response = client.delete(f"/api/v1/roles/{test_role.id}") assert response.status_code == 400 assert "Cannot delete role that is assigned to users" in response.json()["detail"] def test_get_nonexistent_role(client: TestClient): """Test getting a nonexistent role""" response = client.get("/api/v1/roles/999") assert response.status_code == 404 assert "Role not found" in response.json()["detail"] def test_update_nonexistent_role(client: TestClient): """Test updating a nonexistent role""" update_data = { "name": "Updated Name", "description": "This should fail" } response = client.put("/api/v1/roles/999", json=update_data) assert response.status_code == 404 assert "Role not found" in response.json()["detail"] def test_delete_nonexistent_role(client: TestClient): """Test deleting a nonexistent role""" response = client.delete("/api/v1/roles/999") assert response.status_code == 404 assert "Role not found" in response.json()["detail"] def test_add_invalid_permission(client: TestClient, test_role): """Test adding an invalid permission to a role""" response = client.post(f"/api/v1/roles/{test_role.id}/permissions/invalid_permission") assert response.status_code == 404 assert "Permission not found" in response.json()["detail"] def test_remove_invalid_permission(client: TestClient, test_role): """Test removing an invalid permission from a role""" response = client.delete(f"/api/v1/roles/{test_role.id}/permissions/invalid_permission") assert response.status_code == 404 assert "Permission not found" in response.json()["detail"] # ------------------------------------------------- permission gating & escalation @pytest.fixture def unprivileged_client(db: Session, test_organization) -> TestClient: """A client whose role grants nothing. Every endpoint here except POST used to be Depends(get_current_user) with only an org check, so any authenticated user could rewrite roles — including granting their own role super_admin. """ # Explicit id: the test_role fixture hardcodes id=1, so an autoincrement # here collides depending on which fixture resolves first. role = Role(id=99, name="No Permissions", organization_id=test_organization.id) db.add(role) db.commit() user = User( id=uuid4(), email="nopermissions@test.com", hashed_password="hashed_password", organization_id=test_organization.id, role_id=role.id, is_active=True, full_name="No Permissions", ) db.add(user) db.commit() db.refresh(user) async def override_get_current_user(): return user def override_get_db(): session = TestingSessionLocal() try: yield session finally: session.close() app.dependency_overrides[get_current_user] = override_get_current_user app.dependency_overrides[get_db] = override_get_db yield TestClient(app) app.dependency_overrides.clear() def test_role_endpoints_require_permissions(unprivileged_client: TestClient, test_role: Role): """None of the role endpoints are reachable without manage_roles/manage_users""" role_id = test_role.id assert unprivileged_client.get("/api/v1/roles").status_code == 403 assert unprivileged_client.get(f"/api/v1/roles/{role_id}").status_code == 403 assert unprivileged_client.get("/api/v1/roles/permissions/all").status_code == 403 assert unprivileged_client.post("/api/v1/roles", json={"name": "X", "permissions": []}).status_code == 403 assert unprivileged_client.put(f"/api/v1/roles/{role_id}", json={"name": "X"}).status_code == 403 assert unprivileged_client.delete(f"/api/v1/roles/{role_id}").status_code == 403 assert unprivileged_client.post(f"/api/v1/roles/{role_id}/permissions/manage_roles").status_code == 403 assert unprivileged_client.delete(f"/api/v1/roles/{role_id}/permissions/manage_roles").status_code == 403 def test_cannot_grant_permission_caller_does_not_hold(client: TestClient, db: Session, test_role: Role): """The reported hole: POST /roles/{own_role_id}/permissions/super_admin""" db.add(Permission(name="super_admin", description="Has all permissions")) db.commit() response = client.post(f"/api/v1/roles/{test_role.id}/permissions/super_admin") assert response.status_code == 403 assert "do not hold" in response.json()["detail"] db.refresh(test_role) assert "super_admin" not in {p.name for p in test_role.permissions} def test_cannot_grant_unheld_permission_via_update(client: TestClient, db: Session, test_role: Role): """The same escalation through PUT rather than the permissions route""" super_admin = Permission(name="super_admin", description="Has all permissions") db.add(super_admin) db.commit() db.refresh(super_admin) response = client.put( f"/api/v1/roles/{test_role.id}", json={ "name": test_role.name, "permissions": [ {"id": super_admin.id, "name": "super_admin", "description": "Has all permissions"} ], }, ) assert response.status_code == 403 db.refresh(test_role) assert "super_admin" not in {p.name for p in test_role.permissions} def test_cannot_create_role_with_unheld_permission(client: TestClient, db: Session): """Creating a role is a grant too — otherwise the rule is trivially bypassed""" super_admin = Permission(name="super_admin", description="Has all permissions") db.add(super_admin) db.commit() db.refresh(super_admin) response = client.post( "/api/v1/roles", json={ "name": "Backdoor", "description": "", "permissions": [{"id": super_admin.id, "name": "super_admin", "description": "Has all permissions"}], }, ) assert response.status_code == 403 def test_can_grant_permission_caller_holds(client: TestClient, db: Session, test_organization): """Granting something you do hold is unaffected""" other_role = Role(name="Other Role", organization_id=test_organization.id) db.add(other_role) db.commit() db.refresh(other_role) response = client.post(f"/api/v1/roles/{other_role.id}/permissions/manage_users") assert response.status_code == 200 db.refresh(other_role) assert "manage_users" in {p.name for p in other_role.permissions} def test_super_admin_can_grant_anything(db: Session, test_organization): """check_permissions short-circuits on super_admin, so an owner is unaffected""" super_admin = Permission(name="super_admin", description="Has all permissions") db.add(super_admin) db.commit() owner_role = Role(name="Owner", organization_id=test_organization.id) db.add(owner_role) db.commit() db.execute(role_permissions.insert().values(role_id=owner_role.id, permission_id=super_admin.id)) db.commit() owner = User( id=uuid4(), email="owner@test.com", hashed_password="hashed_password", organization_id=test_organization.id, role_id=owner_role.id, is_active=True, full_name="Owner", ) db.add(owner) target = Role(name="Target", organization_id=test_organization.id) db.add(target) db.commit() db.refresh(target) async def override_get_current_user(): return owner def override_get_db(): session = TestingSessionLocal() try: yield session finally: session.close() app.dependency_overrides[get_current_user] = override_get_current_user app.dependency_overrides[get_db] = override_get_db try: response = TestClient(app).post(f"/api/v1/roles/{target.id}/permissions/super_admin") finally: app.dependency_overrides.clear() assert response.status_code == 200