test_portal_auth.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
  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. assert payload["user"]["username"] == "portal_setup"
  178. # 独立密码设好后可走普通用户名密码登录
  179. login = b2_client.post(
  180. "/api/v1/auth/login",
  181. json={
  182. "username": "portal_setup",
  183. "password": "Portal#9005",
  184. "keepSignedIn": False,
  185. },
  186. )
  187. assert login.status_code == 200
  188. # 票据一次性:设密后auth_version已变更,复用被拒
  189. replay = b2_client.post(
  190. SETUP_PATH,
  191. json={
  192. "portalTicket": data["portalTicket"],
  193. "newPassword": "Portal#9095",
  194. "keepSignedIn": False,
  195. },
  196. )
  197. assert replay.status_code == 401
  198. assert replay.get_json()["code"] == "PORTAL_TICKET_INVALID"
  199. def test_setup_password_keep_signed_in_extends_expiry(b2_client, portal_mock):
  200. data = _verify(
  201. b2_client, "mock:9006:portal_keep:保持登录用户"
  202. ).get_json()["data"]
  203. response = b2_client.post(
  204. SETUP_PATH,
  205. json={
  206. "portalTicket": data["portalTicket"],
  207. "newPassword": "Portal#9006",
  208. "keepSignedIn": True,
  209. },
  210. )
  211. assert response.status_code == 200
  212. assert response.get_json()["data"]["expiresIn"] == 604800
  213. def test_setup_password_rejects_weak_password_without_burning_ticket(
  214. b2_client, portal_mock
  215. ):
  216. data = _verify(
  217. b2_client, "mock:9007:portal_weak:弱密码用户"
  218. ).get_json()["data"]
  219. weak = b2_client.post(
  220. SETUP_PATH,
  221. json={
  222. "portalTicket": data["portalTicket"],
  223. "newPassword": "short",
  224. "keepSignedIn": False,
  225. },
  226. )
  227. assert weak.status_code == 400
  228. assert weak.get_json()["code"] == "INVALID_ARGUMENT"
  229. # 弱密码校验先于票据消费,票据未烧毁仍可正常设密
  230. retry = b2_client.post(
  231. SETUP_PATH,
  232. json={
  233. "portalTicket": data["portalTicket"],
  234. "newPassword": "Portal#9007",
  235. "keepSignedIn": False,
  236. },
  237. )
  238. assert retry.status_code == 200
  239. def test_setup_password_rejects_garbage_ticket(b2_client, portal_mock):
  240. response = b2_client.post(
  241. SETUP_PATH,
  242. json={
  243. "portalTicket": "not-a-jwt",
  244. "newPassword": "Portal#9000",
  245. "keepSignedIn": False,
  246. },
  247. )
  248. assert response.status_code == 401
  249. assert response.get_json()["code"] == "PORTAL_TICKET_INVALID"
  250. def test_setup_password_updates_organization_when_provided(
  251. b2_app, b2_client, portal_mock
  252. ):
  253. # 用户推断挂作战部,设密时自选通信部 → 机构改为通信部并记审计
  254. data = _verify(
  255. b2_client, "mock:9012:portal_pickorg:自选机构用户:作战部"
  256. ).get_json()["data"]
  257. comms = next(
  258. o for o in data["organizations"] if o["orgName"] == "通信部"
  259. )
  260. response = b2_client.post(
  261. SETUP_PATH,
  262. json={
  263. "portalTicket": data["portalTicket"],
  264. "newPassword": "Portal#9012",
  265. "keepSignedIn": False,
  266. "organizationId": comms["id"],
  267. },
  268. )
  269. assert response.status_code == 200
  270. assert response.get_json()["data"]["user"]["organizationName"] == "通信部"
  271. user = _find_user(b2_app, "portal_pickorg")
  272. assert user.organization_id == int(comms["id"])
  273. assert user.organization_name == "通信部"
  274. with b2_app.app_context():
  275. audit = db.session.scalar(
  276. db.select(AuditLog).where(
  277. AuditLog.username == "portal_pickorg",
  278. AuditLog.action_type == "LOGIN",
  279. )
  280. )
  281. assert audit is not None
  282. assert audit.detail["organizationId"] == comms["id"]
  283. def test_setup_password_keeps_inferred_org_when_omitted(
  284. b2_app, b2_client, portal_mock
  285. ):
  286. # 缺省(旧客户端)不改变用户机构
  287. _verify(b2_client, "mock:9013:portal_keeporg:缺省机构用户:作战部")
  288. data = _verify(
  289. b2_client, "mock:9013:portal_keeporg:缺省机构用户:作战部"
  290. ).get_json()["data"]
  291. response = b2_client.post(
  292. SETUP_PATH,
  293. json={
  294. "portalTicket": data["portalTicket"],
  295. "newPassword": "Portal#9013",
  296. "keepSignedIn": False,
  297. },
  298. )
  299. assert response.status_code == 200
  300. user = _find_user(b2_app, "portal_keeporg")
  301. assert user.organization_name == "作战部"
  302. def test_setup_password_rejects_invalid_organization(
  303. b2_app, b2_client, portal_mock
  304. ):
  305. data = _verify(
  306. b2_client, "mock:9014:portal_badorg:非法机构用户:作战部"
  307. ).get_json()["data"]
  308. def _setup(org_id) -> object:
  309. return b2_client.post(
  310. SETUP_PATH,
  311. json={
  312. "portalTicket": data["portalTicket"],
  313. "newPassword": "Portal#9014",
  314. "keepSignedIn": False,
  315. "organizationId": org_id,
  316. },
  317. )
  318. with b2_app.app_context():
  319. disabled = db.session.scalar(
  320. db.select(Organization).where(
  321. Organization.org_code == "ORG_DISABLED"
  322. )
  323. )
  324. assert disabled is not None
  325. disabled_id = str(disabled.id)
  326. # 停用机构拒绝
  327. disabled_response = _setup(disabled_id)
  328. assert disabled_response.status_code == 400
  329. assert (
  330. disabled_response.get_json()["code"] == "INVALID_ARGUMENT"
  331. )
  332. # 不存在的机构拒绝
  333. missing = _setup("999999")
  334. assert missing.status_code == 400
  335. assert missing.get_json()["code"] == "INVALID_ARGUMENT"
  336. # 非字符串拒绝
  337. non_string = _setup(123)
  338. assert non_string.status_code == 400
  339. # 上述拒绝均不烧毁票据,改用合法机构仍可设密
  340. comms = next(
  341. o for o in data["organizations"] if o["orgName"] == "通信部"
  342. )
  343. ok = _setup(comms["id"])
  344. assert ok.status_code == 200
  345. def test_second_launch_returns_login_phase(b2_client, portal_mock):
  346. token = "mock:9008:portal_again:二次跳转用户"
  347. first = _verify(b2_client, token).get_json()["data"]
  348. assert first["phase"] == "SET_PASSWORD"
  349. setup = b2_client.post(
  350. SETUP_PATH,
  351. json={
  352. "portalTicket": first["portalTicket"],
  353. "newPassword": "Portal#9008",
  354. "keepSignedIn": False,
  355. },
  356. )
  357. assert setup.status_code == 200
  358. # 设密登录后(last_login_at非空),再次跳转直接进入独立密码登录态
  359. second = _verify(b2_client, token).get_json()["data"]
  360. assert second["phase"] == "LOGIN"
  361. assert second["username"] == "portal_again"
  362. assert "portalTicket" not in second