Files
replypier/backend/tests/api/test_roles.py
T

489 lines
17 KiB
Python

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