netsleuth/tests/test_ip_geo_normalize.py

313 lines
11 KiB
Python

from __future__ import annotations
import httpx
import pytest
from netsleuth.config import Providers
from netsleuth.ip_geo import (
classify_ip_type,
gather_identity,
normalize_freeipapi,
normalize_ip_api,
normalize_ipinfo,
normalize_ipwhois,
normalize_ripestat_network_info,
parse_cf_trace,
provider_flags,
)
def test_ip_api_normalizes_to_the_common_shape(api_fixture):
geo = normalize_ip_api(api_fixture("ip_api.json"))
assert geo.ip == "203.0.113.44"
assert geo.ip_version == 4
assert geo.asn == "AS64500"
assert geo.as_name == "Example Telecom"
assert geo.org == "Example Telecom BV"
assert geo.country == "Netherlands"
assert geo.country_code == "NL"
assert geo.city == "Amsterdam"
assert geo.lat == 52.3759
assert geo.lon == 4.8975
assert geo.timezone == "Europe/Amsterdam"
assert geo.reverse_dns == "host-203-0-113-44.example.net"
assert geo.ip_type == "hosting"
def test_ip_api_failure_payload_yields_an_empty_geo_not_an_exception(api_fixture):
geo = normalize_ip_api(api_fixture("ip_api_fail.json"))
assert geo.asn is None
assert geo.country is None
assert geo.ip_type == "unknown"
def test_freeipapi_normalizes_and_prefixes_the_bare_asn(api_fixture):
geo = normalize_freeipapi(api_fixture("freeipapi.json"))
assert geo.ip == "203.0.113.44"
assert geo.ip_version == 4
assert geo.asn == "AS64500"
assert geo.org == "Example Telecom BV"
assert geo.country_code == "NL"
assert geo.city == "Amsterdam"
assert geo.ip_type == "residential"
def test_ipinfo_splits_the_org_field_into_asn_and_name(api_fixture):
geo = normalize_ipinfo(api_fixture("ipinfo.json"))
assert geo.asn == "AS64500"
assert geo.as_name == "Example Telecom BV"
assert geo.lat == 52.3759
assert geo.lon == 4.8975
assert geo.country_code == "NL"
assert geo.reverse_dns == "host-203-0-113-44.example.net"
def test_ipinfo_without_a_loc_field_does_not_crash():
geo = normalize_ipinfo({"ip": "203.0.113.44", "org": "AS64500 Example"})
assert geo.lat is None
assert geo.lon is None
def test_ipwhois_reads_the_nested_connection_and_timezone_objects(api_fixture):
geo = normalize_ipwhois(api_fixture("ipwhois.json"))
assert geo.asn == "AS64500"
assert geo.as_name == "Example Telecom"
assert geo.org == "Example Telecom BV"
assert geo.timezone == "Europe/Amsterdam"
assert geo.ip_version == 4
assert geo.country_code == "NL"
def test_ripestat_network_info_gives_authoritative_asn_only(api_fixture):
geo = normalize_ripestat_network_info(api_fixture("ripestat_network_info.json"))
assert geo.asn == "AS64500"
assert geo.city is None
assert geo.sources == {"asn": "ripestat"}
def test_cf_trace_parses_every_key_and_the_vpn_relevant_flags(fixtures_dir):
cf = parse_cf_trace((fixtures_dir / "api" / "cf_trace.txt").read_text(encoding="utf-8"))
assert cf.ip == "203.0.113.44"
assert cf.colo == "AMS"
assert cf.loc == "NL"
assert cf.warp == "off"
assert cf.gateway == "off"
assert cf.rbi == "off"
assert cf.raw["tls"] == "TLSv1.3"
def test_cf_trace_tolerates_blank_lines_and_missing_keys():
cf = parse_cf_trace("ip=1.2.3.4\n\nnot-a-pair\ncolo=AMS\n")
assert cf.ip == "1.2.3.4"
assert cf.colo == "AMS"
assert cf.warp is None
def test_provider_flags_reads_the_ip_api_booleans(api_fixture):
assert provider_flags(api_fixture("ip_api.json")) == {
"mobile": False,
"proxy": False,
"hosting": True,
}
def test_provider_flags_of_a_failed_payload_is_empty(api_fixture):
assert provider_flags(api_fixture("ip_api_fail.json")) == {}
@pytest.mark.parametrize(
("mobile", "proxy", "hosting", "known", "expected"),
[
(False, False, False, False, "unknown"),
(False, False, False, True, "residential"),
(True, False, False, True, "mobile"),
(True, False, True, True, "mobile"),
(False, True, False, True, "hosting"),
(False, False, True, True, "hosting"),
],
)
def test_ip_type_classification(mobile, proxy, hosting, known, expected):
assert classify_ip_type(mobile, proxy, hosting, known) == expected
from netsleuth.models import IpGeo
from netsleuth.ip_geo import dual_stack_mismatch, merge_geo
def test_merge_takes_the_first_non_empty_value_in_priority_order():
merged = merge_geo(
[
("cf-trace", IpGeo(ip="203.0.113.44")),
("ip-api", IpGeo(asn="AS64500", city="Amsterdam", country_code="NL")),
("ipwho.is", IpGeo(asn="AS64999", city="Rotterdam", timezone="Europe/Amsterdam")),
]
)
assert merged.ip == "203.0.113.44"
assert merged.asn == "AS64500"
assert merged.city == "Amsterdam"
assert merged.timezone == "Europe/Amsterdam"
def test_merge_records_which_provider_supplied_each_field():
merged = merge_geo(
[
("cf-trace", IpGeo(ip="203.0.113.44")),
("ip-api", IpGeo(asn="AS64500")),
("ipwho.is", IpGeo(timezone="Europe/Amsterdam")),
]
)
assert merged.sources["ip"] == "cf-trace"
assert merged.sources["asn"] == "ip-api"
assert merged.sources["timezone"] == "ipwho.is"
def test_merge_prefers_a_known_ip_type_over_unknown():
merged = merge_geo([("a", IpGeo(ip_type="unknown")), ("b", IpGeo(ip_type="mobile"))])
assert merged.ip_type == "mobile"
assert merged.sources["ip_type"] == "b"
def test_merge_of_nothing_is_an_empty_geo():
merged = merge_geo([])
assert merged.ip is None
assert merged.ip_type == "unknown"
assert merged.sources == {}
def test_merge_ignores_a_provider_that_returned_nothing():
merged = merge_geo([("dead", IpGeo()), ("live", IpGeo(asn="AS64500"))])
assert merged.asn == "AS64500"
assert merged.sources["asn"] == "live"
def test_merge_skips_empty_string_values():
merged = merge_geo([("a", IpGeo(city="")), ("b", IpGeo(city="Amsterdam"))])
assert merged.city == "Amsterdam"
assert merged.sources["city"] == "b"
def test_merge_keeps_first_known_ip_type():
merged = merge_geo([("a", IpGeo(ip_type="hosting")), ("b", IpGeo(ip_type="mobile"))])
assert merged.ip_type == "hosting"
assert merged.sources["ip_type"] == "a"
def test_as_number_returns_none_on_invalid_string():
from netsleuth.ip_geo import _as_number
assert _as_number("invalid") is None
def test_ip_version_returns_none_on_invalid_ip():
from netsleuth.ip_geo import _ip_version
assert _ip_version("not_an_ip") is None
def test_normalize_ipinfo_tolerates_invalid_loc():
geo = normalize_ipinfo({"ip": "203.0.113.44", "loc": "bad,loc"})
assert geo.lat is None
assert geo.lon is None
def test_dual_stack_mismatch_is_reported_not_resolved():
v4 = IpGeo(ip="203.0.113.44", asn="AS64500", country_code="NL")
v6 = IpGeo(ip="2001:db8::1", asn="AS64777", country_code="DE")
pair = dual_stack_mismatch(v4, v6)
assert pair is not None
en, ru = pair
assert "AS64500" in en
assert "AS64777" in en
assert "AS64500" in ru
assert "AS64777" in ru
assert any(ch in ru for ch in "абвгдеёжзийклмнопрстуфхцчшщъыьэюя")
def test_dual_stack_agreement_produces_no_note():
v4 = IpGeo(ip="203.0.113.44", asn="AS64500", country_code="NL")
v6 = IpGeo(ip="2001:db8::1", asn="AS64500", country_code="NL")
assert dual_stack_mismatch(v4, v6) is None
def test_dual_stack_comparison_needs_both_sides():
assert dual_stack_mismatch(IpGeo(asn="AS64500"), None) is None
assert dual_stack_mismatch(None, IpGeo(asn="AS64500")) is None
async def test_gather_identity_for_self_reports_cf_trace_ip(httpx_mock):
httpx_mock.add_response(
url="https://www.cloudflare.com/cdn-cgi/trace",
text="ip=203.0.113.9\nloc=NL\nwarp=off\ngateway=off\nrbi=off\n",
)
httpx_mock.add_response(url="http://ip-api.com/json/", json={"status": "fail"})
httpx_mock.add_response(url="https://freeipapi.com/api/json/", json={})
httpx_mock.add_response(url="https://ipinfo.io/json", json={})
httpx_mock.add_response(url="https://ipwho.is/", json={"success": False})
httpx_mock.add_response(
url="https://stat.ripe.net/data/network-info/data.json?resource=203.0.113.9",
json={"status": "ok", "data": {"asns": []}},
)
async with httpx.AsyncClient() as client:
merged, cf, _flags, _raw = await gather_identity(client, Providers(), ip=None)
assert merged.ip == "203.0.113.9"
assert merged.country_code == "NL"
assert cf is not None and cf.ip == "203.0.113.9"
async def test_gather_identity_tolerates_ripestat_http_error(httpx_mock):
httpx_mock.add_response(url="https://www.cloudflare.com/cdn-cgi/trace", text="")
httpx_mock.add_response(
url="http://ip-api.com/json/8.8.8.8",
json={
"status": "success",
"query": "8.8.8.8",
"as": "AS15169 GOOGLE",
"countryCode": "US",
},
)
httpx_mock.add_response(url="https://freeipapi.com/api/json/8.8.8.8", json={})
httpx_mock.add_response(url="https://ipinfo.io/8.8.8.8json", json={})
httpx_mock.add_response(url="https://ipwho.is/8.8.8.8", json={"success": False})
httpx_mock.add_response(
url="https://stat.ripe.net/data/network-info/data.json?resource=8.8.8.8",
status_code=500,
)
async with httpx.AsyncClient() as client:
merged, _, _, _ = await gather_identity(client, Providers(), ip="8.8.8.8")
assert merged.ip == "8.8.8.8"
assert merged.asn == "AS15169"
async def test_gather_identity_for_a_target_never_reports_this_hosts_own_ip(httpx_mock):
httpx_mock.add_response(
url="https://www.cloudflare.com/cdn-cgi/trace",
text="ip=203.0.113.9\nloc=NL\nwarp=off\ngateway=off\nrbi=off\n",
)
httpx_mock.add_response(
url="http://ip-api.com/json/8.8.8.8",
json={
"status": "success",
"query": "8.8.8.8",
"as": "AS15169 GOOGLE",
"org": "Google LLC",
"country": "United States",
"countryCode": "US",
"city": "Ashburn",
},
)
httpx_mock.add_response(url="https://freeipapi.com/api/json/8.8.8.8", json={})
httpx_mock.add_response(url="https://ipinfo.io/8.8.8.8json", json={})
httpx_mock.add_response(url="https://ipwho.is/8.8.8.8", json={"success": False})
httpx_mock.add_response(
url="https://stat.ripe.net/data/network-info/data.json?resource=8.8.8.8",
json={"status": "ok", "data": {"asns": ["15169"]}},
)
async with httpx.AsyncClient() as client:
merged, cf, _flags, _raw = await gather_identity(client, Providers(), ip="8.8.8.8")
assert merged.ip == "8.8.8.8"
assert merged.asn == "AS15169"
assert cf is not None and cf.ip == "203.0.113.9"