test_b9_organization_user_maintenance.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789
  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, 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. name: str,
  68. parent_id: str | None,
  69. sort_no: int,
  70. status: str,
  71. row_version: int,
  72. ):
  73. return client.put(
  74. f"/api/v1/organizations/{org_id}",
  75. json={
  76. "orgName": name,
  77. "parentId": parent_id,
  78. "sortNo": sort_no,
  79. "status": status,
  80. "rowVersion": row_version,
  81. },
  82. headers=_auth(token),
  83. )
  84. def _delete_org(client, token: str, org_id: str, row_version: int):
  85. return client.delete(
  86. f"/api/v1/organizations/{org_id}?rowVersion={row_version}",
  87. headers=_auth(token),
  88. )
  89. def _create_user(
  90. client,
  91. token: str,
  92. *,
  93. username: str,
  94. real_name: str,
  95. password: str = "Initial@2026",
  96. organization_id: str | None = None,
  97. role_code: str = "USER",
  98. security_level: str = "INTERNAL",
  99. status: str = "ENABLED",
  100. ):
  101. return client.post(
  102. "/api/v1/users",
  103. json={
  104. "username": username,
  105. "realName": real_name,
  106. "initialPassword": password,
  107. "organizationId": organization_id,
  108. "roleCode": role_code,
  109. "roleId": None,
  110. "securityLevel": security_level,
  111. "status": status,
  112. },
  113. headers=_auth(token),
  114. )
  115. def _update_user(
  116. client,
  117. token: str,
  118. user_id: str,
  119. *,
  120. real_name: str,
  121. organization_id: str | None,
  122. role_code: str,
  123. security_level: str,
  124. status: str,
  125. row_version: int,
  126. ):
  127. return client.put(
  128. f"/api/v1/users/{user_id}",
  129. json={
  130. "realName": real_name,
  131. "organizationId": organization_id,
  132. "roleCode": role_code,
  133. "roleId": None,
  134. "securityLevel": security_level,
  135. "status": status,
  136. "rowVersion": row_version,
  137. },
  138. headers=_auth(token),
  139. )
  140. def _reset_password(
  141. client, token: str, user_id: str, *, new_password: str, row_version: int
  142. ):
  143. return client.post(
  144. f"/api/v1/users/{user_id}/reset-password",
  145. json={"newPassword": new_password, "rowVersion": row_version},
  146. headers=_auth(token),
  147. )
  148. # =====================================================================
  149. # 机构生命周期 + 软删除 + 复合唯一索引
  150. # =====================================================================
  151. @pytest.mark.integration
  152. def test_create_organization_success_writes_audit(b2_app, b2_client, login_token):
  153. response = _create_org(
  154. b2_client,
  155. login_token,
  156. code="ORG_B9_NEW",
  157. name="B9新增机构",
  158. parent_id=str(_org_id(b2_app, "ORG_ROOT")),
  159. sort_no=99,
  160. )
  161. assert response.status_code == 201, response.get_json()
  162. payload = response.get_json()["data"]
  163. assert payload["orgCode"] == "ORG_B9_NEW"
  164. assert payload["orgPath"] == "/机关/B9新增机构"
  165. assert payload["parentId"] == str(_org_id(b2_app, "ORG_ROOT"))
  166. with b2_app.app_context():
  167. audit = db.session.scalar(
  168. db.select(AuditLog).where(AuditLog.action_type == "CREATE_ORGANIZATION")
  169. )
  170. assert audit is not None
  171. assert audit.target_id == int(payload["id"])
  172. @pytest.mark.integration
  173. def test_create_organization_rejects_duplicate_code(b2_client, login_token):
  174. response = _create_org(
  175. b2_client,
  176. login_token,
  177. code="ORG_ROOT",
  178. name="重名机构",
  179. )
  180. assert response.status_code == 409
  181. assert response.get_json()["code"] == "DATA_VERSION_CONFLICT"
  182. @pytest.mark.integration
  183. def test_create_organization_rejects_disabled_parent(b2_client, login_token, b2_app):
  184. disabled_id = str(_org_id(b2_app, "ORG_DISABLED"))
  185. response = _create_org(
  186. b2_client,
  187. login_token,
  188. code="ORG_B9_UNDER_DISABLED",
  189. name="挂在停用机构下",
  190. parent_id=disabled_id,
  191. )
  192. assert response.status_code == 400
  193. assert response.get_json()["code"] == "INVALID_ARGUMENT"
  194. @pytest.mark.integration
  195. def test_soft_deleted_org_code_can_be_reused(b2_client, login_token, b2_app):
  196. """active_marker 复合唯一索引:软删除后允许重建同名条目。"""
  197. create_resp = _create_org(
  198. b2_client, login_token, code="ORG_B9_REVIVE", name="原机构"
  199. )
  200. assert create_resp.status_code == 201
  201. org_id = create_resp.get_json()["data"]["id"]
  202. row_version = create_resp.get_json()["data"]["rowVersion"]
  203. delete_resp = _delete_org(b2_client, login_token, org_id, row_version)
  204. assert delete_resp.status_code == 200
  205. rebuild_resp = _create_org(
  206. b2_client, login_token, code="ORG_B9_REVIVE", name="重建机构"
  207. )
  208. assert rebuild_resp.status_code == 201
  209. assert rebuild_resp.get_json()["data"]["orgName"] == "重建机构"
  210. # =====================================================================
  211. # 机构更新:row_version / 循环 / 级联路径 / 长度上限
  212. # =====================================================================
  213. @pytest.mark.integration
  214. def test_update_organization_row_version_conflict(b2_client, login_token, b2_app):
  215. create_resp = _create_org(
  216. b2_client, login_token, code="ORG_B9_VC", name="版本冲突"
  217. )
  218. org_id = create_resp.get_json()["data"]["id"]
  219. response = _update_org(
  220. b2_client,
  221. login_token,
  222. org_id,
  223. name="版本冲突",
  224. parent_id=None,
  225. sort_no=10,
  226. status="ENABLED",
  227. row_version=99,
  228. )
  229. assert response.status_code == 409
  230. body = response.get_json()
  231. assert body["code"] == "DATA_VERSION_CONFLICT"
  232. assert body["details"]["currentRowVersion"] == 0
  233. @pytest.mark.integration
  234. def test_update_organization_rejects_cycle_to_descendant(
  235. b2_client, login_token, b2_app
  236. ):
  237. """parentId 不能指向自己的后代(否则会形成环)。"""
  238. grandparent_resp = _create_org(
  239. b2_client, login_token, code="ORG_B9_GP", name="祖父"
  240. )
  241. gp_id = grandparent_resp.get_json()["data"]["id"]
  242. parent_resp = _create_org(
  243. b2_client,
  244. login_token,
  245. code="ORG_B9_P",
  246. name="父",
  247. parent_id=gp_id,
  248. )
  249. p_id = parent_resp.get_json()["data"]["id"]
  250. child_resp = _create_org(
  251. b2_client,
  252. login_token,
  253. code="ORG_B9_C",
  254. name="子",
  255. parent_id=p_id,
  256. )
  257. c_id = child_resp.get_json()["data"]["id"]
  258. # 尝试把"父"挂到"子"下面 -> 应当拒绝
  259. response = _update_org(
  260. b2_client,
  261. login_token,
  262. p_id,
  263. name="父",
  264. parent_id=c_id,
  265. sort_no=10,
  266. status="ENABLED",
  267. row_version=0,
  268. )
  269. assert response.status_code == 400
  270. assert response.get_json()["code"] == "INVALID_ARGUMENT"
  271. @pytest.mark.integration
  272. def test_update_organization_cascades_descendant_paths(
  273. b2_client, login_token, b2_app
  274. ):
  275. """重命名中间节点时,所有后代的 org_path 必须同步刷新。"""
  276. a_resp = _create_org(b2_client, login_token, code="ORG_B9_CASCADE_A", name="原名A")
  277. a_id = a_resp.get_json()["data"]["id"]
  278. b_resp = _create_org(
  279. b2_client,
  280. login_token,
  281. code="ORG_B9_CASCADE_B",
  282. name="原名B",
  283. parent_id=a_id,
  284. )
  285. b_id = b_resp.get_json()["data"]["id"]
  286. c_resp = _create_org(
  287. b2_client,
  288. login_token,
  289. code="ORG_B9_CASCADE_C",
  290. name="原名C",
  291. parent_id=b_id,
  292. )
  293. c_id = c_resp.get_json()["data"]["id"]
  294. rename_resp = _update_org(
  295. b2_client,
  296. login_token,
  297. a_id,
  298. name="新名A",
  299. parent_id=None,
  300. sort_no=10,
  301. status="ENABLED",
  302. row_version=0,
  303. )
  304. assert rename_resp.status_code == 200
  305. with b2_app.app_context():
  306. a = db.session.get(Organization, int(a_id))
  307. b = db.session.get(Organization, int(b_id))
  308. c = db.session.get(Organization, int(c_id))
  309. assert a.org_path == "/新名A"
  310. assert b.org_path == "/新名A/原名B"
  311. assert c.org_path == "/新名A/原名B/原名C"
  312. # row_version 都被递增
  313. assert a.row_version == 1
  314. assert b.row_version == 1
  315. assert c.row_version == 1
  316. @pytest.mark.integration
  317. def test_update_organization_path_length_pre_validation(
  318. b2_client, login_token, b2_app
  319. ):
  320. """M-4:路径长度溢出时整个事务回滚,不留半成品状态。"""
  321. a_resp = _create_org(
  322. b2_client, login_token, code="ORG_B9_LEN_A", name="A"
  323. )
  324. a_id = a_resp.get_json()["data"]["id"]
  325. b_resp = _create_org(
  326. b2_client, login_token, code="ORG_B9_LEN_B", name="B", parent_id=a_id
  327. )
  328. b_id = b_resp.get_json()["data"]["id"]
  329. # 制造一个超长名(>1000 字符),更新 B 应当整体回滚
  330. long_name = "X" * 1100
  331. response = _update_org(
  332. b2_client,
  333. login_token,
  334. b_id,
  335. name=long_name,
  336. parent_id=a_id,
  337. sort_no=10,
  338. status="ENABLED",
  339. row_version=0,
  340. )
  341. assert response.status_code == 400
  342. assert response.get_json()["code"] == "INVALID_ARGUMENT"
  343. # B 的原状态保持不变
  344. with b2_app.app_context():
  345. b = db.session.get(Organization, int(b_id))
  346. assert b.org_name == "B"
  347. assert b.org_path == "/A/B"
  348. assert b.row_version == 0
  349. # =====================================================================
  350. # 机构删除:占用守卫
  351. # =====================================================================
  352. @pytest.mark.integration
  353. def test_delete_organization_blocks_when_has_children(b2_client, login_token):
  354. parent_resp = _create_org(
  355. b2_client, login_token, code="ORG_B9_DEL_PARENT", name="父"
  356. )
  357. p_id = parent_resp.get_json()["data"]["id"]
  358. _create_org(
  359. b2_client,
  360. login_token,
  361. code="ORG_B9_DEL_CHILD",
  362. name="子",
  363. parent_id=p_id,
  364. )
  365. response = _delete_org(b2_client, login_token, p_id, row_version=0)
  366. assert response.status_code == 409
  367. body = response.get_json()
  368. assert body["code"] == "ORGANIZATION_IN_USE"
  369. assert body["details"]["childOrganizationCount"] == 1
  370. assert body["details"]["userCount"] == 0
  371. @pytest.mark.integration
  372. def test_delete_organization_blocks_when_has_users(
  373. b2_client, login_token, b2_app
  374. ):
  375. org_resp = _create_org(
  376. b2_client, login_token, code="ORG_B9_DEL_WITH_USER", name="占用机构"
  377. )
  378. org_id = org_resp.get_json()["data"]["id"]
  379. user_resp = _create_user(
  380. b2_client,
  381. login_token,
  382. username="b9_user_on_org",
  383. real_name="占用用户",
  384. organization_id=org_id,
  385. )
  386. assert user_resp.status_code == 201
  387. response = _delete_org(b2_client, login_token, org_id, row_version=0)
  388. assert response.status_code == 409
  389. body = response.get_json()
  390. assert body["code"] == "ORGANIZATION_IN_USE"
  391. assert body["details"]["childOrganizationCount"] == 0
  392. assert body["details"]["userCount"] == 1
  393. @pytest.mark.integration
  394. def test_delete_organization_row_version_conflict(b2_client, login_token):
  395. org_resp = _create_org(
  396. b2_client, login_token, code="ORG_B9_DEL_VC", name="版本"
  397. )
  398. org_id = org_resp.get_json()["data"]["id"]
  399. response = _delete_org(b2_client, login_token, org_id, row_version=99)
  400. assert response.status_code == 409
  401. assert response.get_json()["code"] == "DATA_VERSION_CONFLICT"
  402. # =====================================================================
  403. # 用户生命周期 + 自我保护 + 最后管理员守卫
  404. # =====================================================================
  405. @pytest.mark.integration
  406. def test_create_user_success_and_duplicate(b2_client, login_token, b2_app):
  407. org_id = str(_org_id(b2_app, "ORG_ROOT"))
  408. first = _create_user(
  409. b2_client,
  410. login_token,
  411. username="b9_dup",
  412. real_name="重名测试",
  413. organization_id=org_id,
  414. )
  415. assert first.status_code == 201
  416. second = _create_user(
  417. b2_client,
  418. login_token,
  419. username="b9_dup",
  420. real_name="重名测试2",
  421. organization_id=org_id,
  422. )
  423. assert second.status_code == 409
  424. assert second.get_json()["code"] == "DATA_VERSION_CONFLICT"
  425. @pytest.mark.integration
  426. def test_update_user_row_version_conflict(b2_client, login_token, b2_app):
  427. user_id = str(_user_id(b2_app, "user"))
  428. response = _update_user(
  429. b2_client,
  430. login_token,
  431. user_id,
  432. real_name="普通用户",
  433. organization_id=str(_org_id(b2_app, "ORG_OPS")),
  434. role_code="USER",
  435. security_level="SECRET",
  436. status="ENABLED",
  437. row_version=99,
  438. )
  439. assert response.status_code == 409
  440. body = response.get_json()
  441. assert body["code"] == "DATA_VERSION_CONFLICT"
  442. assert body["details"]["currentRowVersion"] == 0
  443. @pytest.mark.integration
  444. def test_self_protection_admin_cannot_edit_own_role_or_status(
  445. b2_app, b2_client, b2_password
  446. ):
  447. """admin 不能编辑自己的角色/状态(避免误锁自己)。"""
  448. token = _login(b2_client, b2_password, username="admin")
  449. admin_id = str(_user_id(b2_app, "admin"))
  450. # 试图改自己的角色
  451. role_resp = _update_user(
  452. b2_client,
  453. token,
  454. admin_id,
  455. real_name="系统管理员",
  456. organization_id=str(_org_id(b2_app, "ORG_ROOT")),
  457. role_code="USER",
  458. security_level="TOP_SECRET",
  459. status="ENABLED",
  460. row_version=0,
  461. )
  462. assert role_resp.status_code == 403
  463. assert role_resp.get_json()["code"] == "USER_SELF_PROTECTION"
  464. # 试图停用自己
  465. disable_resp = _update_user(
  466. b2_client,
  467. token,
  468. admin_id,
  469. real_name="系统管理员",
  470. organization_id=str(_org_id(b2_app, "ORG_ROOT")),
  471. role_code="ADMIN",
  472. security_level="TOP_SECRET",
  473. status="DISABLED",
  474. row_version=0,
  475. )
  476. assert disable_resp.status_code == 403
  477. assert disable_resp.get_json()["code"] == "USER_SELF_PROTECTION"
  478. @pytest.mark.integration
  479. def test_self_protection_admin_cannot_delete_self(b2_app, b2_client, b2_password):
  480. token = _login(b2_client, b2_password, username="admin")
  481. admin_id = str(_user_id(b2_app, "admin"))
  482. response = b2_client.delete(
  483. f"/api/v1/users/{admin_id}?rowVersion=0",
  484. headers=_auth(token),
  485. )
  486. assert response.status_code == 403
  487. assert response.get_json()["code"] == "USER_SELF_PROTECTION"
  488. @pytest.mark.integration
  489. def test_self_protection_admin_cannot_reset_own_password(
  490. b2_app, b2_client, b2_password
  491. ):
  492. token = _login(b2_client, b2_password, username="admin")
  493. admin_id = str(_user_id(b2_app, "admin"))
  494. response = _reset_password(
  495. b2_client, token, admin_id, new_password="NewPassword@2026", row_version=0
  496. )
  497. assert response.status_code == 403
  498. assert response.get_json()["code"] == "USER_SELF_PROTECTION"
  499. @pytest.mark.integration
  500. def test_last_admin_protection_blocks_delete(b2_client, login_token, b2_app):
  501. """只有一个活跃 admin 时,删除该 admin 应当被拒绝。"""
  502. admin_id = str(_user_id(b2_app, "admin"))
  503. response = b2_client.delete(
  504. f"/api/v1/users/{admin_id}?rowVersion=0",
  505. headers=_auth(login_token),
  506. )
  507. assert response.status_code == 409
  508. assert response.get_json()["code"] == "DATA_VERSION_CONFLICT"
  509. @pytest.mark.integration
  510. def test_last_admin_protection_blocks_downgrade(b2_client, login_token, b2_app):
  511. """只有一个活跃 admin 时,将其降级为 USER 应当被拒绝。"""
  512. admin_id = str(_user_id(b2_app, "admin"))
  513. response = _update_user(
  514. b2_client,
  515. login_token,
  516. admin_id,
  517. real_name="系统管理员",
  518. organization_id=str(_org_id(b2_app, "ORG_ROOT")),
  519. role_code="USER",
  520. security_level="TOP_SECRET",
  521. status="ENABLED",
  522. row_version=0,
  523. )
  524. assert response.status_code == 409
  525. assert response.get_json()["code"] == "DATA_VERSION_CONFLICT"
  526. @pytest.mark.integration
  527. def test_last_admin_protection_blocks_disable(b2_client, login_token, b2_app):
  528. """只有一个活跃 admin 时,停用该 admin 应当被拒绝。"""
  529. admin_id = str(_user_id(b2_app, "admin"))
  530. response = _update_user(
  531. b2_client,
  532. login_token,
  533. admin_id,
  534. real_name="系统管理员",
  535. organization_id=str(_org_id(b2_app, "ORG_ROOT")),
  536. role_code="ADMIN",
  537. security_level="TOP_SECRET",
  538. status="DISABLED",
  539. row_version=0,
  540. )
  541. assert response.status_code == 409
  542. assert response.get_json()["code"] == "DATA_VERSION_CONFLICT"
  543. @pytest.mark.integration
  544. def test_disabled_admin_can_be_deleted_after_losing_active_status(
  545. b2_client, login_token, b2_app
  546. ):
  547. """H-1 隐藏 bug 验证:先创建第二个 admin,再降级原 admin(合法),
  548. 接着停用原 admin(此时它已不是活跃 admin),最后删除应当通过。
  549. 这条测试保护 _ensure_not_last_active_admin 中"非活跃 admin 放行"的分支。
  550. """
  551. # 1) 新建第二个 admin
  552. second_resp = _create_user(
  553. b2_client,
  554. login_token,
  555. username="b9_second_admin",
  556. real_name="第二管理员",
  557. organization_id=str(_org_id(b2_app, "ORG_ROOT")),
  558. role_code="ADMIN",
  559. security_level="TOP_SECRET",
  560. status="ENABLED",
  561. )
  562. assert second_resp.status_code == 201
  563. second_id = second_resp.get_json()["data"]["id"]
  564. # 2) 现在可以把原 admin 降级(仍有第二 admin 守护)
  565. admin_id = str(_user_id(b2_app, "admin"))
  566. downgrade_resp = _update_user(
  567. b2_client,
  568. login_token,
  569. admin_id,
  570. real_name="系统管理员",
  571. organization_id=str(_org_id(b2_app, "ORG_ROOT")),
  572. role_code="USER",
  573. security_level="TOP_SECRET",
  574. status="ENABLED",
  575. row_version=0,
  576. )
  577. assert downgrade_resp.status_code == 200, downgrade_resp.get_json()
  578. new_version = downgrade_resp.get_json()["data"]["rowVersion"]
  579. # 3) 停用原 admin(此时它已经不是 admin,最后管理员守卫不应触发)
  580. disable_resp = _update_user(
  581. b2_client,
  582. login_token,
  583. admin_id,
  584. real_name="系统管理员",
  585. organization_id=str(_org_id(b2_app, "ORG_ROOT")),
  586. role_code="USER",
  587. security_level="TOP_SECRET",
  588. status="DISABLED",
  589. row_version=new_version,
  590. )
  591. assert disable_resp.status_code == 200, disable_resp.get_json()
  592. new_version = disable_resp.get_json()["data"]["rowVersion"]
  593. # 4) 最后删除原 admin —— _ensure_not_last_active_admin 必须放行
  594. delete_resp = b2_client.delete(
  595. f"/api/v1/users/{admin_id}?rowVersion={new_version}",
  596. headers=_auth(login_token),
  597. )
  598. assert delete_resp.status_code == 200, delete_resp.get_json()
  599. # =====================================================================
  600. # auth_version 在用户写操作中的递增策略
  601. # =====================================================================
  602. @pytest.mark.integration
  603. def test_auth_version_bumped_on_role_change(b2_app, b2_client, login_token):
  604. user_id = str(_user_id(b2_app, "user"))
  605. with b2_app.app_context():
  606. before_version = db.session.scalar(
  607. db.select(User.auth_version).where(User.username == "user")
  608. )
  609. response = _update_user(
  610. b2_client,
  611. login_token,
  612. user_id,
  613. real_name="普通用户",
  614. organization_id=str(_org_id(b2_app, "ORG_OPS")),
  615. role_code="ADMIN",
  616. security_level="SECRET",
  617. status="ENABLED",
  618. row_version=0,
  619. )
  620. assert response.status_code == 200
  621. with b2_app.app_context():
  622. after_version = db.session.scalar(
  623. db.select(User.auth_version).where(User.username == "user")
  624. )
  625. assert after_version == before_version + 1
  626. @pytest.mark.integration
  627. def test_auth_version_bumped_on_disable(b2_app, b2_client, login_token):
  628. user_id = str(_user_id(b2_app, "user"))
  629. with b2_app.app_context():
  630. before_version = db.session.scalar(
  631. db.select(User.auth_version).where(User.username == "user")
  632. )
  633. response = _update_user(
  634. b2_client,
  635. login_token,
  636. user_id,
  637. real_name="普通用户",
  638. organization_id=str(_org_id(b2_app, "ORG_OPS")),
  639. role_code="USER",
  640. security_level="SECRET",
  641. status="DISABLED",
  642. row_version=0,
  643. )
  644. assert response.status_code == 200
  645. with b2_app.app_context():
  646. after_version = db.session.scalar(
  647. db.select(User.auth_version).where(User.username == "user")
  648. )
  649. assert after_version == before_version + 1
  650. @pytest.mark.integration
  651. def test_auth_version_not_bumped_on_realname_only(b2_app, b2_client, login_token):
  652. """只改 realName 不应让旧 token 失效。"""
  653. user_id = str(_user_id(b2_app, "user"))
  654. with b2_app.app_context():
  655. before_version = db.session.scalar(
  656. db.select(User.auth_version).where(User.username == "user")
  657. )
  658. response = _update_user(
  659. b2_client,
  660. login_token,
  661. user_id,
  662. real_name="普通用户改名",
  663. organization_id=str(_org_id(b2_app, "ORG_OPS")),
  664. role_code="USER",
  665. security_level="SECRET",
  666. status="ENABLED",
  667. row_version=0,
  668. )
  669. assert response.status_code == 200
  670. with b2_app.app_context():
  671. after_version = db.session.scalar(
  672. db.select(User.auth_version).where(User.username == "user")
  673. )
  674. assert after_version == before_version
  675. @pytest.mark.integration
  676. def test_auth_version_bumped_on_password_reset(b2_app, b2_client, login_token):
  677. user_id = str(_user_id(b2_app, "user"))
  678. with b2_app.app_context():
  679. before_version = db.session.scalar(
  680. db.select(User.auth_version).where(User.username == "user")
  681. )
  682. response = _reset_password(
  683. b2_client,
  684. login_token,
  685. user_id,
  686. new_password="BrandNew@2026",
  687. row_version=0,
  688. )
  689. assert response.status_code == 200
  690. assert response.get_json()["data"]["authVersion"] == before_version + 1