test_role_management.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404
  1. """需求16 角色管理与权限矩阵测试。
  2. 覆盖:
  3. - 角色 CRUD、角色名唯一、默认角色全局唯一(先清后设)
  4. - row_version 乐观锁
  5. - ROLE_IN_USE 删除守卫与解除后可删
  6. - 权限矩阵 10 行整存校验与持久化
  7. - 用户关联(仅普通用户、auth_version 递增、解除)
  8. - 非 ADMIN 访问 403
  9. - 审计记录(CREATE_ROLE/EDIT_ROLE/CHANGE_ROLE_PERMISSION/CHANGE_ROLE_USER/DELETE_ROLE)
  10. """
  11. from __future__ import annotations
  12. import pytest
  13. from dms.extensions import db
  14. from dms.models import AuditLog, User
  15. LEVELS = ("PUBLIC", "INTERNAL", "SECRET", "CONFIDENTIAL", "TOP_SECRET")
  16. SCOPES = ("OWN", "OTHER")
  17. ACTION_KEYS = (
  18. "canView",
  19. "canDownload",
  20. "canEdit",
  21. "canDelete",
  22. "canConfigPermission",
  23. "canBindAttachment",
  24. "canUnbindAttachment",
  25. )
  26. def _auth(token: str) -> dict[str, str]:
  27. return {"Authorization": f"Bearer {token}"}
  28. def _create_role(
  29. client,
  30. token: str,
  31. *,
  32. name: str,
  33. sort_no: int = 100,
  34. status: str = "ENABLED",
  35. remark: str | None = None,
  36. is_default: bool = False,
  37. ):
  38. return client.post(
  39. "/api/v1/roles",
  40. json={
  41. "roleName": name,
  42. "sortNo": sort_no,
  43. "status": status,
  44. "remark": remark,
  45. "isDefault": is_default,
  46. },
  47. headers=_auth(token),
  48. )
  49. def _update_role(client, token, role_id, payload):
  50. return client.put(
  51. f"/api/v1/roles/{role_id}", json=payload, headers=_auth(token)
  52. )
  53. def _matrix(overrides: dict | None = None) -> list[dict]:
  54. overrides = overrides or {}
  55. rows = []
  56. for level in LEVELS:
  57. for scope in SCOPES:
  58. row: dict[str, object] = {
  59. "securityLevel": level,
  60. "deptScope": scope,
  61. }
  62. row.update({key: False for key in ACTION_KEYS})
  63. row.update(overrides.get((level, scope), {}))
  64. rows.append(row)
  65. return rows
  66. def _row_payload(role, *, is_default=None, name=None) -> dict:
  67. return {
  68. "roleName": name or role["roleName"],
  69. "sortNo": role["sortNo"],
  70. "status": role["status"],
  71. "remark": role["remark"],
  72. "isDefault": role["isDefault"] if is_default is None else is_default,
  73. "rowVersion": role["rowVersion"],
  74. }
  75. def _user_id(app, username: str) -> int:
  76. with app.app_context():
  77. user = db.session.scalar(
  78. db.select(User).where(User.username == username)
  79. )
  80. assert user is not None, f"user {username} not found"
  81. return user.id
  82. @pytest.mark.integration
  83. def test_create_role_seeds_matrix_and_lists(
  84. b2_app, b2_client, login_token, role_cleanup
  85. ):
  86. response = _create_role(b2_client, login_token, name="资料员", sort_no=10)
  87. assert response.status_code == 201, response.get_json()
  88. role = response.get_json()["data"]
  89. assert role["roleName"] == "资料员"
  90. assert role["memberCount"] == 0
  91. assert role["isDefault"] is False
  92. assert role["rowVersion"] == 0
  93. listed = b2_client.get(
  94. "/api/v1/roles", headers=_auth(login_token)
  95. ).get_json()["data"]
  96. assert listed["total"] >= 1
  97. assert any(item["roleName"] == "资料员" for item in listed["items"])
  98. matrix = b2_client.get(
  99. f"/api/v1/roles/{role['id']}/permissions", headers=_auth(login_token)
  100. ).get_json()["data"]
  101. assert len(matrix) == 10
  102. assert {(row["securityLevel"], row["deptScope"]) for row in matrix} == {
  103. (level, scope) for level in LEVELS for scope in SCOPES
  104. }
  105. assert all(row[key] is False for row in matrix for key in ACTION_KEYS)
  106. @pytest.mark.integration
  107. def test_create_role_rejects_duplicate_name(
  108. b2_client, login_token, role_cleanup
  109. ):
  110. first = _create_role(b2_client, login_token, name="重复角色")
  111. assert first.status_code == 201
  112. second = _create_role(b2_client, login_token, name="重复角色")
  113. assert second.status_code == 409
  114. assert second.get_json()["code"] == "DATA_VERSION_CONFLICT"
  115. @pytest.mark.integration
  116. def test_default_role_switches_exclusively(
  117. b2_app, b2_client, login_token, role_cleanup
  118. ):
  119. first = _create_role(
  120. b2_client, login_token, name="默认甲", is_default=True
  121. ).get_json()["data"]
  122. second = _create_role(
  123. b2_client, login_token, name="默认乙", is_default=False
  124. ).get_json()["data"]
  125. switched = _update_role(
  126. b2_client,
  127. login_token,
  128. second["id"],
  129. _row_payload(second, is_default=True),
  130. )
  131. assert switched.status_code == 200, switched.get_json()
  132. assert switched.get_json()["data"]["isDefault"] is True
  133. items = b2_client.get(
  134. "/api/v1/roles", headers=_auth(login_token)
  135. ).get_json()["data"]["items"]
  136. by_name = {item["roleName"]: item for item in items}
  137. assert by_name["默认甲"]["isDefault"] is False
  138. assert by_name["默认乙"]["isDefault"] is True
  139. # 旧默认被系统调整后 rowVersion 递增
  140. assert by_name["默认甲"]["rowVersion"] == first["rowVersion"] + 1
  141. @pytest.mark.integration
  142. def test_update_role_optimistic_lock(b2_client, login_token, role_cleanup):
  143. role = _create_role(b2_client, login_token, name="乐观锁角色").get_json()[
  144. "data"
  145. ]
  146. stale = _row_payload(role, name="新名字")
  147. stale["rowVersion"] = role["rowVersion"] + 5
  148. response = _update_role(b2_client, login_token, role["id"], stale)
  149. assert response.status_code == 409
  150. body = response.get_json()
  151. assert body["code"] == "DATA_VERSION_CONFLICT"
  152. assert body["details"]["currentRowVersion"] == 0
  153. @pytest.mark.integration
  154. def test_delete_role_blocked_while_in_use_and_succeeds_after_unbind(
  155. b2_app, b2_client, login_token, role_cleanup
  156. ):
  157. user_id = _user_id(b2_app, "user")
  158. role = _create_role(b2_client, login_token, name="在用角色").get_json()[
  159. "data"
  160. ]
  161. bound = b2_client.put(
  162. f"/api/v1/roles/{role['id']}/users",
  163. json={"userIds": [str(user_id)], "rowVersion": role["rowVersion"]},
  164. headers=_auth(login_token),
  165. )
  166. assert bound.status_code == 200, bound.get_json()
  167. blocked = b2_client.delete(
  168. f"/api/v1/roles/{role['id']}?rowVersion={bound.get_json()['data']['rowVersion']}",
  169. headers=_auth(login_token),
  170. )
  171. assert blocked.status_code == 409
  172. assert blocked.get_json()["code"] == "ROLE_IN_USE"
  173. current_version = bound.get_json()["data"]["rowVersion"]
  174. unbound = b2_client.put(
  175. f"/api/v1/roles/{role['id']}/users",
  176. json={"userIds": [], "rowVersion": current_version},
  177. headers=_auth(login_token),
  178. )
  179. assert unbound.status_code == 200
  180. deleted = b2_client.delete(
  181. f"/api/v1/roles/{role['id']}?rowVersion={unbound.get_json()['data']['rowVersion']}",
  182. headers=_auth(login_token),
  183. )
  184. assert deleted.status_code == 200
  185. items = b2_client.get(
  186. "/api/v1/roles", headers=_auth(login_token)
  187. ).get_json()["data"]["items"]
  188. assert all(item["id"] != role["id"] for item in items)
  189. @pytest.mark.integration
  190. def test_matrix_validation_rejects_malformed_rows(
  191. b2_client, login_token, role_cleanup
  192. ):
  193. role = _create_role(b2_client, login_token, name="矩阵校验角色").get_json()[
  194. "data"
  195. ]
  196. short = b2_client.put(
  197. f"/api/v1/roles/{role['id']}/permissions",
  198. json={"matrix": _matrix()[:9], "rowVersion": 0},
  199. headers=_auth(login_token),
  200. )
  201. assert short.status_code == 400
  202. duplicated = _matrix()
  203. duplicated[1]["securityLevel"] = duplicated[0]["securityLevel"]
  204. duplicated[1]["deptScope"] = duplicated[0]["deptScope"]
  205. dup = b2_client.put(
  206. f"/api/v1/roles/{role['id']}/permissions",
  207. json={"matrix": duplicated, "rowVersion": 0},
  208. headers=_auth(login_token),
  209. )
  210. assert dup.status_code == 400
  211. typed = _matrix()
  212. typed[0]["canView"] = 1
  213. bad_type = b2_client.put(
  214. f"/api/v1/roles/{role['id']}/permissions",
  215. json={"matrix": typed, "rowVersion": 0},
  216. headers=_auth(login_token),
  217. )
  218. assert bad_type.status_code == 400
  219. assert bad_type.get_json()["code"] == "INVALID_ARGUMENT"
  220. @pytest.mark.integration
  221. def test_matrix_update_persists_flags(b2_client, login_token, role_cleanup):
  222. role = _create_role(b2_client, login_token, name="矩阵更新角色").get_json()[
  223. "data"
  224. ]
  225. matrix = _matrix({("SECRET", "OWN"): {"canView": True, "canDownload": True}})
  226. saved = b2_client.put(
  227. f"/api/v1/roles/{role['id']}/permissions",
  228. json={"matrix": matrix, "rowVersion": 0},
  229. headers=_auth(login_token),
  230. )
  231. assert saved.status_code == 200, saved.get_json()
  232. assert saved.get_json()["data"]["rowVersion"] == 1
  233. reloaded = b2_client.get(
  234. f"/api/v1/roles/{role['id']}/permissions", headers=_auth(login_token)
  235. ).get_json()["data"]
  236. by_key = {(row["securityLevel"], row["deptScope"]): row for row in reloaded}
  237. assert by_key[("SECRET", "OWN")]["canView"] is True
  238. assert by_key[("SECRET", "OWN")]["canDownload"] is True
  239. assert by_key[("SECRET", "OWN")]["canEdit"] is False
  240. assert by_key[("PUBLIC", "OTHER")]["canView"] is False
  241. @pytest.mark.integration
  242. def test_role_users_plain_user_only_and_auth_version_bumped(
  243. b2_app, b2_client, login_token, role_cleanup
  244. ):
  245. admin_id = _user_id(b2_app, "admin")
  246. user_id = _user_id(b2_app, "user")
  247. disabled_id = _user_id(b2_app, "disabled")
  248. role = _create_role(b2_client, login_token, name="用户关联角色").get_json()[
  249. "data"
  250. ]
  251. rejected = b2_client.put(
  252. f"/api/v1/roles/{role['id']}/users",
  253. json={"userIds": [str(admin_id)], "rowVersion": 0},
  254. headers=_auth(login_token),
  255. )
  256. assert rejected.status_code == 400
  257. assert rejected.get_json()["code"] == "INVALID_ARGUMENT"
  258. with b2_app.app_context():
  259. baseline = db.session.scalar(
  260. db.select(User.auth_version).where(User.username == "user")
  261. )
  262. saved = b2_client.put(
  263. f"/api/v1/roles/{role['id']}/users",
  264. json={
  265. "userIds": [str(user_id), str(disabled_id)],
  266. "rowVersion": 0,
  267. },
  268. headers=_auth(login_token),
  269. )
  270. assert saved.status_code == 200, saved.get_json()
  271. assert saved.get_json()["data"]["userIds"] == [
  272. str(disabled_id),
  273. str(user_id),
  274. ]
  275. listed = b2_client.get(
  276. f"/api/v1/roles/{role['id']}/users", headers=_auth(login_token)
  277. ).get_json()["data"]
  278. assert set(listed["userIds"]) == {str(user_id), str(disabled_id)}
  279. with b2_app.app_context():
  280. stored_role_id = db.session.scalar(
  281. db.select(User.role_id).where(User.username == "user")
  282. )
  283. auth_version = db.session.scalar(
  284. db.select(User.auth_version).where(User.username == "user")
  285. )
  286. assert stored_role_id == int(role["id"])
  287. assert auth_version == baseline + 1
  288. cleared = b2_client.put(
  289. f"/api/v1/roles/{role['id']}/users",
  290. json={"userIds": [], "rowVersion": 1},
  291. headers=_auth(login_token),
  292. )
  293. assert cleared.status_code == 200
  294. with b2_app.app_context():
  295. assert (
  296. db.session.scalar(
  297. db.select(User.role_id).where(User.username == "user")
  298. )
  299. is None
  300. )
  301. @pytest.mark.integration
  302. def test_role_routes_require_admin(b2_client, token_for):
  303. user_token = token_for("user")
  304. response = b2_client.get("/api/v1/roles", headers=_auth(user_token))
  305. assert response.status_code == 403
  306. @pytest.mark.integration
  307. def test_role_audit_trail_written(
  308. b2_app, b2_client, login_token, role_cleanup
  309. ):
  310. user_id = _user_id(b2_app, "user")
  311. role = _create_role(b2_client, login_token, name="审计角色").get_json()[
  312. "data"
  313. ]
  314. b2_client.put(
  315. f"/api/v1/roles/{role['id']}/permissions",
  316. json={
  317. "matrix": _matrix({("PUBLIC", "OWN"): {"canView": True}}),
  318. "rowVersion": 0,
  319. },
  320. headers=_auth(login_token),
  321. )
  322. b2_client.put(
  323. f"/api/v1/roles/{role['id']}/users",
  324. json={"userIds": [str(user_id)], "rowVersion": 1},
  325. headers=_auth(login_token),
  326. )
  327. b2_client.put(
  328. f"/api/v1/roles/{role['id']}/users",
  329. json={"userIds": [], "rowVersion": 2},
  330. headers=_auth(login_token),
  331. )
  332. b2_client.delete(
  333. f"/api/v1/roles/{role['id']}?rowVersion=3", headers=_auth(login_token)
  334. )
  335. with b2_app.app_context():
  336. actions = set(
  337. db.session.scalars(
  338. db.select(AuditLog.action_type).where(
  339. AuditLog.target_id == int(role["id"]),
  340. AuditLog.target_type == "ROLE",
  341. )
  342. ).all()
  343. )
  344. assert {
  345. "CREATE_ROLE",
  346. "CHANGE_ROLE_PERMISSION",
  347. "CHANGE_ROLE_USER",
  348. "DELETE_ROLE",
  349. } <= actions