test_role_management.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476
  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. "canUpload",
  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 _user_id(app, username: str) -> int:
  77. with app.app_context():
  78. user = db.session.scalar(
  79. db.select(User).where(User.username == username)
  80. )
  81. assert user is not None, f"user {username} not found"
  82. return user.id
  83. @pytest.mark.integration
  84. def test_create_role_seeds_matrix_and_lists(
  85. b2_app, b2_client, login_token, role_cleanup
  86. ):
  87. response = _create_role(b2_client, login_token, name="资料员", sort_no=10)
  88. assert response.status_code == 201, response.get_json()
  89. role = response.get_json()["data"]
  90. assert role["roleName"] == "资料员"
  91. assert role["memberCount"] == 0
  92. assert role["isDefault"] is False
  93. assert role["rowVersion"] == 0
  94. listed = b2_client.get(
  95. "/api/v1/roles", headers=_auth(login_token)
  96. ).get_json()["data"]
  97. assert listed["total"] >= 1
  98. assert any(item["roleName"] == "资料员" for item in listed["items"])
  99. matrix = b2_client.get(
  100. f"/api/v1/roles/{role['id']}/permissions", headers=_auth(login_token)
  101. ).get_json()["data"]
  102. assert len(matrix) == 10
  103. assert {(row["securityLevel"], row["deptScope"]) for row in matrix} == {
  104. (level, scope) for level in LEVELS for scope in SCOPES
  105. }
  106. assert all(row[key] is False for row in matrix for key in ACTION_KEYS)
  107. @pytest.mark.integration
  108. def test_create_role_rejects_duplicate_name(
  109. b2_client, login_token, role_cleanup
  110. ):
  111. first = _create_role(b2_client, login_token, name="重复角色")
  112. assert first.status_code == 201
  113. second = _create_role(b2_client, login_token, name="重复角色")
  114. assert second.status_code == 409
  115. assert second.get_json()["code"] == "DATA_VERSION_CONFLICT"
  116. @pytest.mark.integration
  117. def test_default_role_switches_exclusively(
  118. b2_app, b2_client, login_token, role_cleanup
  119. ):
  120. first = _create_role(
  121. b2_client, login_token, name="默认甲", is_default=True
  122. ).get_json()["data"]
  123. second = _create_role(
  124. b2_client, login_token, name="默认乙", is_default=False
  125. ).get_json()["data"]
  126. switched = _update_role(
  127. b2_client,
  128. login_token,
  129. second["id"],
  130. _row_payload(second, is_default=True),
  131. )
  132. assert switched.status_code == 200, switched.get_json()
  133. assert switched.get_json()["data"]["isDefault"] is True
  134. items = b2_client.get(
  135. "/api/v1/roles", headers=_auth(login_token)
  136. ).get_json()["data"]["items"]
  137. by_name = {item["roleName"]: item for item in items}
  138. assert by_name["默认甲"]["isDefault"] is False
  139. assert by_name["默认乙"]["isDefault"] is True
  140. # 旧默认被系统调整后 rowVersion 递增
  141. assert by_name["默认甲"]["rowVersion"] == first["rowVersion"] + 1
  142. @pytest.mark.integration
  143. def test_update_role_optimistic_lock(b2_client, login_token, role_cleanup):
  144. role = _create_role(b2_client, login_token, name="乐观锁角色").get_json()[
  145. "data"
  146. ]
  147. stale = _row_payload(role, name="新名字")
  148. stale["rowVersion"] = role["rowVersion"] + 5
  149. response = _update_role(b2_client, login_token, role["id"], stale)
  150. assert response.status_code == 409
  151. body = response.get_json()
  152. assert body["code"] == "DATA_VERSION_CONFLICT"
  153. assert body["details"]["currentRowVersion"] == 0
  154. @pytest.mark.integration
  155. def test_delete_role_blocked_while_in_use_and_succeeds_after_unbind(
  156. b2_app, b2_client, login_token, role_cleanup
  157. ):
  158. user_id = _user_id(b2_app, "user")
  159. role = _create_role(b2_client, login_token, name="在用角色").get_json()[
  160. "data"
  161. ]
  162. bound = b2_client.put(
  163. f"/api/v1/roles/{role['id']}/users",
  164. json={"userIds": [str(user_id)], "rowVersion": role["rowVersion"]},
  165. headers=_auth(login_token),
  166. )
  167. assert bound.status_code == 200, bound.get_json()
  168. blocked = b2_client.delete(
  169. f"/api/v1/roles/{role['id']}?rowVersion={bound.get_json()['data']['rowVersion']}",
  170. headers=_auth(login_token),
  171. )
  172. assert blocked.status_code == 409
  173. assert blocked.get_json()["code"] == "ROLE_IN_USE"
  174. current_version = bound.get_json()["data"]["rowVersion"]
  175. unbound = b2_client.put(
  176. f"/api/v1/roles/{role['id']}/users",
  177. json={"userIds": [], "rowVersion": current_version},
  178. headers=_auth(login_token),
  179. )
  180. assert unbound.status_code == 200
  181. deleted = b2_client.delete(
  182. f"/api/v1/roles/{role['id']}?rowVersion={unbound.get_json()['data']['rowVersion']}",
  183. headers=_auth(login_token),
  184. )
  185. assert deleted.status_code == 200
  186. items = b2_client.get(
  187. "/api/v1/roles", headers=_auth(login_token)
  188. ).get_json()["data"]["items"]
  189. assert all(item["id"] != role["id"] for item in items)
  190. @pytest.mark.integration
  191. def test_matrix_validation_rejects_malformed_rows(
  192. b2_client, login_token, role_cleanup
  193. ):
  194. role = _create_role(b2_client, login_token, name="矩阵校验角色").get_json()[
  195. "data"
  196. ]
  197. short = b2_client.put(
  198. f"/api/v1/roles/{role['id']}/permissions",
  199. json={"matrix": _matrix()[:9], "rowVersion": 0},
  200. headers=_auth(login_token),
  201. )
  202. assert short.status_code == 400
  203. duplicated = _matrix()
  204. duplicated[1]["securityLevel"] = duplicated[0]["securityLevel"]
  205. duplicated[1]["deptScope"] = duplicated[0]["deptScope"]
  206. dup = b2_client.put(
  207. f"/api/v1/roles/{role['id']}/permissions",
  208. json={"matrix": duplicated, "rowVersion": 0},
  209. headers=_auth(login_token),
  210. )
  211. assert dup.status_code == 400
  212. typed = _matrix()
  213. typed[0]["canView"] = 1
  214. bad_type = b2_client.put(
  215. f"/api/v1/roles/{role['id']}/permissions",
  216. json={"matrix": typed, "rowVersion": 0},
  217. headers=_auth(login_token),
  218. )
  219. assert bad_type.status_code == 400
  220. assert bad_type.get_json()["code"] == "INVALID_ARGUMENT"
  221. @pytest.mark.integration
  222. def test_matrix_update_persists_flags(b2_client, login_token, role_cleanup):
  223. role = _create_role(b2_client, login_token, name="矩阵更新角色").get_json()[
  224. "data"
  225. ]
  226. matrix = _matrix({("SECRET", "OWN"): {"canView": True, "canDownload": True}})
  227. saved = b2_client.put(
  228. f"/api/v1/roles/{role['id']}/permissions",
  229. json={"matrix": matrix, "rowVersion": 0},
  230. headers=_auth(login_token),
  231. )
  232. assert saved.status_code == 200, saved.get_json()
  233. assert saved.get_json()["data"]["rowVersion"] == 1
  234. reloaded = b2_client.get(
  235. f"/api/v1/roles/{role['id']}/permissions", headers=_auth(login_token)
  236. ).get_json()["data"]
  237. by_key = {(row["securityLevel"], row["deptScope"]): row for row in reloaded}
  238. assert by_key[("SECRET", "OWN")]["canView"] is True
  239. assert by_key[("SECRET", "OWN")]["canDownload"] is True
  240. assert by_key[("SECRET", "OWN")]["canEdit"] is False
  241. assert by_key[("PUBLIC", "OTHER")]["canView"] is False
  242. @pytest.mark.integration
  243. def test_matrix_upload_flag_own_only(b2_client, login_token, role_cleanup):
  244. """上传位(契约1.31):仅 OWN 行可设,OTHER 行为 true 拒收 400。"""
  245. role = _create_role(b2_client, login_token, name="上传位角色").get_json()["data"]
  246. rejected = b2_client.put(
  247. f"/api/v1/roles/{role['id']}/permissions",
  248. json={
  249. "matrix": _matrix({("PUBLIC", "OTHER"): {"canUpload": True}}),
  250. "rowVersion": 0,
  251. },
  252. headers=_auth(login_token),
  253. )
  254. assert rejected.status_code == 400
  255. assert rejected.get_json()["code"] == "INVALID_ARGUMENT"
  256. saved = b2_client.put(
  257. f"/api/v1/roles/{role['id']}/permissions",
  258. json={
  259. "matrix": _matrix({("SECRET", "OWN"): {"canUpload": True}}),
  260. "rowVersion": 0,
  261. },
  262. headers=_auth(login_token),
  263. )
  264. assert saved.status_code == 200, saved.get_json()
  265. reloaded = b2_client.get(
  266. f"/api/v1/roles/{role['id']}/permissions", headers=_auth(login_token)
  267. ).get_json()["data"]
  268. by_key = {(row["securityLevel"], row["deptScope"]): row for row in reloaded}
  269. assert by_key[("SECRET", "OWN")]["canUpload"] is True
  270. assert by_key[("SECRET", "OTHER")]["canUpload"] is False
  271. @pytest.mark.integration
  272. def test_perm_upload_derived_from_matrix(
  273. b2_app, b2_client, login_token, b2_password, role_cleanup
  274. ):
  275. """permUpload(契约1.31):任一 OWN 行 canUpload=true 的角色生效后登录响应为 true。"""
  276. role = _create_role(b2_client, login_token, name="上传位会话角色").get_json()[
  277. "data"
  278. ]
  279. saved = b2_client.put(
  280. f"/api/v1/roles/{role['id']}/permissions",
  281. json={
  282. "matrix": _matrix({("PUBLIC", "OWN"): {"canUpload": True}}),
  283. "rowVersion": 0,
  284. },
  285. headers=_auth(login_token),
  286. )
  287. assert saved.status_code == 200, saved.get_json()
  288. user_id = _user_id(b2_app, "user")
  289. linked = b2_client.put(
  290. f"/api/v1/roles/{role['id']}/users",
  291. json={"userIds": [str(user_id)], "rowVersion": 0},
  292. headers=_auth(login_token),
  293. )
  294. assert linked.status_code == 200, linked.get_json()
  295. login = b2_client.post(
  296. "/api/v1/auth/login",
  297. json={
  298. "username": "user",
  299. "password": b2_password,
  300. "keepSignedIn": False,
  301. },
  302. )
  303. assert login.status_code == 200, login.get_json()
  304. assert login.get_json()["data"]["user"]["permUpload"] is True
  305. @pytest.mark.integration
  306. def test_role_users_plain_user_only_and_auth_version_bumped(
  307. b2_app, b2_client, login_token, role_cleanup
  308. ):
  309. admin_id = _user_id(b2_app, "admin")
  310. user_id = _user_id(b2_app, "user")
  311. disabled_id = _user_id(b2_app, "disabled")
  312. role = _create_role(b2_client, login_token, name="用户关联角色").get_json()[
  313. "data"
  314. ]
  315. rejected = b2_client.put(
  316. f"/api/v1/roles/{role['id']}/users",
  317. json={"userIds": [str(admin_id)], "rowVersion": 0},
  318. headers=_auth(login_token),
  319. )
  320. assert rejected.status_code == 400
  321. assert rejected.get_json()["code"] == "INVALID_ARGUMENT"
  322. with b2_app.app_context():
  323. baseline = db.session.scalar(
  324. db.select(User.auth_version).where(User.username == "user")
  325. )
  326. saved = b2_client.put(
  327. f"/api/v1/roles/{role['id']}/users",
  328. json={
  329. "userIds": [str(user_id), str(disabled_id)],
  330. "rowVersion": 0,
  331. },
  332. headers=_auth(login_token),
  333. )
  334. assert saved.status_code == 200, saved.get_json()
  335. assert saved.get_json()["data"]["userIds"] == [
  336. str(disabled_id),
  337. str(user_id),
  338. ]
  339. listed = b2_client.get(
  340. f"/api/v1/roles/{role['id']}/users", headers=_auth(login_token)
  341. ).get_json()["data"]
  342. assert set(listed["userIds"]) == {str(user_id), str(disabled_id)}
  343. with b2_app.app_context():
  344. stored_role_id = db.session.scalar(
  345. db.select(User.role_id).where(User.username == "user")
  346. )
  347. auth_version = db.session.scalar(
  348. db.select(User.auth_version).where(User.username == "user")
  349. )
  350. assert stored_role_id == int(role["id"])
  351. assert auth_version == baseline + 1
  352. cleared = b2_client.put(
  353. f"/api/v1/roles/{role['id']}/users",
  354. json={"userIds": [], "rowVersion": 1},
  355. headers=_auth(login_token),
  356. )
  357. assert cleared.status_code == 200
  358. with b2_app.app_context():
  359. assert (
  360. db.session.scalar(
  361. db.select(User.role_id).where(User.username == "user")
  362. )
  363. is None
  364. )
  365. @pytest.mark.integration
  366. def test_role_routes_require_admin(b2_client, token_for):
  367. user_token = token_for("user")
  368. response = b2_client.get("/api/v1/roles", headers=_auth(user_token))
  369. assert response.status_code == 403
  370. @pytest.mark.integration
  371. def test_role_audit_trail_written(
  372. b2_app, b2_client, login_token, role_cleanup
  373. ):
  374. user_id = _user_id(b2_app, "user")
  375. role = _create_role(b2_client, login_token, name="审计角色").get_json()[
  376. "data"
  377. ]
  378. b2_client.put(
  379. f"/api/v1/roles/{role['id']}/permissions",
  380. json={
  381. "matrix": _matrix({("PUBLIC", "OWN"): {"canView": True}}),
  382. "rowVersion": 0,
  383. },
  384. headers=_auth(login_token),
  385. )
  386. b2_client.put(
  387. f"/api/v1/roles/{role['id']}/users",
  388. json={"userIds": [str(user_id)], "rowVersion": 1},
  389. headers=_auth(login_token),
  390. )
  391. b2_client.put(
  392. f"/api/v1/roles/{role['id']}/users",
  393. json={"userIds": [], "rowVersion": 2},
  394. headers=_auth(login_token),
  395. )
  396. b2_client.delete(
  397. f"/api/v1/roles/{role['id']}?rowVersion=3", headers=_auth(login_token)
  398. )
  399. with b2_app.app_context():
  400. actions = set(
  401. db.session.scalars(
  402. db.select(AuditLog.action_type).where(
  403. AuditLog.target_id == int(role["id"]),
  404. AuditLog.target_type == "ROLE",
  405. )
  406. ).all()
  407. )
  408. assert {
  409. "CREATE_ROLE",
  410. "CHANGE_ROLE_PERMISSION",
  411. "CHANGE_ROLE_USER",
  412. "DELETE_ROLE",
  413. } <= actions