test_ip_entries.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. """1.10 部门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. # ─── HTTP 集成 ──────────────────────────────────────────────────────────────
  36. @pytest.fixture()
  37. def ip_cleanup(b2_app):
  38. """前后置物理清空IP两表:前置隔离测试数据,后置避免分配表组织FK
  39. RESTRICT 阻止 conftest 对 sys_organization 的物理清理。"""
  40. from sqlalchemy import delete
  41. from dms.extensions import db
  42. from dms.models import IpEntry, OrgIpAssignment
  43. def purge():
  44. with b2_app.app_context():
  45. db.session.execute(delete(OrgIpAssignment))
  46. db.session.execute(delete(IpEntry))
  47. db.session.commit()
  48. purge()
  49. yield
  50. purge()
  51. def _create_entry(client, token, ip_value: str, remark=None):
  52. response = client.post(
  53. "/api/v1/ip-entries",
  54. json={"ipValue": ip_value, "remark": remark},
  55. headers=_auth(token),
  56. )
  57. assert response.status_code == 201, response.get_json()
  58. return response.get_json()["data"]
  59. def _org_id(app, code: str) -> int:
  60. from dms.extensions import db
  61. from dms.models import Organization
  62. with app.app_context():
  63. org = db.session.scalar(
  64. db.select(Organization).where(Organization.org_code == code)
  65. )
  66. assert org is not None, f"organization {code} not found"
  67. return org.id
  68. def _save_assignments(client, token, org_id: int, entry_ids: list[str], row_version: int):
  69. return client.put(
  70. f"/api/v1/organizations/{org_id}/ip-assignments",
  71. json={"ipEntryIds": entry_ids, "rowVersion": row_version},
  72. headers=_auth(token),
  73. )
  74. @pytest.mark.integration
  75. def test_ip_entry_crud_roundtrip(b2_client, login_token, ip_cleanup):
  76. created = b2_client.post(
  77. "/api/v1/ip-entries",
  78. json={"ipValue": " 192.168.1.5 ", "remark": " 网关 "},
  79. headers=_auth(login_token),
  80. )
  81. assert created.status_code == 201, created.get_json()
  82. entry = created.get_json()["data"]
  83. assert entry["ipValue"] == "192.168.1.5"
  84. assert entry["remark"] == "网关"
  85. assert entry["rowVersion"] == 0
  86. listed = b2_client.get(
  87. "/api/v1/ip-entries?keyword=网关", headers=_auth(login_token)
  88. ).get_json()["data"]
  89. assert listed["total"] == 1
  90. assert [item["id"] for item in listed["items"]] == [entry["id"]]
  91. updated = b2_client.put(
  92. f"/api/v1/ip-entries/{entry['id']}",
  93. json={"ipValue": "10.10.10.10", "remark": None, "rowVersion": 0},
  94. headers=_auth(login_token),
  95. )
  96. assert updated.status_code == 200, updated.get_json()
  97. assert updated.get_json()["data"]["ipValue"] == "10.10.10.10"
  98. assert updated.get_json()["data"]["remark"] == ""
  99. assert updated.get_json()["data"]["rowVersion"] == 1
  100. deleted = b2_client.delete(
  101. f"/api/v1/ip-entries/{entry['id']}?rowVersion=1",
  102. headers=_auth(login_token),
  103. )
  104. assert deleted.status_code == 200
  105. assert deleted.get_json()["data"]["deleted"] is True
  106. remaining = b2_client.get(
  107. "/api/v1/ip-entries", headers=_auth(login_token)
  108. ).get_json()["data"]
  109. assert remaining["total"] == 0
  110. @pytest.mark.integration
  111. def test_create_duplicate_ip_conflicts(b2_client, login_token, ip_cleanup):
  112. first = _create_entry(b2_client, login_token, "10.0.0.5")
  113. second = b2_client.post(
  114. "/api/v1/ip-entries",
  115. json={"ipValue": "10.0.0.5", "remark": None},
  116. headers=_auth(login_token),
  117. )
  118. assert second.status_code == 409
  119. assert second.get_json()["code"] == "DATA_VERSION_CONFLICT"
  120. assert first["ipValue"] == "10.0.0.5"
  121. @pytest.mark.integration
  122. def test_create_rejects_host_bits_and_extra_fields(
  123. b2_client, login_token, ip_cleanup
  124. ):
  125. host_bits = b2_client.post(
  126. "/api/v1/ip-entries",
  127. json={"ipValue": "192.168.1.5/24", "remark": None},
  128. headers=_auth(login_token),
  129. )
  130. assert host_bits.status_code == 400
  131. assert host_bits.get_json()["code"] == "INVALID_ARGUMENT"
  132. extra = b2_client.post(
  133. "/api/v1/ip-entries",
  134. json={"ipValue": "1.2.3.4", "remark": None, "surplus": True},
  135. headers=_auth(login_token),
  136. )
  137. assert extra.status_code == 400
  138. assert extra.get_json()["code"] == "INVALID_ARGUMENT"
  139. @pytest.mark.integration
  140. def test_org_assignments_full_replace(b2_app, b2_client, login_token, ip_cleanup):
  141. ops_id = _org_id(b2_app, "ORG_OPS")
  142. entry_one = _create_entry(b2_client, login_token, "192.168.10.0/24")
  143. entry_two = _create_entry(b2_client, login_token, "10.10.10.10")
  144. saved = _save_assignments(
  145. b2_client,
  146. login_token,
  147. ops_id,
  148. [entry_two["id"], entry_one["id"]],
  149. 0,
  150. )
  151. assert saved.status_code == 200, saved.get_json()
  152. assert saved.get_json()["data"]["ipEntryIds"] == [
  153. entry_one["id"],
  154. entry_two["id"],
  155. ]
  156. assert saved.get_json()["data"]["rowVersion"] == 1
  157. listed = b2_client.get(
  158. f"/api/v1/organizations/{ops_id}/ip-assignments",
  159. headers=_auth(login_token),
  160. )
  161. assert listed.status_code == 200
  162. assert listed.get_json()["data"]["ipEntryIds"] == [
  163. entry_one["id"],
  164. entry_two["id"],
  165. ]
  166. invalid = _save_assignments(
  167. b2_client, login_token, ops_id, ["99999"], 1
  168. )
  169. assert invalid.status_code == 400
  170. assert invalid.get_json()["code"] == "INVALID_ARGUMENT"
  171. cleared = _save_assignments(b2_client, login_token, ops_id, [], 1)
  172. assert cleared.status_code == 200, cleared.get_json()
  173. assert cleared.get_json()["data"]["ipEntryIds"] == []
  174. assert cleared.get_json()["data"]["rowVersion"] == 2
  175. @pytest.mark.integration
  176. def test_delete_blocked_while_assigned(
  177. b2_app, b2_client, login_token, ip_cleanup
  178. ):
  179. ops_id = _org_id(b2_app, "ORG_OPS")
  180. entry = _create_entry(b2_client, login_token, "172.16.0.1")
  181. assert _save_assignments(
  182. b2_client, login_token, ops_id, [entry["id"]], 0
  183. ).status_code == 200
  184. blocked = b2_client.delete(
  185. f"/api/v1/ip-entries/{entry['id']}?rowVersion=0",
  186. headers=_auth(login_token),
  187. )
  188. assert blocked.status_code == 409
  189. assert blocked.get_json()["code"] == "DATA_VERSION_CONFLICT"
  190. assert (
  191. _save_assignments(b2_client, login_token, ops_id, [], 1).status_code
  192. == 200
  193. )
  194. deleted = b2_client.delete(
  195. f"/api/v1/ip-entries/{entry['id']}?rowVersion=0",
  196. headers=_auth(login_token),
  197. )
  198. assert deleted.status_code == 200
  199. @pytest.mark.integration
  200. def test_login_ip_restriction_enforced(
  201. ip_cleanup, b2_app, b2_client, login_token, b2_password
  202. ):
  203. ops_id = _org_id(b2_app, "ORG_OPS")
  204. subnet = _create_entry(b2_client, login_token, "192.168.10.0/24")
  205. exact = _create_entry(b2_client, login_token, "10.10.10.10")
  206. assert (
  207. _save_assignments(
  208. b2_client,
  209. login_token,
  210. ops_id,
  211. [subnet["id"], exact["id"]],
  212. 0,
  213. ).status_code
  214. == 200
  215. )
  216. def login(username: str, client_ip: str):
  217. return b2_client.post(
  218. "/api/v1/auth/login",
  219. json={
  220. "username": username,
  221. "password": b2_password,
  222. "keepSignedIn": False,
  223. },
  224. headers={"X-Forwarded-For": client_ip},
  225. )
  226. hit_subnet = login("user", "192.168.10.55")
  227. assert hit_subnet.status_code == 200
  228. hit_exact = login("user", "10.10.10.10")
  229. assert hit_exact.status_code == 200
  230. miss = login("user", "172.16.0.1")
  231. assert miss.status_code == 403
  232. assert miss.get_json()["code"] == "LOGIN_IP_FORBIDDEN"
  233. admin_exempt = login("admin", "172.16.0.1")
  234. assert admin_exempt.status_code == 200
  235. with b2_app.app_context():
  236. from dms.extensions import db
  237. from dms.models import AuditLog
  238. reasons = set(
  239. db.session.scalars(
  240. db.select(AuditLog.failure_reason).where(
  241. AuditLog.action_type == "LOGIN",
  242. AuditLog.operation_result == "FAILURE",
  243. AuditLog.username == "user",
  244. )
  245. ).all()
  246. )
  247. assert "IP_NOT_ALLOWED" in reasons
  248. assert (
  249. _save_assignments(b2_client, login_token, ops_id, [], 1).status_code
  250. == 200
  251. )
  252. unrestricted = login("user", "172.16.0.1")
  253. assert unrestricted.status_code == 200
  254. @pytest.mark.integration
  255. def test_ip_routes_require_admin(ip_cleanup, b2_client, token_for):
  256. user_token = token_for("user")
  257. response = b2_client.get("/api/v1/ip-entries", headers=_auth(user_token))
  258. assert response.status_code == 403
  259. @pytest.mark.integration
  260. def test_ip_audit_trail_written(b2_app, b2_client, login_token, ip_cleanup):
  261. from dms.extensions import db
  262. from dms.models import AuditLog
  263. ops_id = _org_id(b2_app, "ORG_OPS")
  264. entry = _create_entry(b2_client, login_token, "10.20.30.40", "审计条目")
  265. b2_client.put(
  266. f"/api/v1/ip-entries/{entry['id']}",
  267. json={
  268. "ipValue": "10.20.30.41",
  269. "remark": "审计条目",
  270. "rowVersion": 0,
  271. },
  272. headers=_auth(login_token),
  273. )
  274. assert (
  275. _save_assignments(
  276. b2_client, login_token, ops_id, [entry["id"]], 0
  277. ).status_code
  278. == 200
  279. )
  280. assert (
  281. _save_assignments(b2_client, login_token, ops_id, [], 1).status_code
  282. == 200
  283. )
  284. b2_client.delete(
  285. f"/api/v1/ip-entries/{entry['id']}?rowVersion=1",
  286. headers=_auth(login_token),
  287. )
  288. with b2_app.app_context():
  289. actions = set(
  290. db.session.scalars(
  291. db.select(AuditLog.action_type).where(
  292. AuditLog.action_type.in_(
  293. [
  294. "CREATE_IP_ENTRY",
  295. "EDIT_IP_ENTRY",
  296. "DELETE_IP_ENTRY",
  297. "ASSIGN_ORG_IP",
  298. ]
  299. )
  300. )
  301. ).all()
  302. )
  303. assert actions == {
  304. "CREATE_IP_ENTRY",
  305. "EDIT_IP_ENTRY",
  306. "DELETE_IP_ENTRY",
  307. "ASSIGN_ORG_IP",
  308. }