test_portal_auth.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. """门户跳转按需登录(mock provider)全分支测试。"""
  2. from __future__ import annotations
  3. from sqlalchemy import delete
  4. import pytest
  5. from dms.extensions import db
  6. from dms.models import AuditLog, IpEntry, OrgIpAssignment, Organization, User
  7. VERIFY_PATH = "/api/v1/auth/portal/verify"
  8. SETUP_PATH = "/api/v1/auth/portal/setup-password"
  9. @pytest.fixture()
  10. def portal_mock(monkeypatch):
  11. monkeypatch.setenv("DMS_PORTAL_PROVIDER", "mock")
  12. @pytest.fixture(autouse=True)
  13. def _clean_ip_tables(b2_app):
  14. """清空IP两表,保证setup-password的verify_login_ip恒走无分配放行分支。"""
  15. with b2_app.app_context():
  16. db.session.execute(delete(OrgIpAssignment))
  17. db.session.execute(delete(IpEntry))
  18. db.session.commit()
  19. def _verify(client, token: str):
  20. return client.post(VERIFY_PATH, json={"query": f"token={token}"})
  21. def _find_user(app, username: str) -> User:
  22. with app.app_context():
  23. user = db.session.scalar(
  24. db.select(User).where(User.username == username)
  25. )
  26. assert user is not None
  27. return user
  28. # ---------------------------------------------------------------- 开关与入参
  29. def test_verify_rejected_when_portal_disabled(b2_client, monkeypatch):
  30. monkeypatch.delenv("DMS_PORTAL_PROVIDER", raising=False)
  31. response = b2_client.post(
  32. VERIFY_PATH, json={"query": "token=mock:1:a:b"}
  33. )
  34. assert response.status_code == 400
  35. assert response.get_json()["code"] == "INVALID_ARGUMENT"
  36. def test_verify_rejects_payload_with_extra_fields(b2_client, portal_mock):
  37. response = b2_client.post(
  38. VERIFY_PATH, json={"query": "token=mock:1:a:b", "extra": 1}
  39. )
  40. assert response.status_code == 400
  41. def test_verify_rejects_missing_token(b2_client, portal_mock):
  42. response = b2_client.post(VERIFY_PATH, json={"query": "foo=1&bar=2"})
  43. assert response.status_code == 400
  44. assert response.get_json()["code"] == "INVALID_ARGUMENT"
  45. def test_verify_rejects_invalid_portal_token(b2_client, portal_mock):
  46. response = _verify(b2_client, "not-a-mock-token")
  47. assert response.status_code == 401
  48. assert response.get_json()["code"] == "PORTAL_TOKEN_INVALID"
  49. # ---------------------------------------------------------------- 新用户落库
  50. def test_verify_provisions_new_user_and_issues_setup_ticket(
  51. b2_app, b2_client, portal_mock
  52. ):
  53. response = _verify(b2_client, "mock:9001:portal_first:门户新用户:作战部")
  54. assert response.status_code == 200
  55. data = response.get_json()["data"]
  56. assert data["phase"] == "SET_PASSWORD"
  57. assert data["username"] == "portal_first"
  58. assert data["realName"] == "门户新用户"
  59. assert data["portalTicket"]
  60. user = _find_user(b2_app, "portal_first")
  61. assert user.external_id == "9001"
  62. assert user.organization_name == "作战部"
  63. assert user.last_login_at is None
  64. assert user.status == "ENABLED"
  65. assert user.role_code == "USER"
  66. with b2_app.app_context():
  67. audit = db.session.scalar(
  68. db.select(AuditLog).where(
  69. AuditLog.username == "portal_first",
  70. AuditLog.action_type == "CREATE_USER",
  71. )
  72. )
  73. assert audit is not None
  74. def test_provisioned_user_cannot_login_before_setup(
  75. b2_client, b2_password, portal_mock
  76. ):
  77. # 占位密码随机不可知:设密前任何密码都无法登录
  78. _verify(b2_client, "mock:9002:portal_locked:未设密用户")
  79. response = b2_client.post(
  80. "/api/v1/auth/login",
  81. json={
  82. "username": "portal_locked",
  83. "password": b2_password,
  84. "keepSignedIn": False,
  85. },
  86. )
  87. assert response.status_code == 401
  88. assert response.get_json()["code"] == "INVALID_CREDENTIALS"
  89. def test_organization_falls_back_to_none_when_no_match(
  90. b2_app, b2_client, portal_mock
  91. ):
  92. _verify(b2_client, "mock:9003:portal_noorg:无组织用户:不存在部门")
  93. user = _find_user(b2_app, "portal_noorg")
  94. assert user.organization_id is None
  95. assert user.organization_name is None
  96. def test_organization_falls_back_to_configured_default(
  97. b2_app, b2_client, portal_mock, monkeypatch
  98. ):
  99. with b2_app.app_context():
  100. root = db.session.scalar(
  101. db.select(Organization).where(Organization.org_code == "ORG_ROOT")
  102. )
  103. assert root is not None
  104. monkeypatch.setenv("DMS_PORTAL_DEFAULT_ORG_ID", str(root.id))
  105. _verify(b2_client, "mock:9004:portal_deforg:默认组织用户:不存在部门")
  106. user = _find_user(b2_app, "portal_deforg")
  107. assert user.organization_name == "机关"
  108. # ---------------------------------------------------------------- 既有用户匹配
  109. def test_existing_username_matched_and_backfilled(b2_app, b2_client, portal_mock):
  110. # b2_app预置username="user"(作战部,从未登录)→ 按用户名匹配并同步展示信息
  111. response = _verify(b2_client, "mock:8001:user:门户同步姓名:通信部")
  112. data = response.get_json()["data"]
  113. assert data["phase"] == "SET_PASSWORD"
  114. assert data["username"] == "user"
  115. user = _find_user(b2_app, "user")
  116. assert user.external_id == "8001"
  117. assert user.real_name == "门户同步姓名"
  118. assert user.organization_name == "通信部"
  119. with b2_app.app_context():
  120. audit = db.session.scalar(
  121. db.select(AuditLog).where(
  122. AuditLog.username == "user",
  123. AuditLog.action_type == "EDIT_USER",
  124. )
  125. )
  126. assert audit is not None
  127. def test_disabled_user_rejected(b2_client, portal_mock):
  128. # b2_app预置username="disabled"(DISABLED)
  129. response = _verify(b2_client, "mock:8002:disabled:停用用户")
  130. assert response.status_code == 401
  131. assert response.get_json()["code"] == "USER_DISABLED"
  132. # ---------------------------------------------------------------- 设密与登录闭环
  133. def test_setup_password_completes_login_and_enables_normal_login(
  134. b2_client, portal_mock
  135. ):
  136. data = _verify(
  137. b2_client, "mock:9005:portal_setup:门户设密用户"
  138. ).get_json()["data"]
  139. response = b2_client.post(
  140. SETUP_PATH,
  141. json={
  142. "portalTicket": data["portalTicket"],
  143. "newPassword": "Portal#9005",
  144. "keepSignedIn": False,
  145. },
  146. )
  147. assert response.status_code == 200
  148. payload = response.get_json()["data"]
  149. assert payload["tokenType"] == "Bearer"
  150. assert payload["expiresIn"] == 7200
  151. assert payload["user"]["username"] == "portal_setup"
  152. # 独立密码设好后可走普通用户名密码登录
  153. login = b2_client.post(
  154. "/api/v1/auth/login",
  155. json={
  156. "username": "portal_setup",
  157. "password": "Portal#9005",
  158. "keepSignedIn": False,
  159. },
  160. )
  161. assert login.status_code == 200
  162. # 票据一次性:设密后auth_version已变更,复用被拒
  163. replay = b2_client.post(
  164. SETUP_PATH,
  165. json={
  166. "portalTicket": data["portalTicket"],
  167. "newPassword": "Portal#9095",
  168. "keepSignedIn": False,
  169. },
  170. )
  171. assert replay.status_code == 401
  172. assert replay.get_json()["code"] == "PORTAL_TICKET_INVALID"
  173. def test_setup_password_keep_signed_in_extends_expiry(b2_client, portal_mock):
  174. data = _verify(
  175. b2_client, "mock:9006:portal_keep:保持登录用户"
  176. ).get_json()["data"]
  177. response = b2_client.post(
  178. SETUP_PATH,
  179. json={
  180. "portalTicket": data["portalTicket"],
  181. "newPassword": "Portal#9006",
  182. "keepSignedIn": True,
  183. },
  184. )
  185. assert response.status_code == 200
  186. assert response.get_json()["data"]["expiresIn"] == 604800
  187. def test_setup_password_rejects_weak_password_without_burning_ticket(
  188. b2_client, portal_mock
  189. ):
  190. data = _verify(
  191. b2_client, "mock:9007:portal_weak:弱密码用户"
  192. ).get_json()["data"]
  193. weak = b2_client.post(
  194. SETUP_PATH,
  195. json={
  196. "portalTicket": data["portalTicket"],
  197. "newPassword": "short",
  198. "keepSignedIn": False,
  199. },
  200. )
  201. assert weak.status_code == 400
  202. assert weak.get_json()["code"] == "INVALID_ARGUMENT"
  203. # 弱密码校验先于票据消费,票据未烧毁仍可正常设密
  204. retry = b2_client.post(
  205. SETUP_PATH,
  206. json={
  207. "portalTicket": data["portalTicket"],
  208. "newPassword": "Portal#9007",
  209. "keepSignedIn": False,
  210. },
  211. )
  212. assert retry.status_code == 200
  213. def test_setup_password_rejects_garbage_ticket(b2_client, portal_mock):
  214. response = b2_client.post(
  215. SETUP_PATH,
  216. json={
  217. "portalTicket": "not-a-jwt",
  218. "newPassword": "Portal#9000",
  219. "keepSignedIn": False,
  220. },
  221. )
  222. assert response.status_code == 401
  223. assert response.get_json()["code"] == "PORTAL_TICKET_INVALID"
  224. def test_second_launch_returns_login_phase(b2_client, portal_mock):
  225. token = "mock:9008:portal_again:二次跳转用户"
  226. first = _verify(b2_client, token).get_json()["data"]
  227. assert first["phase"] == "SET_PASSWORD"
  228. setup = b2_client.post(
  229. SETUP_PATH,
  230. json={
  231. "portalTicket": first["portalTicket"],
  232. "newPassword": "Portal#9008",
  233. "keepSignedIn": False,
  234. },
  235. )
  236. assert setup.status_code == 200
  237. # 设密登录后(last_login_at非空),再次跳转直接进入独立密码登录态
  238. second = _verify(b2_client, token).get_json()["data"]
  239. assert second["phase"] == "LOGIN"
  240. assert second["username"] == "portal_again"
  241. assert "portalTicket" not in second