test_portal_auth.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438
  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. def test_verify_set_password_embeds_enabled_org_options(b2_client, portal_mock):
  109. # 匹配作战部 → 机构下拉仅含启用机构(不含停用部门),预选推断结果
  110. data = _verify(
  111. b2_client, "mock:9010:portal_orgs:机构列表用户:作战部"
  112. ).get_json()["data"]
  113. assert data["phase"] == "SET_PASSWORD"
  114. orgs = data["organizations"]
  115. assert {o["orgName"] for o in orgs} == {"机关", "作战部", "通信部"}
  116. assert all(
  117. set(o) == {"id", "orgCode", "orgName", "orgPath"} for o in orgs
  118. )
  119. assert all(isinstance(o["id"], str) for o in orgs)
  120. # 按 org_path 稳定排序:机关 → 作战部 → 通信部
  121. assert [o["orgName"] for o in orgs] == ["机关", "作战部", "通信部"]
  122. ops = next(o for o in orgs if o["orgName"] == "作战部")
  123. assert data["suggestedOrganizationId"] == ops["id"]
  124. def test_verify_set_password_suggested_null_without_org(b2_client, portal_mock):
  125. # 无匹配机构且未配置默认 → 预选为空,机构列表仍内嵌
  126. data = _verify(
  127. b2_client, "mock:9011:portal_noorg2:无预选用户:不存在部门"
  128. ).get_json()["data"]
  129. assert data["phase"] == "SET_PASSWORD"
  130. assert data["suggestedOrganizationId"] is None
  131. assert len(data["organizations"]) == 3
  132. # ---------------------------------------------------------------- 既有用户匹配
  133. def test_existing_username_matched_and_backfilled(b2_app, b2_client, portal_mock):
  134. # b2_app预置username="user"(作战部,从未登录)→ 按用户名匹配并同步展示信息
  135. response = _verify(b2_client, "mock:8001:user:门户同步姓名:通信部")
  136. data = response.get_json()["data"]
  137. assert data["phase"] == "SET_PASSWORD"
  138. assert data["username"] == "user"
  139. user = _find_user(b2_app, "user")
  140. assert user.external_id == "8001"
  141. assert user.real_name == "门户同步姓名"
  142. assert user.organization_name == "通信部"
  143. with b2_app.app_context():
  144. audit = db.session.scalar(
  145. db.select(AuditLog).where(
  146. AuditLog.username == "user",
  147. AuditLog.action_type == "EDIT_USER",
  148. )
  149. )
  150. assert audit is not None
  151. def test_disabled_user_rejected(b2_client, portal_mock):
  152. # b2_app预置username="disabled"(DISABLED)
  153. response = _verify(b2_client, "mock:8002:disabled:停用用户")
  154. assert response.status_code == 401
  155. assert response.get_json()["code"] == "USER_DISABLED"
  156. # ---------------------------------------------------------------- 设密与登录闭环
  157. def test_setup_password_completes_login_and_enables_normal_login(
  158. b2_client, portal_mock
  159. ):
  160. data = _verify(
  161. b2_client, "mock:9005:portal_setup:门户设密用户"
  162. ).get_json()["data"]
  163. response = b2_client.post(
  164. SETUP_PATH,
  165. json={
  166. "portalTicket": data["portalTicket"],
  167. "newPassword": "Portal#9005",
  168. "keepSignedIn": False,
  169. },
  170. )
  171. assert response.status_code == 200
  172. payload = response.get_json()["data"]
  173. assert payload["tokenType"] == "Bearer"
  174. assert payload["expiresIn"] == 7200
  175. # 契约1.29:设密完成响应同登录下发空闲自动登出分钟数
  176. assert payload["idleLogoutMinutes"] == 5
  177. # 契约1.38:一次一码轮换回显(Python 版不实现轮换,恒 False)
  178. assert payload["tokenRotation"] is False
  179. assert payload["user"]["username"] == "portal_setup"
  180. # 独立密码设好后可走普通用户名密码登录
  181. login = b2_client.post(
  182. "/api/v1/auth/login",
  183. json={
  184. "username": "portal_setup",
  185. "password": "Portal#9005",
  186. "keepSignedIn": False,
  187. },
  188. )
  189. assert login.status_code == 200
  190. # 票据一次性:设密后auth_version已变更,复用被拒
  191. replay = b2_client.post(
  192. SETUP_PATH,
  193. json={
  194. "portalTicket": data["portalTicket"],
  195. "newPassword": "Portal#9095",
  196. "keepSignedIn": False,
  197. },
  198. )
  199. assert replay.status_code == 401
  200. assert replay.get_json()["code"] == "PORTAL_TICKET_INVALID"
  201. def test_setup_password_keep_signed_in_extends_expiry(b2_client, portal_mock):
  202. data = _verify(
  203. b2_client, "mock:9006:portal_keep:保持登录用户"
  204. ).get_json()["data"]
  205. response = b2_client.post(
  206. SETUP_PATH,
  207. json={
  208. "portalTicket": data["portalTicket"],
  209. "newPassword": "Portal#9006",
  210. "keepSignedIn": True,
  211. },
  212. )
  213. assert response.status_code == 200
  214. assert response.get_json()["data"]["expiresIn"] == 604800
  215. def test_setup_password_rejects_weak_password_without_burning_ticket(
  216. b2_client, portal_mock
  217. ):
  218. data = _verify(
  219. b2_client, "mock:9007:portal_weak:弱密码用户"
  220. ).get_json()["data"]
  221. weak = b2_client.post(
  222. SETUP_PATH,
  223. json={
  224. "portalTicket": data["portalTicket"],
  225. "newPassword": "short",
  226. "keepSignedIn": False,
  227. },
  228. )
  229. assert weak.status_code == 400
  230. assert weak.get_json()["code"] == "INVALID_ARGUMENT"
  231. # 弱密码校验先于票据消费,票据未烧毁仍可正常设密
  232. retry = b2_client.post(
  233. SETUP_PATH,
  234. json={
  235. "portalTicket": data["portalTicket"],
  236. "newPassword": "Portal#9007",
  237. "keepSignedIn": False,
  238. },
  239. )
  240. assert retry.status_code == 200
  241. def test_setup_password_rejects_garbage_ticket(b2_client, portal_mock):
  242. response = b2_client.post(
  243. SETUP_PATH,
  244. json={
  245. "portalTicket": "not-a-jwt",
  246. "newPassword": "Portal#9000",
  247. "keepSignedIn": False,
  248. },
  249. )
  250. assert response.status_code == 401
  251. assert response.get_json()["code"] == "PORTAL_TICKET_INVALID"
  252. def test_setup_password_updates_organization_when_provided(
  253. b2_app, b2_client, portal_mock
  254. ):
  255. # 用户推断挂作战部,设密时自选通信部 → 机构改为通信部并记审计
  256. data = _verify(
  257. b2_client, "mock:9012:portal_pickorg:自选机构用户:作战部"
  258. ).get_json()["data"]
  259. comms = next(
  260. o for o in data["organizations"] if o["orgName"] == "通信部"
  261. )
  262. response = b2_client.post(
  263. SETUP_PATH,
  264. json={
  265. "portalTicket": data["portalTicket"],
  266. "newPassword": "Portal#9012",
  267. "keepSignedIn": False,
  268. "organizationId": comms["id"],
  269. },
  270. )
  271. assert response.status_code == 200
  272. assert response.get_json()["data"]["user"]["organizationName"] == "通信部"
  273. user = _find_user(b2_app, "portal_pickorg")
  274. assert user.organization_id == int(comms["id"])
  275. assert user.organization_name == "通信部"
  276. with b2_app.app_context():
  277. audit = db.session.scalar(
  278. db.select(AuditLog).where(
  279. AuditLog.username == "portal_pickorg",
  280. AuditLog.action_type == "LOGIN",
  281. )
  282. )
  283. assert audit is not None
  284. assert audit.detail["organizationId"] == comms["id"]
  285. def test_setup_password_keeps_inferred_org_when_omitted(
  286. b2_app, b2_client, portal_mock
  287. ):
  288. # 缺省(旧客户端)不改变用户机构
  289. _verify(b2_client, "mock:9013:portal_keeporg:缺省机构用户:作战部")
  290. data = _verify(
  291. b2_client, "mock:9013:portal_keeporg:缺省机构用户:作战部"
  292. ).get_json()["data"]
  293. response = b2_client.post(
  294. SETUP_PATH,
  295. json={
  296. "portalTicket": data["portalTicket"],
  297. "newPassword": "Portal#9013",
  298. "keepSignedIn": False,
  299. },
  300. )
  301. assert response.status_code == 200
  302. user = _find_user(b2_app, "portal_keeporg")
  303. assert user.organization_name == "作战部"
  304. def test_setup_password_rejects_invalid_organization(
  305. b2_app, b2_client, portal_mock
  306. ):
  307. data = _verify(
  308. b2_client, "mock:9014:portal_badorg:非法机构用户:作战部"
  309. ).get_json()["data"]
  310. def _setup(org_id) -> object:
  311. return b2_client.post(
  312. SETUP_PATH,
  313. json={
  314. "portalTicket": data["portalTicket"],
  315. "newPassword": "Portal#9014",
  316. "keepSignedIn": False,
  317. "organizationId": org_id,
  318. },
  319. )
  320. with b2_app.app_context():
  321. disabled = db.session.scalar(
  322. db.select(Organization).where(
  323. Organization.org_code == "ORG_DISABLED"
  324. )
  325. )
  326. assert disabled is not None
  327. disabled_id = str(disabled.id)
  328. # 停用机构拒绝
  329. disabled_response = _setup(disabled_id)
  330. assert disabled_response.status_code == 400
  331. assert (
  332. disabled_response.get_json()["code"] == "INVALID_ARGUMENT"
  333. )
  334. # 不存在的机构拒绝
  335. missing = _setup("999999")
  336. assert missing.status_code == 400
  337. assert missing.get_json()["code"] == "INVALID_ARGUMENT"
  338. # 非字符串拒绝
  339. non_string = _setup(123)
  340. assert non_string.status_code == 400
  341. # 上述拒绝均不烧毁票据,改用合法机构仍可设密
  342. comms = next(
  343. o for o in data["organizations"] if o["orgName"] == "通信部"
  344. )
  345. ok = _setup(comms["id"])
  346. assert ok.status_code == 200
  347. def test_second_launch_returns_login_phase(b2_client, portal_mock):
  348. token = "mock:9008:portal_again:二次跳转用户"
  349. first = _verify(b2_client, token).get_json()["data"]
  350. assert first["phase"] == "SET_PASSWORD"
  351. setup = b2_client.post(
  352. SETUP_PATH,
  353. json={
  354. "portalTicket": first["portalTicket"],
  355. "newPassword": "Portal#9008",
  356. "keepSignedIn": False,
  357. },
  358. )
  359. assert setup.status_code == 200
  360. # 设密登录后(last_login_at非空),再次跳转直接进入独立密码登录态
  361. second = _verify(b2_client, token).get_json()["data"]
  362. assert second["phase"] == "LOGIN"
  363. assert second["username"] == "portal_again"
  364. assert "portalTicket" not in second