test_ip_entries.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484
  1. """1.10 部门IP登录限制测试(1.28 扩展起止IP范围形式)。
  2. 覆盖:
  3. - normalize_ip_value 规范化(精确IP/CIDR/IPv6压缩/起止范围)与非法输入
  4. - IP条目 CRUD、重复409、host bits 400、多余字段400
  5. - 部门分配整存(乱序输入升序返回、无效条目400、空数组清空、rowVersion递增)
  6. - 删除守卫(被分配引用409,取消分配后可删)
  7. - 登录IP校验(网段命中/精确命中/范围闭区间命中/未命中拒绝/内置admin用户名豁免/管理员类别不再豁免/清空后放行/失败审计)
  8. - 非 ADMIN 访问 403
  9. - 审计记录(CREATE/EDIT/DELETE_IP_ENTRY、ASSIGN_ORG_IP)
  10. """
  11. from __future__ import annotations
  12. import pytest
  13. from dms.common.errors import InvalidArgumentError
  14. from dms.services.ip_entry_service import normalize_ip_value
  15. def _auth(token: str) -> dict[str, str]:
  16. return {"Authorization": f"Bearer {token}"}
  17. # ─── 单元:规范化 ───────────────────────────────────────────────────────────
  18. def test_normalize_accepts_exact_ips():
  19. assert normalize_ip_value(" 192.168.1.5 ") == "192.168.1.5"
  20. assert normalize_ip_value("2001:0DB8::0001") == "2001:db8::1"
  21. def test_normalize_accepts_strict_cidr():
  22. assert normalize_ip_value("192.168.1.0/24") == "192.168.1.0/24"
  23. assert normalize_ip_value("10.0.0.0/8") == "10.0.0.0/8"
  24. def test_normalize_rejects_host_bits_with_hint():
  25. with pytest.raises(InvalidArgumentError) as exc_info:
  26. normalize_ip_value("192.168.1.5/24")
  27. assert "主机位" in str(exc_info.value)
  28. @pytest.mark.parametrize(
  29. "bad_value",
  30. ["", " ", "abc", "999.1.1.1", "192.168.1.0/33", 123, None],
  31. )
  32. def test_normalize_rejects_invalid_input(bad_value):
  33. with pytest.raises(InvalidArgumentError):
  34. normalize_ip_value(bad_value)
  35. def test_normalize_accepts_ip_ranges():
  36. assert (
  37. normalize_ip_value(" 192.168.1.1-192.168.1.254 ")
  38. == "192.168.1.1-192.168.1.254"
  39. )
  40. assert normalize_ip_value("10.0.0.5-10.0.0.5") == "10.0.0.5-10.0.0.5"
  41. assert (
  42. normalize_ip_value("2001:DB8::1-2001:db8::10")
  43. == "2001:db8::1-2001:db8::10"
  44. )
  45. def test_normalize_rejects_reversed_range_with_hint():
  46. with pytest.raises(InvalidArgumentError) as exc_info:
  47. normalize_ip_value("192.168.1.254-192.168.1.1")
  48. assert "起始不能大于结束" in str(exc_info.value)
  49. def test_normalize_rejects_mixed_family_range():
  50. with pytest.raises(InvalidArgumentError) as exc_info:
  51. normalize_ip_value("::1-192.168.1.1")
  52. assert "同为IPv4或IPv6" in str(exc_info.value)
  53. @pytest.mark.parametrize(
  54. "bad_value",
  55. [
  56. "1.1.1.1-",
  57. "-1.1.1.1",
  58. "1.1.1.1-2.2.2.2-3.3.3.3",
  59. "1.1.1.1-999.2.2.2",
  60. "abc-1.1.1.1",
  61. "1.1.1.1-abc",
  62. ],
  63. )
  64. def test_normalize_rejects_invalid_ranges(bad_value):
  65. with pytest.raises(InvalidArgumentError) as exc_info:
  66. normalize_ip_value(bad_value)
  67. assert "格式非法" in str(exc_info.value)
  68. # ─── HTTP 集成 ──────────────────────────────────────────────────────────────
  69. @pytest.fixture()
  70. def ip_cleanup(b2_app):
  71. """前后置物理清空IP两表:前置隔离测试数据,后置避免分配表组织FK
  72. RESTRICT 阻止 conftest 对 sys_organization 的物理清理。"""
  73. from sqlalchemy import delete
  74. from dms.extensions import db
  75. from dms.models import IpEntry, OrgIpAssignment
  76. def purge():
  77. with b2_app.app_context():
  78. db.session.execute(delete(OrgIpAssignment))
  79. db.session.execute(delete(IpEntry))
  80. db.session.commit()
  81. purge()
  82. yield
  83. purge()
  84. def _create_entry(client, token, ip_value: str, remark=None):
  85. response = client.post(
  86. "/api/v1/ip-entries",
  87. json={"ipValue": ip_value, "remark": remark},
  88. headers=_auth(token),
  89. )
  90. assert response.status_code == 201, response.get_json()
  91. return response.get_json()["data"]
  92. def _org_id(app, code: str) -> int:
  93. from dms.extensions import db
  94. from dms.models import Organization
  95. with app.app_context():
  96. org = db.session.scalar(
  97. db.select(Organization).where(Organization.org_code == code)
  98. )
  99. assert org is not None, f"organization {code} not found"
  100. return org.id
  101. def _save_assignments(client, token, org_id: int, entry_ids: list[str], row_version: int):
  102. return client.put(
  103. f"/api/v1/organizations/{org_id}/ip-assignments",
  104. json={"ipEntryIds": entry_ids, "rowVersion": row_version},
  105. headers=_auth(token),
  106. )
  107. @pytest.mark.integration
  108. def test_ip_entry_crud_roundtrip(b2_client, login_token, ip_cleanup):
  109. created = b2_client.post(
  110. "/api/v1/ip-entries",
  111. json={"ipValue": " 192.168.1.5 ", "remark": " 网关 "},
  112. headers=_auth(login_token),
  113. )
  114. assert created.status_code == 201, created.get_json()
  115. entry = created.get_json()["data"]
  116. assert entry["ipValue"] == "192.168.1.5"
  117. assert entry["remark"] == "网关"
  118. assert entry["rowVersion"] == 0
  119. listed = b2_client.get(
  120. "/api/v1/ip-entries?keyword=网关", headers=_auth(login_token)
  121. ).get_json()["data"]
  122. assert listed["total"] == 1
  123. assert [item["id"] for item in listed["items"]] == [entry["id"]]
  124. updated = b2_client.put(
  125. f"/api/v1/ip-entries/{entry['id']}",
  126. json={"ipValue": "10.10.10.10", "remark": None, "rowVersion": 0},
  127. headers=_auth(login_token),
  128. )
  129. assert updated.status_code == 200, updated.get_json()
  130. assert updated.get_json()["data"]["ipValue"] == "10.10.10.10"
  131. assert updated.get_json()["data"]["remark"] == ""
  132. assert updated.get_json()["data"]["rowVersion"] == 1
  133. deleted = b2_client.delete(
  134. f"/api/v1/ip-entries/{entry['id']}?rowVersion=1",
  135. headers=_auth(login_token),
  136. )
  137. assert deleted.status_code == 200
  138. assert deleted.get_json()["data"]["deleted"] is True
  139. remaining = b2_client.get(
  140. "/api/v1/ip-entries", headers=_auth(login_token)
  141. ).get_json()["data"]
  142. assert remaining["total"] == 0
  143. @pytest.mark.integration
  144. def test_create_duplicate_ip_conflicts(b2_client, login_token, ip_cleanup):
  145. first = _create_entry(b2_client, login_token, "10.0.0.5")
  146. second = b2_client.post(
  147. "/api/v1/ip-entries",
  148. json={"ipValue": "10.0.0.5", "remark": None},
  149. headers=_auth(login_token),
  150. )
  151. assert second.status_code == 409
  152. assert second.get_json()["code"] == "DATA_VERSION_CONFLICT"
  153. assert first["ipValue"] == "10.0.0.5"
  154. @pytest.mark.integration
  155. def test_create_rejects_host_bits_and_extra_fields(
  156. b2_client, login_token, ip_cleanup
  157. ):
  158. host_bits = b2_client.post(
  159. "/api/v1/ip-entries",
  160. json={"ipValue": "192.168.1.5/24", "remark": None},
  161. headers=_auth(login_token),
  162. )
  163. assert host_bits.status_code == 400
  164. assert host_bits.get_json()["code"] == "INVALID_ARGUMENT"
  165. extra = b2_client.post(
  166. "/api/v1/ip-entries",
  167. json={"ipValue": "1.2.3.4", "remark": None, "surplus": True},
  168. headers=_auth(login_token),
  169. )
  170. assert extra.status_code == 400
  171. assert extra.get_json()["code"] == "INVALID_ARGUMENT"
  172. @pytest.mark.integration
  173. def test_org_assignments_full_replace(b2_app, b2_client, login_token, ip_cleanup):
  174. ops_id = _org_id(b2_app, "ORG_OPS")
  175. entry_one = _create_entry(b2_client, login_token, "192.168.10.0/24")
  176. entry_two = _create_entry(b2_client, login_token, "10.10.10.10")
  177. saved = _save_assignments(
  178. b2_client,
  179. login_token,
  180. ops_id,
  181. [entry_two["id"], entry_one["id"]],
  182. 0,
  183. )
  184. assert saved.status_code == 200, saved.get_json()
  185. assert saved.get_json()["data"]["ipEntryIds"] == [
  186. entry_one["id"],
  187. entry_two["id"],
  188. ]
  189. assert saved.get_json()["data"]["rowVersion"] == 1
  190. listed = b2_client.get(
  191. f"/api/v1/organizations/{ops_id}/ip-assignments",
  192. headers=_auth(login_token),
  193. )
  194. assert listed.status_code == 200
  195. assert listed.get_json()["data"]["ipEntryIds"] == [
  196. entry_one["id"],
  197. entry_two["id"],
  198. ]
  199. invalid = _save_assignments(
  200. b2_client, login_token, ops_id, ["99999"], 1
  201. )
  202. assert invalid.status_code == 400
  203. assert invalid.get_json()["code"] == "INVALID_ARGUMENT"
  204. cleared = _save_assignments(b2_client, login_token, ops_id, [], 1)
  205. assert cleared.status_code == 200, cleared.get_json()
  206. assert cleared.get_json()["data"]["ipEntryIds"] == []
  207. assert cleared.get_json()["data"]["rowVersion"] == 2
  208. @pytest.mark.integration
  209. def test_delete_blocked_while_assigned(
  210. b2_app, b2_client, login_token, ip_cleanup
  211. ):
  212. ops_id = _org_id(b2_app, "ORG_OPS")
  213. entry = _create_entry(b2_client, login_token, "172.16.0.1")
  214. assert _save_assignments(
  215. b2_client, login_token, ops_id, [entry["id"]], 0
  216. ).status_code == 200
  217. blocked = b2_client.delete(
  218. f"/api/v1/ip-entries/{entry['id']}?rowVersion=0",
  219. headers=_auth(login_token),
  220. )
  221. assert blocked.status_code == 409
  222. assert blocked.get_json()["code"] == "DATA_VERSION_CONFLICT"
  223. assert (
  224. _save_assignments(b2_client, login_token, ops_id, [], 1).status_code
  225. == 200
  226. )
  227. deleted = b2_client.delete(
  228. f"/api/v1/ip-entries/{entry['id']}?rowVersion=0",
  229. headers=_auth(login_token),
  230. )
  231. assert deleted.status_code == 200
  232. @pytest.mark.integration
  233. def test_login_ip_restriction_enforced(
  234. ip_cleanup, b2_app, b2_client, login_token, b2_password
  235. ):
  236. ops_id = _org_id(b2_app, "ORG_OPS")
  237. subnet = _create_entry(b2_client, login_token, "192.168.10.0/24")
  238. exact = _create_entry(b2_client, login_token, "10.10.10.10")
  239. assert (
  240. _save_assignments(
  241. b2_client,
  242. login_token,
  243. ops_id,
  244. [subnet["id"], exact["id"]],
  245. 0,
  246. ).status_code
  247. == 200
  248. )
  249. def login(username: str, client_ip: str):
  250. return b2_client.post(
  251. "/api/v1/auth/login",
  252. json={
  253. "username": username,
  254. "password": b2_password,
  255. "keepSignedIn": False,
  256. },
  257. headers={"X-Forwarded-For": client_ip},
  258. )
  259. hit_subnet = login("user", "192.168.10.55")
  260. assert hit_subnet.status_code == 200
  261. hit_exact = login("user", "10.10.10.10")
  262. assert hit_exact.status_code == 200
  263. miss = login("user", "172.16.0.1")
  264. assert miss.status_code == 403
  265. assert miss.get_json()["code"] == "LOGIN_IP_FORBIDDEN"
  266. admin_exempt = login("admin", "172.16.0.1")
  267. assert admin_exempt.status_code == 200
  268. # 管理员类别不再整体豁免:挂在本部门(已分配IP段)的机构管理员,
  269. # 来源IP未命中同样拒绝(仅内置 admin 用户名豁免)
  270. with b2_app.app_context():
  271. from dms.extensions import db
  272. from dms.models import Organization, User
  273. from dms.security.passwords import hash_password
  274. org = db.session.scalar(
  275. db.select(Organization).where(Organization.org_code == "ORG_OPS")
  276. )
  277. db.session.add(
  278. User(
  279. username="org_admin",
  280. password_hash=hash_password(b2_password),
  281. real_name="机构管理员",
  282. organization_id=org.id,
  283. organization_name=org.org_name,
  284. role_code="ADMIN",
  285. security_level="SECRET",
  286. status="ENABLED",
  287. )
  288. )
  289. db.session.commit()
  290. admin_category_miss = login("org_admin", "172.16.0.1")
  291. assert admin_category_miss.status_code == 403
  292. assert admin_category_miss.get_json()["code"] == "LOGIN_IP_FORBIDDEN"
  293. admin_category_hit = login("org_admin", "192.168.10.55")
  294. assert admin_category_hit.status_code == 200
  295. with b2_app.app_context():
  296. from dms.extensions import db
  297. from dms.models import AuditLog
  298. reasons = set(
  299. db.session.scalars(
  300. db.select(AuditLog.failure_reason).where(
  301. AuditLog.action_type == "LOGIN",
  302. AuditLog.operation_result == "FAILURE",
  303. AuditLog.username == "user",
  304. )
  305. ).all()
  306. )
  307. assert "IP_NOT_ALLOWED" in reasons
  308. assert (
  309. _save_assignments(b2_client, login_token, ops_id, [], 1).status_code
  310. == 200
  311. )
  312. unrestricted = login("user", "172.16.0.1")
  313. assert unrestricted.status_code == 200
  314. @pytest.mark.integration
  315. def test_login_ip_range_restriction_enforced(
  316. ip_cleanup, b2_app, b2_client, login_token, b2_password
  317. ):
  318. ops_id = _org_id(b2_app, "ORG_OPS")
  319. ip_range = _create_entry(
  320. b2_client, login_token, " 172.16.20.10-172.16.20.99 "
  321. )
  322. assert ip_range["ipValue"] == "172.16.20.10-172.16.20.99"
  323. assert (
  324. _save_assignments(
  325. b2_client, login_token, ops_id, [ip_range["id"]], 0
  326. ).status_code
  327. == 200
  328. )
  329. def login(username: str, client_ip: str):
  330. return b2_client.post(
  331. "/api/v1/auth/login",
  332. json={
  333. "username": username,
  334. "password": b2_password,
  335. "keepSignedIn": False,
  336. },
  337. headers={"X-Forwarded-For": client_ip},
  338. )
  339. # 闭区间语义:起止边界与范围内均放行
  340. assert login("user", "172.16.20.10").status_code == 200
  341. assert login("user", "172.16.20.55").status_code == 200
  342. assert login("user", "172.16.20.99").status_code == 200
  343. miss = login("user", "172.16.20.100")
  344. assert miss.status_code == 403
  345. assert miss.get_json()["code"] == "LOGIN_IP_FORBIDDEN"
  346. @pytest.mark.integration
  347. def test_ip_routes_require_admin(ip_cleanup, b2_client, token_for):
  348. user_token = token_for("user")
  349. response = b2_client.get("/api/v1/ip-entries", headers=_auth(user_token))
  350. assert response.status_code == 403
  351. @pytest.mark.integration
  352. def test_ip_audit_trail_written(b2_app, b2_client, login_token, ip_cleanup):
  353. from dms.extensions import db
  354. from dms.models import AuditLog
  355. ops_id = _org_id(b2_app, "ORG_OPS")
  356. entry = _create_entry(b2_client, login_token, "10.20.30.40", "审计条目")
  357. b2_client.put(
  358. f"/api/v1/ip-entries/{entry['id']}",
  359. json={
  360. "ipValue": "10.20.30.41",
  361. "remark": "审计条目",
  362. "rowVersion": 0,
  363. },
  364. headers=_auth(login_token),
  365. )
  366. assert (
  367. _save_assignments(
  368. b2_client, login_token, ops_id, [entry["id"]], 0
  369. ).status_code
  370. == 200
  371. )
  372. assert (
  373. _save_assignments(b2_client, login_token, ops_id, [], 1).status_code
  374. == 200
  375. )
  376. b2_client.delete(
  377. f"/api/v1/ip-entries/{entry['id']}?rowVersion=1",
  378. headers=_auth(login_token),
  379. )
  380. with b2_app.app_context():
  381. actions = set(
  382. db.session.scalars(
  383. db.select(AuditLog.action_type).where(
  384. AuditLog.action_type.in_(
  385. [
  386. "CREATE_IP_ENTRY",
  387. "EDIT_IP_ENTRY",
  388. "DELETE_IP_ENTRY",
  389. "ASSIGN_ORG_IP",
  390. ]
  391. )
  392. )
  393. ).all()
  394. )
  395. assert actions == {
  396. "CREATE_IP_ENTRY",
  397. "EDIT_IP_ENTRY",
  398. "DELETE_IP_ENTRY",
  399. "ASSIGN_ORG_IP",
  400. }