182 lines
7.0 KiB
Python
182 lines
7.0 KiB
Python
# -*- coding: utf-8 -*-
|
||||
|
|
"""三级 RBAC 权限模型测试(issue #62)。
|
|||
|
|
|
|||
|
|
覆盖:
|
|||
|
|
1. 三级角色权限矩阵正确(readonly/engineer/admin);
|
|||
|
|
2. 角色继承(admin 继承 engineer 继承 readonly);
|
|||
|
|
3. has_permission 允许/拒绝判定 + 理由可解释;
|
|||
|
|
4. 细粒度收窄(restricted_to_readonly 把写动作降级拒绝);
|
|||
|
|
5. 便捷判定 can_publish / 用户概览。
|
|||
|
|
"""
|
|||
|
|
import os
|
|||
|
|
import sys
|
|||
|
|
import unittest
|
|||
|
|
|
|||
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|||
|
|
import _bootstrap # noqa: F401
|
|||
|
|
|
|||
|
|
from template_console.rbac import ( # noqa: E402
|
|||
|
|
Action,
|
|||
|
|
Permission,
|
|||
|
|
Resource,
|
|||
|
|
Role,
|
|||
|
|
RoleKind,
|
|||
|
|
User,
|
|||
|
|
all_roles,
|
|||
|
|
can_publish,
|
|||
|
|
effective_permissions,
|
|||
|
|
get_role,
|
|||
|
|
has_permission,
|
|||
|
|
user_summary,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class RoleRegistryTest(unittest.TestCase):
|
|||
|
|
"""三级角色注册表。"""
|
|||
|
|
|
|||
|
|
def test_three_roles_present(self):
|
|||
|
|
roles = {r.kind for r in all_roles()}
|
|||
|
|
self.assertEqual(roles, {RoleKind.READONLY, RoleKind.ENGINEER, RoleKind.ADMIN})
|
|||
|
|
|
|||
|
|
def test_role_labels_in_chinese(self):
|
|||
|
|
self.assertEqual(get_role(RoleKind.READONLY).label, "只读")
|
|||
|
|
self.assertEqual(get_role(RoleKind.ENGINEER).label, "行业工程师")
|
|||
|
|
self.assertEqual(get_role(RoleKind.ADMIN).label, "管理员")
|
|||
|
|
|
|||
|
|
def test_role_descriptions_explainable(self):
|
|||
|
|
# 可解释性:每个角色都有职责说明
|
|||
|
|
for role in all_roles():
|
|||
|
|
self.assertTrue(role.description, f"{role.kind} 缺少 description")
|
|||
|
|
|
|||
|
|
def test_inheritance_chain(self):
|
|||
|
|
self.assertEqual(get_role(RoleKind.ADMIN).inherits, RoleKind.ENGINEER)
|
|||
|
|
self.assertEqual(get_role(RoleKind.ENGINEER).inherits, RoleKind.READONLY)
|
|||
|
|
self.assertIsNone(get_role(RoleKind.READONLY).inherits)
|
|||
|
|
|
|||
|
|
def test_permission_key_format(self):
|
|||
|
|
p = Permission(RoleKind.ADMIN, Resource.USER, Action.MANAGE)
|
|||
|
|
# 匹配键为资源:动作(角色无关,便于继承);审计键含授予角色
|
|||
|
|
self.assertEqual(p.key(), "user:manage")
|
|||
|
|
self.assertEqual(p.audit_key(), "admin:user:manage")
|
|||
|
|
|
|||
|
|
|
|||
|
|
class EffectivePermissionTest(unittest.TestCase):
|
|||
|
|
"""继承后的有效权限集合。"""
|
|||
|
|
|
|||
|
|
def test_admin_inherits_engineer_and_readonly(self):
|
|||
|
|
eff = effective_permissions(RoleKind.ADMIN)
|
|||
|
|
# 匹配键为 resource:action:admin 拥有自身的 user:manage,
|
|||
|
|
# 也继承 engineer 的 model_param:edit 与 readonly 的 layout:view
|
|||
|
|
self.assertIn("user:manage", eff)
|
|||
|
|
self.assertIn("model_param:edit", eff)
|
|||
|
|
self.assertIn("layout:view", eff)
|
|||
|
|
|
|||
|
|
def test_engineer_cannot_publish(self):
|
|||
|
|
eff = effective_permissions(RoleKind.ENGINEER)
|
|||
|
|
# 工程师不能发布/推送/管用户
|
|||
|
|
self.assertNotIn("release:publish", eff)
|
|||
|
|
self.assertNotIn("push:publish", eff)
|
|||
|
|
self.assertNotIn("user:manage", eff)
|
|||
|
|
|
|||
|
|
def test_readonly_has_no_write(self):
|
|||
|
|
eff = effective_permissions(RoleKind.READONLY)
|
|||
|
|
for key in eff:
|
|||
|
|
# 只读权限只能以 :view 结尾
|
|||
|
|
self.assertTrue(key.endswith(":view"), f"readonly 不应有写/发布权限: {key}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
class HasPermissionTest(unittest.TestCase):
|
|||
|
|
"""has_permission 判定 + 理由。"""
|
|||
|
|
|
|||
|
|
def setUp(self):
|
|||
|
|
self.ro = User("viewer", RoleKind.READONLY, "查看员")
|
|||
|
|
self.eng = User("li_engineer", RoleKind.ENGINEER, "李工")
|
|||
|
|
self.admin = User("root_admin", RoleKind.ADMIN, "管理员甲")
|
|||
|
|
|
|||
|
|
def test_readonly_view_allowed(self):
|
|||
|
|
d = has_permission(self.ro, Resource.LAYOUT, Action.VIEW)
|
|||
|
|
self.assertTrue(d.allow)
|
|||
|
|
self.assertEqual(d.source, "explicit")
|
|||
|
|
|
|||
|
|
def test_readonly_edit_denied(self):
|
|||
|
|
d = has_permission(self.ro, Resource.LAYOUT, Action.EDIT)
|
|||
|
|
self.assertFalse(d.allow)
|
|||
|
|
self.assertIn("缺少权限", d.reason)
|
|||
|
|
|
|||
|
|
def test_engineer_edit_allowed_inherited_view(self):
|
|||
|
|
# 工程师编辑是本角色权限(explicit)
|
|||
|
|
d_edit = has_permission(self.eng, Resource.LAYOUT, Action.EDIT)
|
|||
|
|
self.assertTrue(d_edit.allow)
|
|||
|
|
self.assertEqual(d_edit.source, "explicit")
|
|||
|
|
# 工程师查看布局是继承自 readonly(inherited)
|
|||
|
|
d_view = has_permission(self.eng, Resource.LAYOUT, Action.VIEW)
|
|||
|
|
self.assertTrue(d_view.allow)
|
|||
|
|
self.assertEqual(d_view.source, "inherited")
|
|||
|
|
|
|||
|
|
def test_engineer_publish_denied(self):
|
|||
|
|
d = has_permission(self.eng, Resource.RELEASE, Action.PUBLISH)
|
|||
|
|
self.assertFalse(d.allow)
|
|||
|
|
|
|||
|
|
def test_admin_publish_allowed(self):
|
|||
|
|
d = has_permission(self.admin, Resource.RELEASE, Action.PUBLISH)
|
|||
|
|
self.assertTrue(d.allow)
|
|||
|
|
self.assertEqual(d.source, "explicit")
|
|||
|
|
|
|||
|
|
def test_admin_inherited_engineer_edit(self):
|
|||
|
|
d = has_permission(self.admin, Resource.MODEL_PARAM, Action.EDIT)
|
|||
|
|
self.assertTrue(d.allow)
|
|||
|
|
self.assertEqual(d.source, "inherited")
|
|||
|
|
|
|||
|
|
def test_decision_carries_reason(self):
|
|||
|
|
# 可解释性:无论允许/拒绝,reason 非空且含用户名与资源
|
|||
|
|
for user in (self.ro, self.eng, self.admin):
|
|||
|
|
d = has_permission(user, Resource.PUSH, Action.PUBLISH)
|
|||
|
|
self.assertIn(user.username, d.reason)
|
|||
|
|
self.assertIn(Resource.PUSH.value, d.reason)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class RestrictedUserTest(unittest.TestCase):
|
|||
|
|
"""细粒度收窄:restricted_to_readonly。"""
|
|||
|
|
|
|||
|
|
def test_restricted_engineer_cannot_edit_that_resource(self):
|
|||
|
|
# 工程师本可编辑布局,但被收窄为只读后应拒绝
|
|||
|
|
u = User("limited", RoleKind.ENGINEER, "受限工程师",
|
|||
|
|
restricted_to_readonly=[Resource.LAYOUT])
|
|||
|
|
d = has_permission(u, Resource.LAYOUT, Action.EDIT)
|
|||
|
|
self.assertFalse(d.allow)
|
|||
|
|
self.assertEqual(d.source, "restricted")
|
|||
|
|
|
|||
|
|
def test_restricted_engineer_can_still_view(self):
|
|||
|
|
u = User("limited", RoleKind.ENGINEER, "受限工程师",
|
|||
|
|
restricted_to_readonly=[Resource.LAYOUT])
|
|||
|
|
d = has_permission(u, Resource.LAYOUT, Action.VIEW)
|
|||
|
|
self.assertTrue(d.allow)
|
|||
|
|
|
|||
|
|
def test_restricted_only_affects_named_resource(self):
|
|||
|
|
u = User("limited", RoleKind.ENGINEER, "受限工程师",
|
|||
|
|
restricted_to_readonly=[Resource.LAYOUT])
|
|||
|
|
# 模型超参未被收窄,仍可编辑
|
|||
|
|
d = has_permission(u, Resource.MODEL_PARAM, Action.EDIT)
|
|||
|
|
self.assertTrue(d.allow)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class ConvenienceTest(unittest.TestCase):
|
|||
|
|
"""便捷判定与用户概览。"""
|
|||
|
|
|
|||
|
|
def test_can_publish(self):
|
|||
|
|
self.assertFalse(can_publish(User("v", RoleKind.READONLY)))
|
|||
|
|
self.assertFalse(can_publish(User("e", RoleKind.ENGINEER)))
|
|||
|
|
self.assertTrue(can_publish(User("a", RoleKind.ADMIN)))
|
|||
|
|
|
|||
|
|
def test_user_summary(self):
|
|||
|
|
s = user_summary(User("li", RoleKind.ENGINEER, "李工"))
|
|||
|
|
self.assertEqual(s["username"], "li")
|
|||
|
|
self.assertEqual(s["role"], "engineer")
|
|||
|
|
self.assertEqual(s["role_label"], "行业工程师")
|
|||
|
|
self.assertGreater(s["effective_permission_count"], 0)
|
|||
|
|
self.assertEqual(s["restricted_to_readonly"], [])
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
unittest.main()
|