test_role_management.py 14 KB

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