test: 补充权限、消息与会话服务回归用例
会话读取、接管、重分配与 Socket 订阅统一校验角色、组织与可见性。
This commit is contained in:
1 parent
8e3a4d95da
commit
164f5f9615
97 files changed
+37281
No files matched your search
@@ -0,0 +1,488 @@
|
||||
"""
|
||||
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 UUID, 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_tables
|
||||
|
||||
# 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
|
||||
Reference in new issue
Block a user