test_b9_organization_user_maintenance.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862
  1. """B9 机构与用户维护写操作测试。
  2. 覆盖本期新增/重构的逻辑:
  3. - 自我保护(admin 不能编辑自己角色/状态、删除、重置密码)
  4. - 最后一个活跃管理员守卫(统一覆盖删除/降级/停用)
  5. - row_version 乐观锁冲突
  6. - 机构循环检测(parentId 指向后代)
  7. - 机构占用守卫(存在子机构或挂载用户)
  8. - 级联路径更新 + 路径长度预校验
  9. - 软删除后同名重建(active_marker 复合唯一索引)
  10. - auth_version 在角色变更/停用/重置密码场景下的递增
  11. """
  12. from __future__ import annotations
  13. import pytest
  14. from dms.extensions import db
  15. from dms.models import AuditLog, Organization, Role, User
  16. def _auth(token: str) -> dict[str, str]:
  17. return {"Authorization": f"Bearer {token}"}
  18. def _login(client, password: str, *, username: str = "admin") -> str:
  19. response = client.post(
  20. "/api/v1/auth/login",
  21. json={
  22. "username": username,
  23. "password": password,
  24. "keepSignedIn": False,
  25. },
  26. )
  27. assert response.status_code == 200, response.get_json()
  28. return response.get_json()["data"]["accessToken"]
  29. def _org_id(app, code: str) -> int:
  30. with app.app_context():
  31. org = db.session.scalar(
  32. db.select(Organization).where(Organization.org_code == code)
  33. )
  34. assert org is not None, f"organization {code} not found"
  35. return org.id
  36. def _user_id(app, username: str) -> int:
  37. with app.app_context():
  38. user = db.session.scalar(db.select(User).where(User.username == username))
  39. assert user is not None, f"user {username} not found"
  40. return user.id
  41. def _create_org(
  42. client,
  43. token: str,
  44. *,
  45. code: str,
  46. name: str,
  47. parent_id: str | None = None,
  48. sort_no: int = 100,
  49. status: str = "ENABLED",
  50. ):
  51. return client.post(
  52. "/api/v1/organizations",
  53. json={
  54. "orgCode": code,
  55. "orgName": name,
  56. "parentId": parent_id,
  57. "sortNo": sort_no,
  58. "status": status,
  59. },
  60. headers=_auth(token),
  61. )
  62. def _update_org(
  63. client,
  64. token: str,
  65. org_id: str,
  66. *,
  67. code: str,
  68. name: str,
  69. parent_id: str | None,
  70. sort_no: int,
  71. status: str,
  72. row_version: int,
  73. ):
  74. return client.put(
  75. f"/api/v1/organizations/{org_id}",
  76. json={
  77. "orgCode": code,
  78. "orgName": name,
  79. "parentId": parent_id,
  80. "sortNo": sort_no,
  81. "status": status,
  82. "rowVersion": row_version,
  83. },
  84. headers=_auth(token),
  85. )
  86. def _delete_org(client, token: str, org_id: str, row_version: int):
  87. return client.delete(
  88. f"/api/v1/organizations/{org_id}?rowVersion={row_version}",
  89. headers=_auth(token),
  90. )
  91. def _create_user(
  92. client,
  93. token: str,
  94. *,
  95. username: str,
  96. real_name: str,
  97. password: str = "Initial@2026",
  98. organization_id: str | None = None,
  99. role_code: str = "USER",
  100. role_id: str | None = None,
  101. security_level: str = "INTERNAL",
  102. status: str = "ENABLED",
  103. ):
  104. return client.post(
  105. "/api/v1/users",
  106. json={
  107. "username": username,
  108. "realName": real_name,
  109. "initialPassword": password,
  110. "organizationId": organization_id,
  111. "roleCode": role_code,
  112. "roleId": role_id,
  113. "securityLevel": security_level,
  114. "status": status,
  115. },
  116. headers=_auth(token),
  117. )
  118. def _update_user(
  119. client,
  120. token: str,
  121. user_id: str,
  122. *,
  123. real_name: str,
  124. organization_id: str | None,
  125. role_code: str,
  126. security_level: str,
  127. status: str,
  128. row_version: int,
  129. ):
  130. return client.put(
  131. f"/api/v1/users/{user_id}",
  132. json={
  133. "realName": real_name,
  134. "organizationId": organization_id,
  135. "roleCode": role_code,
  136. "roleId": None,
  137. "securityLevel": security_level,
  138. "status": status,
  139. "rowVersion": row_version,
  140. },
  141. headers=_auth(token),
  142. )
  143. def _reset_password(
  144. client, token: str, user_id: str, *, new_password: str, row_version: int
  145. ):
  146. return client.post(
  147. f"/api/v1/users/{user_id}/reset-password",
  148. json={"newPassword": new_password, "rowVersion": row_version},
  149. headers=_auth(token),
  150. )
  151. # =====================================================================
  152. # 机构生命周期 + 软删除 + 复合唯一索引
  153. # =====================================================================
  154. @pytest.mark.integration
  155. def test_create_organization_success_writes_audit(b2_app, b2_client, login_token):
  156. response = _create_org(
  157. b2_client,
  158. login_token,
  159. code="ORG_B9_NEW",
  160. name="B9新增机构",
  161. parent_id=str(_org_id(b2_app, "ORG_ROOT")),
  162. sort_no=99,
  163. )
  164. assert response.status_code == 201, response.get_json()
  165. payload = response.get_json()["data"]
  166. assert payload["orgCode"] == "ORG_B9_NEW"
  167. assert payload["orgPath"] == "/机关/B9新增机构"
  168. assert payload["parentId"] == str(_org_id(b2_app, "ORG_ROOT"))
  169. with b2_app.app_context():
  170. audit = db.session.scalar(
  171. db.select(AuditLog).where(AuditLog.action_type == "CREATE_ORGANIZATION")
  172. )
  173. assert audit is not None
  174. assert audit.target_id == int(payload["id"])
  175. @pytest.mark.integration
  176. def test_create_organization_rejects_duplicate_code(b2_client, login_token):
  177. response = _create_org(
  178. b2_client,
  179. login_token,
  180. code="ORG_ROOT",
  181. name="重名机构",
  182. )
  183. assert response.status_code == 409
  184. assert response.get_json()["code"] == "DATA_VERSION_CONFLICT"
  185. @pytest.mark.integration
  186. def test_create_organization_rejects_disabled_parent(b2_client, login_token, b2_app):
  187. disabled_id = str(_org_id(b2_app, "ORG_DISABLED"))
  188. response = _create_org(
  189. b2_client,
  190. login_token,
  191. code="ORG_B9_UNDER_DISABLED",
  192. name="挂在停用机构下",
  193. parent_id=disabled_id,
  194. )
  195. assert response.status_code == 400
  196. assert response.get_json()["code"] == "INVALID_ARGUMENT"
  197. @pytest.mark.integration
  198. def test_soft_deleted_org_code_can_be_reused(b2_client, login_token, b2_app):
  199. """active_marker 复合唯一索引:软删除后允许重建同名条目。"""
  200. create_resp = _create_org(
  201. b2_client, login_token, code="ORG_B9_REVIVE", name="原机构"
  202. )
  203. assert create_resp.status_code == 201
  204. org_id = create_resp.get_json()["data"]["id"]
  205. row_version = create_resp.get_json()["data"]["rowVersion"]
  206. delete_resp = _delete_org(b2_client, login_token, org_id, row_version)
  207. assert delete_resp.status_code == 200
  208. rebuild_resp = _create_org(
  209. b2_client, login_token, code="ORG_B9_REVIVE", name="重建机构"
  210. )
  211. assert rebuild_resp.status_code == 201
  212. assert rebuild_resp.get_json()["data"]["orgName"] == "重建机构"
  213. # =====================================================================
  214. # 机构更新:row_version / 循环 / 级联路径 / 长度上限
  215. # =====================================================================
  216. @pytest.mark.integration
  217. def test_update_organization_row_version_conflict(b2_client, login_token, b2_app):
  218. create_resp = _create_org(
  219. b2_client, login_token, code="ORG_B9_VC", name="版本冲突"
  220. )
  221. org_id = create_resp.get_json()["data"]["id"]
  222. response = _update_org(
  223. b2_client,
  224. login_token,
  225. org_id,
  226. code="ORG_B9_VC",
  227. name="版本冲突",
  228. parent_id=None,
  229. sort_no=10,
  230. status="ENABLED",
  231. row_version=99,
  232. )
  233. assert response.status_code == 409
  234. body = response.get_json()
  235. assert body["code"] == "DATA_VERSION_CONFLICT"
  236. assert body["details"]["currentRowVersion"] == 0
  237. @pytest.mark.integration
  238. def test_update_organization_rejects_cycle_to_descendant(
  239. b2_client, login_token, b2_app
  240. ):
  241. """parentId 不能指向自己的后代(否则会形成环)。"""
  242. grandparent_resp = _create_org(
  243. b2_client, login_token, code="ORG_B9_GP", name="祖父"
  244. )
  245. gp_id = grandparent_resp.get_json()["data"]["id"]
  246. parent_resp = _create_org(
  247. b2_client,
  248. login_token,
  249. code="ORG_B9_P",
  250. name="父",
  251. parent_id=gp_id,
  252. )
  253. p_id = parent_resp.get_json()["data"]["id"]
  254. child_resp = _create_org(
  255. b2_client,
  256. login_token,
  257. code="ORG_B9_C",
  258. name="子",
  259. parent_id=p_id,
  260. )
  261. c_id = child_resp.get_json()["data"]["id"]
  262. # 尝试把"父"挂到"子"下面 -> 应当拒绝
  263. response = _update_org(
  264. b2_client,
  265. login_token,
  266. p_id,
  267. code="ORG_B9_P",
  268. name="父",
  269. parent_id=c_id,
  270. sort_no=10,
  271. status="ENABLED",
  272. row_version=0,
  273. )
  274. assert response.status_code == 400
  275. assert response.get_json()["code"] == "INVALID_ARGUMENT"
  276. @pytest.mark.integration
  277. def test_update_organization_cascades_descendant_paths(
  278. b2_client, login_token, b2_app
  279. ):
  280. """重命名中间节点时,所有后代的 org_path 必须同步刷新。"""
  281. a_resp = _create_org(b2_client, login_token, code="ORG_B9_CASCADE_A", name="原名A")
  282. a_id = a_resp.get_json()["data"]["id"]
  283. b_resp = _create_org(
  284. b2_client,
  285. login_token,
  286. code="ORG_B9_CASCADE_B",
  287. name="原名B",
  288. parent_id=a_id,
  289. )
  290. b_id = b_resp.get_json()["data"]["id"]
  291. c_resp = _create_org(
  292. b2_client,
  293. login_token,
  294. code="ORG_B9_CASCADE_C",
  295. name="原名C",
  296. parent_id=b_id,
  297. )
  298. c_id = c_resp.get_json()["data"]["id"]
  299. rename_resp = _update_org(
  300. b2_client,
  301. login_token,
  302. a_id,
  303. code="ORG_B9_CASCADE_A",
  304. name="新名A",
  305. parent_id=None,
  306. sort_no=10,
  307. status="ENABLED",
  308. row_version=0,
  309. )
  310. assert rename_resp.status_code == 200
  311. with b2_app.app_context():
  312. a = db.session.get(Organization, int(a_id))
  313. b = db.session.get(Organization, int(b_id))
  314. c = db.session.get(Organization, int(c_id))
  315. assert a.org_path == "/新名A"
  316. assert b.org_path == "/新名A/原名B"
  317. assert c.org_path == "/新名A/原名B/原名C"
  318. # row_version 都被递增
  319. assert a.row_version == 1
  320. assert b.row_version == 1
  321. assert c.row_version == 1
  322. @pytest.mark.integration
  323. def test_update_organization_path_length_pre_validation(
  324. b2_client, login_token, b2_app
  325. ):
  326. """M-4:路径长度溢出时整个事务回滚,不留半成品状态。"""
  327. a_resp = _create_org(
  328. b2_client, login_token, code="ORG_B9_LEN_A", name="A"
  329. )
  330. a_id = a_resp.get_json()["data"]["id"]
  331. b_resp = _create_org(
  332. b2_client, login_token, code="ORG_B9_LEN_B", name="B", parent_id=a_id
  333. )
  334. b_id = b_resp.get_json()["data"]["id"]
  335. # 制造一个超长名(>1000 字符),更新 B 应当整体回滚
  336. long_name = "X" * 1100
  337. response = _update_org(
  338. b2_client,
  339. login_token,
  340. b_id,
  341. code="ORG_B9_LEN_B",
  342. name=long_name,
  343. parent_id=a_id,
  344. sort_no=10,
  345. status="ENABLED",
  346. row_version=0,
  347. )
  348. assert response.status_code == 400
  349. assert response.get_json()["code"] == "INVALID_ARGUMENT"
  350. # B 的原状态保持不变
  351. with b2_app.app_context():
  352. b = db.session.get(Organization, int(b_id))
  353. assert b.org_name == "B"
  354. assert b.org_path == "/A/B"
  355. assert b.row_version == 0
  356. # =====================================================================
  357. # 机构删除:占用守卫
  358. # =====================================================================
  359. @pytest.mark.integration
  360. def test_delete_organization_blocks_when_has_children(b2_client, login_token):
  361. parent_resp = _create_org(
  362. b2_client, login_token, code="ORG_B9_DEL_PARENT", name="父"
  363. )
  364. p_id = parent_resp.get_json()["data"]["id"]
  365. _create_org(
  366. b2_client,
  367. login_token,
  368. code="ORG_B9_DEL_CHILD",
  369. name="子",
  370. parent_id=p_id,
  371. )
  372. response = _delete_org(b2_client, login_token, p_id, row_version=0)
  373. assert response.status_code == 409
  374. body = response.get_json()
  375. assert body["code"] == "ORGANIZATION_IN_USE"
  376. assert body["details"]["childOrganizationCount"] == 1
  377. assert body["details"]["userCount"] == 0
  378. @pytest.mark.integration
  379. def test_delete_organization_blocks_when_has_users(
  380. b2_client, login_token, b2_app
  381. ):
  382. org_resp = _create_org(
  383. b2_client, login_token, code="ORG_B9_DEL_WITH_USER", name="占用机构"
  384. )
  385. org_id = org_resp.get_json()["data"]["id"]
  386. user_resp = _create_user(
  387. b2_client,
  388. login_token,
  389. username="b9_user_on_org",
  390. real_name="占用用户",
  391. organization_id=org_id,
  392. )
  393. assert user_resp.status_code == 201
  394. response = _delete_org(b2_client, login_token, org_id, row_version=0)
  395. assert response.status_code == 409
  396. body = response.get_json()
  397. assert body["code"] == "ORGANIZATION_IN_USE"
  398. assert body["details"]["childOrganizationCount"] == 0
  399. assert body["details"]["userCount"] == 1
  400. @pytest.mark.integration
  401. def test_delete_organization_row_version_conflict(b2_client, login_token):
  402. org_resp = _create_org(
  403. b2_client, login_token, code="ORG_B9_DEL_VC", name="版本"
  404. )
  405. org_id = org_resp.get_json()["data"]["id"]
  406. response = _delete_org(b2_client, login_token, org_id, row_version=99)
  407. assert response.status_code == 409
  408. assert response.get_json()["code"] == "DATA_VERSION_CONFLICT"
  409. # =====================================================================
  410. # 用户生命周期 + 自我保护 + 最后管理员守卫
  411. # =====================================================================
  412. @pytest.mark.integration
  413. def test_create_user_success_and_duplicate(b2_client, login_token, b2_app):
  414. org_id = str(_org_id(b2_app, "ORG_ROOT"))
  415. first = _create_user(
  416. b2_client,
  417. login_token,
  418. username="b9_dup",
  419. real_name="重名测试",
  420. organization_id=org_id,
  421. )
  422. assert first.status_code == 201
  423. second = _create_user(
  424. b2_client,
  425. login_token,
  426. username="b9_dup",
  427. real_name="重名测试2",
  428. organization_id=org_id,
  429. )
  430. assert second.status_code == 409
  431. assert second.get_json()["code"] == "DATA_VERSION_CONFLICT"
  432. @pytest.mark.integration
  433. def test_update_user_row_version_conflict(b2_client, login_token, b2_app):
  434. user_id = str(_user_id(b2_app, "user"))
  435. response = _update_user(
  436. b2_client,
  437. login_token,
  438. user_id,
  439. real_name="普通用户",
  440. organization_id=str(_org_id(b2_app, "ORG_OPS")),
  441. role_code="USER",
  442. security_level="SECRET",
  443. status="ENABLED",
  444. row_version=99,
  445. )
  446. assert response.status_code == 409
  447. body = response.get_json()
  448. assert body["code"] == "DATA_VERSION_CONFLICT"
  449. assert body["details"]["currentRowVersion"] == 0
  450. @pytest.mark.integration
  451. def test_self_protection_admin_cannot_edit_own_role_or_status(
  452. b2_app, b2_client, b2_password
  453. ):
  454. """admin 不能编辑自己的角色/状态(避免误锁自己)。"""
  455. token = _login(b2_client, b2_password, username="admin")
  456. admin_id = str(_user_id(b2_app, "admin"))
  457. # 试图改自己的角色
  458. role_resp = _update_user(
  459. b2_client,
  460. token,
  461. admin_id,
  462. real_name="系统管理员",
  463. organization_id=str(_org_id(b2_app, "ORG_ROOT")),
  464. role_code="USER",
  465. security_level="TOP_SECRET",
  466. status="ENABLED",
  467. row_version=0,
  468. )
  469. assert role_resp.status_code == 403
  470. assert role_resp.get_json()["code"] == "USER_SELF_PROTECTION"
  471. # 试图停用自己
  472. disable_resp = _update_user(
  473. b2_client,
  474. token,
  475. admin_id,
  476. real_name="系统管理员",
  477. organization_id=str(_org_id(b2_app, "ORG_ROOT")),
  478. role_code="ADMIN",
  479. security_level="TOP_SECRET",
  480. status="DISABLED",
  481. row_version=0,
  482. )
  483. assert disable_resp.status_code == 403
  484. assert disable_resp.get_json()["code"] == "USER_SELF_PROTECTION"
  485. @pytest.mark.integration
  486. def test_self_protection_admin_cannot_delete_self(b2_app, b2_client, b2_password):
  487. token = _login(b2_client, b2_password, username="admin")
  488. admin_id = str(_user_id(b2_app, "admin"))
  489. response = b2_client.delete(
  490. f"/api/v1/users/{admin_id}?rowVersion=0",
  491. headers=_auth(token),
  492. )
  493. assert response.status_code == 403
  494. assert response.get_json()["code"] == "USER_SELF_PROTECTION"
  495. @pytest.mark.integration
  496. def test_self_protection_admin_cannot_reset_own_password(
  497. b2_app, b2_client, b2_password
  498. ):
  499. token = _login(b2_client, b2_password, username="admin")
  500. admin_id = str(_user_id(b2_app, "admin"))
  501. response = _reset_password(
  502. b2_client, token, admin_id, new_password="NewPassword@2026", row_version=0
  503. )
  504. assert response.status_code == 403
  505. assert response.get_json()["code"] == "USER_SELF_PROTECTION"
  506. def _guard_admin_token(b2_app, b2_client, login_token, b2_password) -> str:
  507. """创建具备用户编辑权限的全局管理员并登录,用于验证内置 admin 守卫。
  508. 内置 admin 的保护语义是"任何其他用户都不可操作",因此验证必须由
  509. 第二个有权限的管理员发起操作,而非 admin 操作自己(那会先触发自我保护)。
  510. """
  511. created = _create_user(
  512. b2_client,
  513. login_token,
  514. username="b9_guard_admin",
  515. real_name="守卫验证管理员",
  516. organization_id=str(_org_id(b2_app, "ORG_ROOT")),
  517. role_code="ADMIN",
  518. security_level="TOP_SECRET",
  519. status="ENABLED",
  520. perm_user_edit=True,
  521. )
  522. assert created.status_code == 201, created.get_json()
  523. return _login(b2_client, "Initial@2026", username="b9_guard_admin")
  524. @pytest.mark.integration
  525. def test_builtin_admin_cannot_be_deleted(b2_app, b2_client, login_token, b2_password):
  526. """内置 admin 账号不允许被任何人删除(系统守护账号)。"""
  527. token = _guard_admin_token(b2_app, b2_client, login_token, b2_password)
  528. admin_id = str(_user_id(b2_app, "admin"))
  529. response = b2_client.delete(
  530. f"/api/v1/users/{admin_id}?rowVersion=0",
  531. headers=_auth(token),
  532. )
  533. assert response.status_code == 403
  534. assert response.get_json()["code"] == "FORBIDDEN"
  535. @pytest.mark.integration
  536. def test_builtin_admin_cannot_be_downgraded(b2_app, b2_client, login_token, b2_password):
  537. """内置 admin 账号不允许被编辑(降级/停用一律拒绝)。"""
  538. token = _guard_admin_token(b2_app, b2_client, login_token, b2_password)
  539. admin_id = str(_user_id(b2_app, "admin"))
  540. response = _update_user(
  541. b2_client,
  542. token,
  543. admin_id,
  544. real_name="系统管理员",
  545. organization_id=str(_org_id(b2_app, "ORG_ROOT")),
  546. role_code="USER",
  547. security_level="TOP_SECRET",
  548. status="ENABLED",
  549. row_version=0,
  550. )
  551. assert response.status_code == 403
  552. assert response.get_json()["code"] == "FORBIDDEN"
  553. @pytest.mark.integration
  554. def test_builtin_admin_cannot_be_disabled(b2_app, b2_client, login_token, b2_password):
  555. """内置 admin 账号不允许被停用。"""
  556. token = _guard_admin_token(b2_app, b2_client, login_token, b2_password)
  557. admin_id = str(_user_id(b2_app, "admin"))
  558. response = _update_user(
  559. b2_client,
  560. token,
  561. admin_id,
  562. real_name="系统管理员",
  563. organization_id=str(_org_id(b2_app, "ORG_ROOT")),
  564. role_code="ADMIN",
  565. security_level="TOP_SECRET",
  566. status="DISABLED",
  567. row_version=0,
  568. )
  569. assert response.status_code == 403
  570. assert response.get_json()["code"] == "FORBIDDEN"
  571. @pytest.mark.integration
  572. def test_builtin_admin_cannot_be_reset_password(b2_app, b2_client, login_token, b2_password):
  573. """内置 admin 账号的密码不允许被他人重置。"""
  574. token = _guard_admin_token(b2_app, b2_client, login_token, b2_password)
  575. admin_id = str(_user_id(b2_app, "admin"))
  576. response = _reset_password(
  577. b2_client, token, admin_id, new_password="NewPassword@2026", row_version=0
  578. )
  579. assert response.status_code == 403
  580. assert response.get_json()["code"] == "FORBIDDEN"
  581. @pytest.mark.integration
  582. def test_second_admin_full_lifecycle_after_builtin_guard(
  583. b2_client, login_token, b2_app
  584. ):
  585. """非内置管理员可走完完整生命周期:创建→降级→停用→删除。
  586. 内置 admin 恒为活跃守护账号,系统永不失去管理员,
  587. 非内置管理员的降级/停用/删除不再受"最后一个活跃管理员"守卫阻塞。
  588. """
  589. # 1) 新建第二个 admin
  590. second_resp = _create_user(
  591. b2_client,
  592. login_token,
  593. username="b9_second_admin",
  594. real_name="第二管理员",
  595. organization_id=str(_org_id(b2_app, "ORG_ROOT")),
  596. role_code="ADMIN",
  597. security_level="TOP_SECRET",
  598. status="ENABLED",
  599. )
  600. assert second_resp.status_code == 201
  601. second_id = second_resp.get_json()["data"]["id"]
  602. # 2) 降级为 USER(内置 admin 守护系统,应当放行)
  603. downgrade_resp = _update_user(
  604. b2_client,
  605. login_token,
  606. second_id,
  607. real_name="第二管理员",
  608. organization_id=str(_org_id(b2_app, "ORG_ROOT")),
  609. role_code="USER",
  610. security_level="TOP_SECRET",
  611. status="ENABLED",
  612. row_version=0,
  613. )
  614. assert downgrade_resp.status_code == 200, downgrade_resp.get_json()
  615. new_version = downgrade_resp.get_json()["data"]["rowVersion"]
  616. # 3) 停用(此时已不是活跃管理员,应当放行)
  617. disable_resp = _update_user(
  618. b2_client,
  619. login_token,
  620. second_id,
  621. real_name="第二管理员",
  622. organization_id=str(_org_id(b2_app, "ORG_ROOT")),
  623. role_code="USER",
  624. security_level="TOP_SECRET",
  625. status="DISABLED",
  626. row_version=new_version,
  627. )
  628. assert disable_resp.status_code == 200, disable_resp.get_json()
  629. new_version = disable_resp.get_json()["data"]["rowVersion"]
  630. # 4) 删除——应当放行
  631. delete_resp = b2_client.delete(
  632. f"/api/v1/users/{second_id}?rowVersion={new_version}",
  633. headers=_auth(login_token),
  634. )
  635. assert delete_resp.status_code == 200, delete_resp.get_json()
  636. # =====================================================================
  637. # auth_version 在用户写操作中的递增策略
  638. # =====================================================================
  639. @pytest.mark.integration
  640. def test_auth_version_bumped_on_role_change(b2_app, b2_client, login_token):
  641. user_id = str(_user_id(b2_app, "user"))
  642. with b2_app.app_context():
  643. before_version = db.session.scalar(
  644. db.select(User.auth_version).where(User.username == "user")
  645. )
  646. response = _update_user(
  647. b2_client,
  648. login_token,
  649. user_id,
  650. real_name="普通用户",
  651. organization_id=str(_org_id(b2_app, "ORG_OPS")),
  652. role_code="ADMIN",
  653. security_level="SECRET",
  654. status="ENABLED",
  655. row_version=0,
  656. )
  657. assert response.status_code == 200
  658. with b2_app.app_context():
  659. after_version = db.session.scalar(
  660. db.select(User.auth_version).where(User.username == "user")
  661. )
  662. assert after_version == before_version + 1
  663. @pytest.mark.integration
  664. def test_auth_version_bumped_on_disable(b2_app, b2_client, login_token):
  665. user_id = str(_user_id(b2_app, "user"))
  666. with b2_app.app_context():
  667. before_version = db.session.scalar(
  668. db.select(User.auth_version).where(User.username == "user")
  669. )
  670. response = _update_user(
  671. b2_client,
  672. login_token,
  673. user_id,
  674. real_name="普通用户",
  675. organization_id=str(_org_id(b2_app, "ORG_OPS")),
  676. role_code="USER",
  677. security_level="SECRET",
  678. status="DISABLED",
  679. row_version=0,
  680. )
  681. assert response.status_code == 200
  682. with b2_app.app_context():
  683. after_version = db.session.scalar(
  684. db.select(User.auth_version).where(User.username == "user")
  685. )
  686. assert after_version == before_version + 1
  687. @pytest.mark.integration
  688. def test_auth_version_not_bumped_on_realname_only(b2_app, b2_client, login_token):
  689. """只改 realName 不应让旧 token 失效。"""
  690. user_id = str(_user_id(b2_app, "user"))
  691. with b2_app.app_context():
  692. before_version = db.session.scalar(
  693. db.select(User.auth_version).where(User.username == "user")
  694. )
  695. response = _update_user(
  696. b2_client,
  697. login_token,
  698. user_id,
  699. real_name="普通用户改名",
  700. organization_id=str(_org_id(b2_app, "ORG_OPS")),
  701. role_code="USER",
  702. security_level="SECRET",
  703. status="ENABLED",
  704. row_version=0,
  705. )
  706. assert response.status_code == 200
  707. with b2_app.app_context():
  708. after_version = db.session.scalar(
  709. db.select(User.auth_version).where(User.username == "user")
  710. )
  711. assert after_version == before_version
  712. @pytest.mark.integration
  713. def test_auth_version_bumped_on_password_reset(b2_app, b2_client, login_token):
  714. user_id = str(_user_id(b2_app, "user"))
  715. with b2_app.app_context():
  716. before_version = db.session.scalar(
  717. db.select(User.auth_version).where(User.username == "user")
  718. )
  719. response = _reset_password(
  720. b2_client,
  721. login_token,
  722. user_id,
  723. new_password="BrandNew@2026",
  724. row_version=0,
  725. )
  726. assert response.status_code == 200
  727. assert response.get_json()["data"]["authVersion"] == before_version + 1
  728. # =====================================================================
  729. # 管理员关联角色(角色仅约束其文档操作权限矩阵,管理位不受影响)
  730. # =====================================================================
  731. @pytest.mark.integration
  732. def test_admin_can_be_created_with_role(b2_app, b2_client, login_token):
  733. """ADMIN 创建/编辑可携带 roleId,响应回带角色名。"""
  734. with b2_app.app_context():
  735. role = Role(role_name="管理员挂载角色", sort_no=10, status="ENABLED")
  736. db.session.add(role)
  737. db.session.commit()
  738. role_id = str(role.id)
  739. created = _create_user(
  740. b2_client,
  741. login_token,
  742. username="b9_role_admin",
  743. real_name="挂角色管理员",
  744. organization_id=str(_org_id(b2_app, "ORG_ROOT")),
  745. role_code="ADMIN",
  746. role_id=role_id,
  747. security_level="TOP_SECRET",
  748. status="ENABLED",
  749. )
  750. assert created.status_code == 201, created.get_json()
  751. data = created.get_json()["data"]
  752. assert data["roleId"] == role_id
  753. assert data["roleName"] == "管理员挂载角色"