清理全局角色残留并修复项目权限判断
This commit is contained in:
@@ -10,22 +10,22 @@ from app.api.v1.api_permissions import get_my_study_api_permissions, update_stud
|
||||
from app.api.v1.members import update_member
|
||||
from app.api.v1.permission_monitoring import resolve_monitoring_scope
|
||||
from app.api.v1.system_permissions import list_system_permissions
|
||||
from app.core.deps import require_admin_or_any_project_pm
|
||||
from app.core.deps import require_admin_or_any_project_pm, require_system_permission
|
||||
from app.schemas.member import StudyMemberUpdate
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserStub:
|
||||
id: uuid.UUID
|
||||
role: str
|
||||
is_admin: bool = False
|
||||
|
||||
|
||||
async def _seed_user(db: AsyncSession, user_id: uuid.UUID, role: str = "PM") -> None:
|
||||
async def _seed_user(db: AsyncSession, user_id: uuid.UUID, *, is_admin: bool = False) -> None:
|
||||
await db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO users (id, email, password_hash, full_name, role, clinical_department, status)
|
||||
VALUES (:id, :email, :password_hash, :full_name, :role, :clinical_department, :status)
|
||||
INSERT INTO users (id, email, password_hash, full_name, clinical_department, is_admin, status)
|
||||
VALUES (:id, :email, :password_hash, :full_name, :clinical_department, :is_admin, :status)
|
||||
"""
|
||||
),
|
||||
{
|
||||
@@ -33,8 +33,8 @@ async def _seed_user(db: AsyncSession, user_id: uuid.UUID, role: str = "PM") ->
|
||||
"email": f"{user_id.hex}@example.com",
|
||||
"password_hash": "hash",
|
||||
"full_name": f"User {user_id.hex[:6]}",
|
||||
"role": role,
|
||||
"clinical_department": "Clinical",
|
||||
"is_admin": is_admin,
|
||||
"status": "ACTIVE",
|
||||
},
|
||||
)
|
||||
@@ -114,7 +114,7 @@ async def test_project_pm_can_view_system_permission_definitions(db_session: Asy
|
||||
await db_session.commit()
|
||||
|
||||
dependency = require_admin_or_any_project_pm()
|
||||
await dependency(current_user=UserStub(id=pm_id, role="PM"), db=db_session)
|
||||
await dependency(current_user=UserStub(id=pm_id), db=db_session)
|
||||
data = await list_system_permissions()
|
||||
|
||||
assert data["permissions"]
|
||||
@@ -124,14 +124,45 @@ async def test_project_pm_can_view_system_permission_definitions(db_session: Asy
|
||||
async def test_non_pm_cannot_view_system_permission_definitions(db_session: AsyncSession):
|
||||
cra_id = uuid.uuid4()
|
||||
study_id = uuid.uuid4()
|
||||
await _seed_user(db_session, cra_id, role="CRA")
|
||||
await _seed_user(db_session, cra_id)
|
||||
await _seed_study(db_session, study_id, "CRA-SYSTEM-PERMS")
|
||||
await _seed_member(db_session, study_id, cra_id, "CRA")
|
||||
await db_session.commit()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
dependency = require_admin_or_any_project_pm()
|
||||
await dependency(current_user=UserStub(id=cra_id, role="CRA"), db=db_session)
|
||||
await dependency(current_user=UserStub(id=cra_id), db=db_session)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_pm_can_open_project_permission_config_without_global_pm_role(db_session: AsyncSession):
|
||||
user_id = uuid.uuid4()
|
||||
study_id = uuid.uuid4()
|
||||
await _seed_user(db_session, user_id)
|
||||
await _seed_study(db_session, study_id, "PROJECT-PM-NO-GLOBAL-ROLE")
|
||||
await _seed_member(db_session, study_id, user_id, "PM")
|
||||
await db_session.commit()
|
||||
|
||||
dependency = require_system_permission("system:permissions:project_config")
|
||||
result = await dependency(study_id=study_id, current_user=UserStub(id=user_id), db=db_session)
|
||||
|
||||
assert result.id == user_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_project_pm_cannot_open_project_permission_config_even_with_active_membership(db_session: AsyncSession):
|
||||
user_id = uuid.uuid4()
|
||||
study_id = uuid.uuid4()
|
||||
await _seed_user(db_session, user_id)
|
||||
await _seed_study(db_session, study_id, "PROJECT-NON-PM-DENIED")
|
||||
await _seed_member(db_session, study_id, user_id, "CRA")
|
||||
await db_session.commit()
|
||||
|
||||
dependency = require_system_permission("system:permissions:project_config")
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await dependency(study_id=study_id, current_user=UserStub(id=user_id), db=db_session)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
@@ -141,7 +172,7 @@ async def test_pm_permission_update_does_not_persist_admin_or_pm_overrides(db_se
|
||||
study_id = uuid.uuid4()
|
||||
admin_id = uuid.uuid4()
|
||||
await _seed_study(db_session, study_id, "PM-PERM-SKIP")
|
||||
await _seed_user(db_session, admin_id, role="ADMIN")
|
||||
await _seed_user(db_session, admin_id, is_admin=True)
|
||||
await db_session.commit()
|
||||
|
||||
result = await update_study_api_permissions(
|
||||
@@ -151,7 +182,7 @@ async def test_pm_permission_update_does_not_persist_admin_or_pm_overrides(db_se
|
||||
"PM": {"subjects:delete": False},
|
||||
"CRA": {"subjects:delete": True},
|
||||
},
|
||||
current_user=UserStub(id=admin_id, role="ADMIN"),
|
||||
current_user=UserStub(id=admin_id, is_admin=True),
|
||||
db=db_session,
|
||||
)
|
||||
|
||||
@@ -177,14 +208,14 @@ async def test_pm_permission_update_does_not_persist_admin_or_pm_overrides(db_se
|
||||
async def test_project_member_can_read_own_effective_permissions(db_session: AsyncSession):
|
||||
cra_id = uuid.uuid4()
|
||||
study_id = uuid.uuid4()
|
||||
await _seed_user(db_session, cra_id, role="CRA")
|
||||
await _seed_user(db_session, cra_id)
|
||||
await _seed_study(db_session, study_id, "CRA-MY-PERMS")
|
||||
await _seed_member(db_session, study_id, cra_id, "CRA")
|
||||
await db_session.commit()
|
||||
|
||||
result = await get_my_study_api_permissions(
|
||||
study_id=study_id,
|
||||
current_user=UserStub(id=cra_id, role="CRA"),
|
||||
current_user=UserStub(id=cra_id),
|
||||
db=db_session,
|
||||
)
|
||||
|
||||
@@ -204,7 +235,7 @@ async def test_project_pm_monitoring_scope_is_limited_to_own_projects(db_session
|
||||
await _seed_member(db_session, own_study_id, pm_id, "PM")
|
||||
await db_session.commit()
|
||||
|
||||
scope = await resolve_monitoring_scope(db_session, UserStub(id=pm_id, role="PM"))
|
||||
scope = await resolve_monitoring_scope(db_session, UserStub(id=pm_id))
|
||||
|
||||
assert scope.is_admin is False
|
||||
assert scope.study_ids == {own_study_id}
|
||||
@@ -229,7 +260,7 @@ async def test_project_pm_cannot_update_peer_pm_member(db_session: AsyncSession)
|
||||
study_id=study_id,
|
||||
member_id=peer_member_id,
|
||||
member_in=StudyMemberUpdate(role_in_study="CRA"),
|
||||
current_user=UserStub(id=actor_id, role="PM"),
|
||||
current_user=UserStub(id=actor_id),
|
||||
db=db_session,
|
||||
)
|
||||
|
||||
@@ -243,7 +274,7 @@ async def test_project_pm_cannot_grant_peer_pm_role(db_session: AsyncSession):
|
||||
cra_id = uuid.uuid4()
|
||||
await _seed_study(db_session, study_id, "PM-GRANT-PM")
|
||||
await _seed_user(db_session, actor_id)
|
||||
await _seed_user(db_session, cra_id, role="CRA")
|
||||
await _seed_user(db_session, cra_id)
|
||||
await _seed_member(db_session, study_id, actor_id, "PM")
|
||||
cra_member_id = await _seed_member_return_id(db_session, study_id, cra_id, "CRA")
|
||||
await db_session.commit()
|
||||
@@ -253,7 +284,7 @@ async def test_project_pm_cannot_grant_peer_pm_role(db_session: AsyncSession):
|
||||
study_id=study_id,
|
||||
member_id=cra_member_id,
|
||||
member_in=StudyMemberUpdate(role_in_study="PM"),
|
||||
current_user=UserStub(id=actor_id, role="PM"),
|
||||
current_user=UserStub(id=actor_id),
|
||||
db=db_session,
|
||||
)
|
||||
|
||||
|
||||
@@ -7,14 +7,13 @@ import ast
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.api_permissions import API_ENDPOINT_PERMISSIONS, OPERATION_TO_ENDPOINTS, PROJECT_PERMISSION_ROLES
|
||||
from app.core.api_permissions import API_ENDPOINT_PERMISSIONS, OPERATION_TO_ENDPOINTS, PROJECT_PERMISSION_ROLES, SYSTEM_PERMISSIONS
|
||||
from app.core.project_permissions import role_has_api_permission
|
||||
from app.core.project_permissions import get_api_endpoint_permissions, replace_api_endpoint_permissions
|
||||
from app.core.permission_cache import PermissionCache, set_permission_cache
|
||||
from app.core.permission_monitor import PermissionMonitor, set_permission_monitor
|
||||
from app.models.api_endpoint_permission import ApiEndpointPermission
|
||||
from app.models.study import Study
|
||||
from app.models.user import UserRole
|
||||
from app.schemas.member import StudyMemberCreate
|
||||
|
||||
|
||||
@@ -183,6 +182,24 @@ def test_legacy_fee_attachment_router_is_not_registered():
|
||||
assert "fees_attachments" not in router_source
|
||||
|
||||
|
||||
def test_legacy_fee_attachment_runtime_code_is_removed():
|
||||
"""旧费用附件运行时代码应删除,避免绕过模块附件权限的死代码被重新注册。"""
|
||||
backend_root = Path(__file__).resolve().parents[1] / "app"
|
||||
legacy_tokens = {
|
||||
"fees_attachments",
|
||||
"fee_attachment",
|
||||
"FeeAttachment",
|
||||
"fee_attachments",
|
||||
}
|
||||
offenders: list[str] = []
|
||||
for path in backend_root.rglob("*.py"):
|
||||
source = path.read_text(encoding="utf-8")
|
||||
if any(token in source or token in path.name for token in legacy_tokens):
|
||||
offenders.append(str(path.relative_to(backend_root)))
|
||||
|
||||
assert offenders == []
|
||||
|
||||
|
||||
def test_attachment_permissions_use_module_permissions():
|
||||
"""附件鉴权应使用模块附件权限,不再回退通用 attachments:*。"""
|
||||
attachments_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "attachments.py"
|
||||
@@ -388,12 +405,59 @@ def test_document_service_uses_specific_document_permission_keys():
|
||||
|
||||
assert '"create_document": "documents:create"' in source
|
||||
assert '"create_version": "documents:update"' in source
|
||||
assert '"distribute": "documents:update"' in source
|
||||
assert '"delete_document": "documents:delete"' in source
|
||||
assert 'else "documents:update"' not in source
|
||||
assert "await _ensure_study_access(db, doc.trial_id, current_user, action=\"distribute\")" in source
|
||||
|
||||
|
||||
def test_document_routes_do_not_hardcode_admin_for_matrix_controlled_actions():
|
||||
"""文档删除和版本删除应由项目权限矩阵控制,而不是路由层硬编码 ADMIN。"""
|
||||
route_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "documents.py"
|
||||
source = route_path.read_text()
|
||||
|
||||
delete_document_chunk = source[source.index("async def delete_document") : source.index("@router.post", source.index("async def delete_document"))]
|
||||
delete_version_chunk = source[source.index("async def delete_version") : source.index("@router.post", source.index("async def delete_version"))]
|
||||
|
||||
assert "require_roles" not in delete_document_chunk
|
||||
assert "require_roles" not in delete_version_chunk
|
||||
assert "仅管理员可删除文档" not in delete_document_chunk
|
||||
assert "仅管理员可删除版本" not in delete_version_chunk
|
||||
|
||||
|
||||
def test_project_permission_config_is_system_level_permission():
|
||||
"""项目权限配置应归属系统级权限,不能作为项目矩阵里的自管理权限。"""
|
||||
assert "permissions:read" not in API_ENDPOINT_PERMISSIONS
|
||||
assert "permissions:update" not in API_ENDPOINT_PERMISSIONS
|
||||
assert SYSTEM_PERMISSIONS["system:permissions:project_config"]["description"] == "配置项目接口权限"
|
||||
assert "PM" in SYSTEM_PERMISSIONS["system:permissions:project_config"]["roles"]
|
||||
|
||||
|
||||
def test_project_api_permission_routes_use_system_project_config_permission():
|
||||
"""项目权限矩阵 API 应明确依赖 system:permissions:project_config。"""
|
||||
route_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "api_permissions.py"
|
||||
source = route_path.read_text()
|
||||
get_chunk = source[source.index("async def get_study_api_permissions") : source.index("@study_router.put")]
|
||||
update_chunk = source[source.index("async def update_study_api_permissions") :]
|
||||
|
||||
assert 'require_system_permission("system:permissions:project_config")' in get_chunk
|
||||
assert 'require_system_permission("system:permissions:project_config")' in update_chunk
|
||||
assert 'require_study_roles(["PM"])' not in get_chunk
|
||||
assert 'require_study_roles(["PM"])' not in update_chunk
|
||||
|
||||
|
||||
def test_audit_export_event_uses_export_permission():
|
||||
"""审计导出事件应使用 audit_logs:export,而不是仅要求项目成员。"""
|
||||
route_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "audit_logs.py"
|
||||
source = route_path.read_text()
|
||||
event_chunk = source[source.index("async def create_audit_event") :]
|
||||
|
||||
assert 'require_api_permission("audit_logs:export")' in event_chunk
|
||||
assert "require_study_member()" not in event_chunk
|
||||
|
||||
|
||||
def test_user_role_contains_current_project_roles():
|
||||
assert {"QA", "CTA"} <= {role.value for role in UserRole}
|
||||
assert {"QA", "CTA"} <= set(PROJECT_PERMISSION_ROLES)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -49,8 +49,8 @@ async def test_material_equipment_operations_are_grouped_under_materials_module(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_materials_module_only_contains_drug_flow_and_equipment_sections():
|
||||
"""测试物资管理模块只包含药品流向管理和设备管理"""
|
||||
async def test_materials_module_only_contains_drug_flow_attachments_and_equipment_sections():
|
||||
"""测试物资管理模块只包含药品流向、药品流向附件和设备管理"""
|
||||
data = await list_api_operations()
|
||||
operations = data["operations"]
|
||||
|
||||
@@ -60,7 +60,7 @@ async def test_materials_module_only_contains_drug_flow_and_equipment_sections()
|
||||
if op["module"] == "materials"
|
||||
}
|
||||
|
||||
assert material_prefixes == {"drug_shipments", "material_equipments"}
|
||||
assert material_prefixes == {"drug_shipments", "drug_shipments_attachments", "material_equipments"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -90,6 +90,29 @@ def test_dashboard_summary_uses_project_overview_permission():
|
||||
assert 'require_api_permission("dashboard:read")' not in source
|
||||
|
||||
|
||||
def test_project_overview_dashboard_endpoints_require_project_overview_permission():
|
||||
"""项目总览聚合接口应统一使用 project_overview:read,而不是仅要求项目成员。"""
|
||||
source = Path(__file__).parents[1].joinpath("app/api/v1/dashboard.py").read_text()
|
||||
progress_chunk = source[source.index("async def get_progress") : source.index("@router.get", source.index("async def get_progress"))]
|
||||
lost_visits_chunk = source[source.index("async def list_lost_visits") : source.index("@router.get", source.index("async def list_lost_visits"))]
|
||||
center_summary_chunk = source[source.index("async def get_center_summary") :]
|
||||
|
||||
assert 'require_api_permission("project_overview:read")' in progress_chunk
|
||||
assert 'require_api_permission("project_overview:read")' in lost_visits_chunk
|
||||
assert 'require_api_permission("project_overview:read")' in center_summary_chunk
|
||||
assert "require_study_member()" not in progress_chunk
|
||||
assert "require_study_member()" not in lost_visits_chunk
|
||||
|
||||
|
||||
def test_finance_summary_requires_contract_fee_read_permission():
|
||||
"""项目首页财务汇总应使用费用读取权限,避免绕过费用模块权限。"""
|
||||
source = Path(__file__).parents[1].joinpath("app/api/v1/finance_dashboard.py").read_text()
|
||||
summary_chunk = source[source.index("async def finance_summary") :]
|
||||
|
||||
assert 'require_api_permission("fees_contracts:read")' in summary_chunk
|
||||
assert "require_study_member()" not in summary_chunk
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_operation_prerequisites_endpoint():
|
||||
"""测试获取所有操作的前置权限依赖"""
|
||||
|
||||
@@ -12,13 +12,13 @@ from app.models.document import Document, DocumentScopeType
|
||||
from app.models.document_version import DocumentVersion # noqa: F401
|
||||
from app.models.distribution import Distribution # noqa: F401
|
||||
from app.models.acknowledgement import Acknowledgement # noqa: F401
|
||||
from app.models.user import UserRole, UserStatus
|
||||
from app.models.user import UserStatus
|
||||
|
||||
|
||||
class UserStub:
|
||||
def __init__(self) -> None:
|
||||
self.id = uuid.uuid4()
|
||||
self.role = UserRole.ADMIN
|
||||
self.is_admin = True
|
||||
self.status = UserStatus.ACTIVE
|
||||
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@ class FakeIpInfo:
|
||||
self.location = f"中国 / {province} / {city} / {isp}"
|
||||
|
||||
|
||||
class AdminUserStub:
|
||||
is_admin = True
|
||||
|
||||
|
||||
async def _seed_permission_log(db_session, study_id: uuid.UUID, user_id: uuid.UUID, *, allowed: bool, elapsed_ms: float) -> None:
|
||||
study_exists = (
|
||||
await db_session.execute(text("SELECT id FROM studies WHERE id = :id"), {"id": str(study_id)})
|
||||
@@ -48,8 +52,8 @@ async def _seed_permission_log(db_session, study_id: uuid.UUID, user_id: uuid.UU
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO users (id, email, password_hash, full_name, role, clinical_department, status)
|
||||
VALUES (:id, :email, :password_hash, :full_name, :role, :clinical_department, :status)
|
||||
INSERT INTO users (id, email, password_hash, full_name, clinical_department, is_admin, status)
|
||||
VALUES (:id, :email, :password_hash, :full_name, :clinical_department, :is_admin, :status)
|
||||
"""
|
||||
),
|
||||
{
|
||||
@@ -57,8 +61,8 @@ async def _seed_permission_log(db_session, study_id: uuid.UUID, user_id: uuid.UU
|
||||
"email": f"{user_id.hex}@example.com",
|
||||
"password_hash": "hash",
|
||||
"full_name": "Permission Monitoring User",
|
||||
"role": "PM",
|
||||
"clinical_department": "临床运营",
|
||||
"is_admin": False,
|
||||
"status": "ACTIVE",
|
||||
},
|
||||
)
|
||||
@@ -97,7 +101,7 @@ async def test_get_permission_metrics(db_session):
|
||||
await _seed_permission_log(db_session, study_id, user_id, allowed=True, elapsed_ms=5)
|
||||
await _seed_permission_log(db_session, study_id, user_id, allowed=False, elapsed_ms=3)
|
||||
|
||||
data = await permission_monitoring.get_permission_metrics(db=db_session, _=object(), hours=24)
|
||||
data = await permission_monitoring.get_permission_metrics(db=db_session, _=AdminUserStub(), hours=24)
|
||||
assert "check_metrics" in data
|
||||
assert "cache_metrics" in data
|
||||
assert data["check_metrics"]["total_checks"] == 2
|
||||
@@ -115,7 +119,7 @@ async def test_get_cache_statistics(db_session):
|
||||
monitor.record_cache_hit()
|
||||
monitor.record_cache_miss()
|
||||
|
||||
data = await permission_monitoring.get_cache_statistics(_=object(), db=db_session)
|
||||
data = await permission_monitoring.get_cache_statistics(_=AdminUserStub(), db=db_session)
|
||||
assert "cache_metrics" in data
|
||||
assert data["cache_metrics"]["total_accesses"] == 3
|
||||
assert data["cache_metrics"]["cache_hits"] == 2
|
||||
@@ -129,7 +133,7 @@ async def test_get_alerts(db_session):
|
||||
|
||||
monitor.record_slow_check_alert(100)
|
||||
|
||||
data = await permission_monitoring.get_alerts(_=object(), db=db_session)
|
||||
data = await permission_monitoring.get_alerts(_=AdminUserStub(), db=db_session)
|
||||
assert "alerts" in data
|
||||
assert data["total"] > 0
|
||||
|
||||
@@ -143,7 +147,7 @@ async def test_get_alerts_with_level_filter(db_session):
|
||||
monitor.record_slow_check_alert(100)
|
||||
monitor.record_error_alert(ValueError("test error"))
|
||||
|
||||
data = await permission_monitoring.get_alerts(level="warning", _=object(), db=db_session)
|
||||
data = await permission_monitoring.get_alerts(level="warning", _=AdminUserStub(), db=db_session)
|
||||
assert "alerts" in data
|
||||
assert all(alert["level"] == "warning" for alert in data["alerts"])
|
||||
|
||||
@@ -157,7 +161,7 @@ async def test_get_alerts_with_limit(db_session):
|
||||
for _ in range(20):
|
||||
monitor.record_slow_check_alert(100)
|
||||
|
||||
data = await permission_monitoring.get_alerts(limit=5, _=object(), db=db_session)
|
||||
data = await permission_monitoring.get_alerts(limit=5, _=AdminUserStub(), db=db_session)
|
||||
assert len(data["alerts"]) <= 5
|
||||
|
||||
|
||||
@@ -170,7 +174,7 @@ async def test_reset_metrics(db_session):
|
||||
monitor.record_cache_hit()
|
||||
assert monitor.metrics.cache_metrics.total_accesses == 1
|
||||
|
||||
result = await permission_monitoring.reset_metrics(_=object(), db=db_session)
|
||||
result = await permission_monitoring.reset_metrics(_=AdminUserStub(), db=db_session)
|
||||
|
||||
assert result["message"] == "指标已重置"
|
||||
assert monitor.metrics.cache_metrics.total_accesses == 0
|
||||
@@ -185,7 +189,7 @@ async def test_clear_alerts(db_session):
|
||||
monitor.record_slow_check_alert(100)
|
||||
assert len(monitor.get_alerts()) > 0
|
||||
|
||||
result = await permission_monitoring.clear_alerts(_=object(), db=db_session)
|
||||
result = await permission_monitoring.clear_alerts(_=AdminUserStub(), db=db_session)
|
||||
|
||||
assert result["message"] == "告警已清除"
|
||||
assert len(monitor.get_alerts()) == 0
|
||||
@@ -200,7 +204,7 @@ async def test_permission_system_health_healthy(db_session):
|
||||
for _ in range(100):
|
||||
monitor.record_cache_hit()
|
||||
|
||||
data = await permission_monitoring.permission_system_health(db=db_session, _=object())
|
||||
data = await permission_monitoring.permission_system_health(db=db_session, _=AdminUserStub())
|
||||
assert data["status"] == "healthy"
|
||||
assert data["health_score"] >= 80
|
||||
|
||||
@@ -218,7 +222,7 @@ async def test_permission_system_health_degraded(db_session):
|
||||
for _ in range(100):
|
||||
monitor.record_cache_miss()
|
||||
|
||||
data = await permission_monitoring.permission_system_health(db=db_session, _=object())
|
||||
data = await permission_monitoring.permission_system_health(db=db_session, _=AdminUserStub())
|
||||
assert "status" in data
|
||||
assert "health_score" in data
|
||||
assert "issues" in data
|
||||
@@ -233,7 +237,7 @@ async def test_permission_system_health_includes_metrics(db_session):
|
||||
monitor = PermissionMonitor()
|
||||
set_permission_monitor(monitor)
|
||||
|
||||
data = await permission_monitoring.permission_system_health(db=db_session, _=object())
|
||||
data = await permission_monitoring.permission_system_health(db=db_session, _=AdminUserStub())
|
||||
assert "last_hour" in data
|
||||
assert "cache_stats" in data
|
||||
assert "total_checks" in data["last_hour"]
|
||||
@@ -271,8 +275,8 @@ async def test_ip_locations_counts_unique_users_per_location(db_session, monkeyp
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO users (id, email, password_hash, full_name, role, clinical_department, status)
|
||||
VALUES (:id, :email, :password_hash, :full_name, :role, :clinical_department, :status)
|
||||
INSERT INTO users (id, email, password_hash, full_name, clinical_department, is_admin, status)
|
||||
VALUES (:id, :email, :password_hash, :full_name, :clinical_department, :is_admin, :status)
|
||||
"""
|
||||
),
|
||||
{
|
||||
@@ -280,8 +284,8 @@ async def test_ip_locations_counts_unique_users_per_location(db_session, monkeyp
|
||||
"email": email,
|
||||
"password_hash": "hash",
|
||||
"full_name": email,
|
||||
"role": "PM",
|
||||
"clinical_department": "临床运营",
|
||||
"is_admin": False,
|
||||
"status": "ACTIVE",
|
||||
},
|
||||
)
|
||||
@@ -323,7 +327,7 @@ async def test_ip_locations_counts_unique_users_per_location(db_session, monkeyp
|
||||
lambda ip: FakeIpInfo("广东省", "深圳市"),
|
||||
)
|
||||
|
||||
result = await permission_monitoring.get_ip_locations(db=db_session, _=object(), days=7, limit=10)
|
||||
result = await permission_monitoring.get_ip_locations(db=db_session, _=AdminUserStub(), days=7, limit=10)
|
||||
|
||||
assert result["items"][0]["province"] == "广东省"
|
||||
assert result["items"][0]["total_count"] == 3
|
||||
@@ -363,8 +367,8 @@ async def test_ip_locations_merges_same_region_with_different_isp(db_session, mo
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO users (id, email, password_hash, full_name, role, clinical_department, status)
|
||||
VALUES (:id, :email, :password_hash, :full_name, :role, :clinical_department, :status)
|
||||
INSERT INTO users (id, email, password_hash, full_name, clinical_department, is_admin, status)
|
||||
VALUES (:id, :email, :password_hash, :full_name, :clinical_department, :is_admin, :status)
|
||||
"""
|
||||
),
|
||||
{
|
||||
@@ -372,8 +376,8 @@ async def test_ip_locations_merges_same_region_with_different_isp(db_session, mo
|
||||
"email": "region-merge@example.com",
|
||||
"password_hash": "hash",
|
||||
"full_name": "属地合并用户",
|
||||
"role": "PM",
|
||||
"clinical_department": "临床运营",
|
||||
"is_admin": False,
|
||||
"status": "ACTIVE",
|
||||
},
|
||||
)
|
||||
@@ -410,7 +414,7 @@ async def test_ip_locations_merges_same_region_with_different_isp(db_session, mo
|
||||
lambda ip: ip_info_by_address[ip],
|
||||
)
|
||||
|
||||
result = await permission_monitoring.get_ip_locations(db=db_session, _=object(), days=7, limit=10)
|
||||
result = await permission_monitoring.get_ip_locations(db=db_session, _=AdminUserStub(), days=7, limit=10)
|
||||
|
||||
assert len(result["items"]) == 1
|
||||
assert result["items"][0]["province"] == "重庆"
|
||||
@@ -448,8 +452,8 @@ async def test_ip_locations_keeps_private_network_location_label(db_session, mon
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO users (id, email, password_hash, full_name, role, clinical_department, status)
|
||||
VALUES (:id, :email, :password_hash, :full_name, :role, :clinical_department, :status)
|
||||
INSERT INTO users (id, email, password_hash, full_name, clinical_department, is_admin, status)
|
||||
VALUES (:id, :email, :password_hash, :full_name, :clinical_department, :is_admin, :status)
|
||||
"""
|
||||
),
|
||||
{
|
||||
@@ -457,8 +461,8 @@ async def test_ip_locations_keeps_private_network_location_label(db_session, mon
|
||||
"email": "private-network@example.com",
|
||||
"password_hash": "hash",
|
||||
"full_name": "局域网用户",
|
||||
"role": "PM",
|
||||
"clinical_department": "临床运营",
|
||||
"is_admin": False,
|
||||
"status": "ACTIVE",
|
||||
},
|
||||
)
|
||||
@@ -484,7 +488,7 @@ async def test_ip_locations_keeps_private_network_location_label(db_session, mon
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
result = await permission_monitoring.get_ip_locations(db=db_session, _=object(), days=7, limit=10)
|
||||
result = await permission_monitoring.get_ip_locations(db=db_session, _=AdminUserStub(), days=7, limit=10)
|
||||
|
||||
assert result["items"][0]["location"] == "局域网"
|
||||
assert result["items"][0]["province"] == ""
|
||||
@@ -525,8 +529,8 @@ async def test_access_logs_include_user_behavior_summary(db_session):
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO users (id, email, password_hash, full_name, role, clinical_department, status)
|
||||
VALUES (:id, :email, :password_hash, :full_name, :role, :clinical_department, :status)
|
||||
INSERT INTO users (id, email, password_hash, full_name, clinical_department, is_admin, status)
|
||||
VALUES (:id, :email, :password_hash, :full_name, :clinical_department, :is_admin, :status)
|
||||
"""
|
||||
),
|
||||
{
|
||||
@@ -534,8 +538,8 @@ async def test_access_logs_include_user_behavior_summary(db_session):
|
||||
"email": email,
|
||||
"password_hash": "hash",
|
||||
"full_name": name,
|
||||
"role": role,
|
||||
"clinical_department": "临床运营",
|
||||
"is_admin": False,
|
||||
"status": "ACTIVE",
|
||||
},
|
||||
)
|
||||
@@ -573,7 +577,7 @@ async def test_access_logs_include_user_behavior_summary(db_session):
|
||||
|
||||
result = await permission_monitoring.get_access_logs(
|
||||
db=db_session,
|
||||
_=object(),
|
||||
_=AdminUserStub(),
|
||||
study_id=None,
|
||||
user_id=None,
|
||||
endpoint_key=None,
|
||||
@@ -621,7 +625,7 @@ async def test_security_access_logs_include_anonymous_ip_attempts(db_session):
|
||||
|
||||
result = await permission_monitoring.get_security_access_logs(
|
||||
db=db_session,
|
||||
_=object(),
|
||||
_=AdminUserStub(),
|
||||
status_min=400,
|
||||
auth_status=None,
|
||||
page=1,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import pytest
|
||||
|
||||
from app.crud import user as user_crud
|
||||
from app.models.user import UserRole, UserStatus
|
||||
from app.models.user import UserStatus
|
||||
|
||||
|
||||
class _ScalarResult:
|
||||
@@ -37,6 +37,6 @@ async def test_ensure_admin_exists_creates_protected_admin():
|
||||
assert len(session.added) == 1
|
||||
admin = session.added[0]
|
||||
assert admin.email == "admin@huapont.cn"
|
||||
assert admin.role == UserRole.ADMIN
|
||||
assert admin.is_admin is True
|
||||
assert admin.status == UserStatus.ACTIVE
|
||||
assert session.commit_count == 1
|
||||
|
||||
@@ -5,7 +5,7 @@ from app.core import rbac
|
||||
|
||||
@dataclass
|
||||
class UserStub:
|
||||
role: str
|
||||
is_admin: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -14,12 +14,12 @@ class MemberStub:
|
||||
|
||||
|
||||
def test_non_admin_global_role_does_not_grant_project_document_permission():
|
||||
assert not rbac.is_allowed("delete_document", UserStub(role="PM"), MemberStub(role_in_study="CRA"))
|
||||
assert not rbac.is_allowed("delete_document", UserStub(), MemberStub(role_in_study="CRA"))
|
||||
|
||||
|
||||
def test_project_role_grants_project_document_permission():
|
||||
assert rbac.is_allowed("delete_document", UserStub(role="CRA"), MemberStub(role_in_study="PM"))
|
||||
assert rbac.is_allowed("delete_document", UserStub(), MemberStub(role_in_study="PM"))
|
||||
|
||||
|
||||
def test_global_admin_keeps_system_document_permission_without_membership():
|
||||
assert rbac.is_allowed("delete_document", UserStub(role="ADMIN"), None)
|
||||
assert rbac.is_allowed("delete_document", UserStub(is_admin=True), None)
|
||||
|
||||
@@ -19,7 +19,7 @@ from app.crud import user as user_crud
|
||||
from app.db.base_class import Base
|
||||
from tests.conftest import GUID
|
||||
from app.models.study_member import StudyMember
|
||||
from app.models.user import User, UserRole, UserStatus
|
||||
from app.models.user import User, UserStatus
|
||||
from app.schemas.user import UserRegisterRequest
|
||||
|
||||
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
|
||||
@@ -99,7 +99,7 @@ async def client_and_db():
|
||||
password_hash=hash_password("admin123"),
|
||||
full_name="Admin",
|
||||
clinical_department="Admin",
|
||||
role=UserRole.ADMIN,
|
||||
is_admin=True,
|
||||
status=UserStatus.ACTIVE,
|
||||
)
|
||||
session.add(admin)
|
||||
@@ -128,7 +128,7 @@ async def test_register_creates_pending_user(client_and_db):
|
||||
user = await user_crud.get_by_email(session, payload["email"])
|
||||
assert user is not None
|
||||
assert user.status == UserStatus.PENDING
|
||||
assert user.role == UserRole.PV
|
||||
assert user.is_admin is False
|
||||
member_rows = (
|
||||
await session.execute(select(StudyMember).where(StudyMember.user_id == user.id))
|
||||
).scalars().all()
|
||||
|
||||
@@ -6,17 +6,17 @@ import pytest
|
||||
from app.api.v1 import studies as studies_api
|
||||
from app.models.study import Study
|
||||
from app.models.study_member import StudyMember
|
||||
from app.models.user import User, UserRole, UserStatus
|
||||
from app.models.user import User, UserStatus
|
||||
|
||||
|
||||
def _make_user(role: UserRole = UserRole.CRA) -> User:
|
||||
def _make_user(*, is_admin: bool = False) -> User:
|
||||
return User(
|
||||
id=uuid.uuid4(),
|
||||
email="cra-pm@test.com",
|
||||
password_hash="hashed",
|
||||
full_name="CRA PM",
|
||||
clinical_department="Clinical",
|
||||
role=role,
|
||||
is_admin=is_admin,
|
||||
status=UserStatus.ACTIVE,
|
||||
)
|
||||
|
||||
@@ -36,7 +36,7 @@ def _make_study() -> Study:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_studies_returns_project_role_for_non_admin(monkeypatch):
|
||||
current_user = _make_user(UserRole.CRA)
|
||||
current_user = _make_user()
|
||||
study = _make_study()
|
||||
member = StudyMember(
|
||||
id=uuid.uuid4(),
|
||||
@@ -62,7 +62,7 @@ async def test_list_studies_returns_project_role_for_non_admin(monkeypatch):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_studies_does_not_map_system_admin_to_project_admin(monkeypatch):
|
||||
current_user = _make_user(UserRole.ADMIN)
|
||||
current_user = _make_user(is_admin=True)
|
||||
study = _make_study()
|
||||
|
||||
async def fake_list_studies(_db, skip=0, limit=100):
|
||||
|
||||
Reference in New Issue
Block a user