towerops/test/mix/tasks/geoip.import_test.exs
Graham McIntire 2269f38fc5 test: lift coverage 78.42% → 79.59% with focused unit + integration tests
Adds tests across previously-uncovered or under-covered modules:

- Mix tasks: backfill_checks, copy_mibs, import_mibs (new test files)
- Status pages: StatusIncident changeset (new file)
- Webhook signature verification on AgentReleaseWebhookController
- CloudflareBanWorker: HTTP-stubbed success / 5xx / transport-error paths
- FirmwareVersionFetcherWorker: stubbed perform/0 success + non-200 + tx error
- Geoip.Import: production --production code path (HTTP-stubbed)
- AgentLive.Show: superuser restart_agent + update_agent + non-superuser paths
- NetworkMapLive: node_clicked, deep-link node, topology_updated PubSub
- PreseemInsightsLive: toggle_select, deselect_all, filter, dismiss-twice
- AlertQuery: 1-arity defaults variants
- ProfileWatcher: yaml + reload-trigger event passthrough
- MobileAuthController: verify_qr_token success + get/revoke session
- HealthController: redis-configured branch
- RedisHealthCheck: un-tag :integration tests now that Redis is local
- FourOhFourTracker: un-skip module (Redis available)
- PingExecutor: un-tag local-only tests + KeyError surface + clamp branch
- CheckExecutorWorker: dispatch tcp/dns/ssl/ping branches
- UserResetPasswordController: drop dead edit/update + their template

Also removes dead code: edit/update actions on UserResetPasswordController
and the unused edit.html.heex template — both routed via LiveView.
2026-05-07 18:40:43 -05:00

551 lines
20 KiB
Elixir

defmodule Mix.Tasks.Geoip.ImportTest do
use Towerops.DataCase, async: true
import ExUnit.CaptureIO
import Mix.Tasks.Geoip.Import,
only: [
load_locations: 1,
load_blocks: 2,
run: 1,
parse_location_line: 1,
parse_block_line: 1,
build_block_entry: 3,
parse_geoname_id: 1,
parse_cidr: 1
]
alias Mix.Tasks.Geoip.Import
alias Towerops.GeoIP.Block
alias Towerops.GeoIP.Location
alias Towerops.Repo
setup do
# Create temporary directory for test CSV files
temp_dir = Path.join(System.tmp_dir!(), "geoip_test_#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir)
on_exit(fn ->
File.rm_rf!(temp_dir)
end)
{:ok, temp_dir: temp_dir}
end
describe "load_locations/1" do
test "imports locations from CSV file", %{temp_dir: temp_dir} do
csv_file = Path.join(temp_dir, "locations.csv")
File.write!(csv_file, """
geoname_id,locale_code,continent_code,continent_name,country_iso_code,country_name,subdivision_1_iso_code,subdivision_1_name,subdivision_2_iso_code,subdivision_2_name,city_name,metro_code,time_zone,is_in_european_union
5391959,en,NA,North America,US,United States,CA,California,,,San Francisco,807,America/Los_Angeles,0
6167865,en,NA,North America,CA,Canada,ON,Ontario,,,Toronto,0,America/Toronto,0
2643743,en,EU,Europe,GB,United Kingdom,ENG,England,,,London,0,Europe/London,1
""")
{count, geoname_ids} = load_locations(csv_file)
assert count == 3
assert MapSet.size(geoname_ids) == 3
assert MapSet.member?(geoname_ids, 5_391_959)
assert MapSet.member?(geoname_ids, 6_167_865)
assert MapSet.member?(geoname_ids, 2_643_743)
# Verify database records
locations = Repo.all(Location)
assert length(locations) == 3
san_francisco = Enum.find(locations, &(&1.geoname_id == 5_391_959))
assert san_francisco.country_code == "US"
assert san_francisco.country_name == "United States"
assert san_francisco.city_name == "San Francisco"
assert san_francisco.subdivision_1_name == "California"
end
test "skips locations without country code", %{temp_dir: temp_dir} do
csv_file = Path.join(temp_dir, "locations.csv")
File.write!(csv_file, """
geoname_id,locale_code,continent_code,continent_name,country_iso_code,country_name,subdivision_1_iso_code,subdivision_1_name,subdivision_2_iso_code,subdivision_2_name,city_name,metro_code,time_zone,is_in_european_union
5391959,en,NA,North America,US,United States,CA,California,,,San Francisco,807,America/Los_Angeles,0
9999999,en,NA,North America,,,,,,,,0,,0
""")
{count, geoname_ids} = load_locations(csv_file)
assert count == 1
assert MapSet.size(geoname_ids) == 1
assert MapSet.member?(geoname_ids, 5_391_959)
end
test "skips locations without geoname_id", %{temp_dir: temp_dir} do
csv_file = Path.join(temp_dir, "locations.csv")
File.write!(csv_file, """
geoname_id,locale_code,continent_code,continent_name,country_iso_code,country_name,subdivision_1_iso_code,subdivision_1_name,subdivision_2_iso_code,subdivision_2_name,city_name,metro_code,time_zone,is_in_european_union
5391959,en,NA,North America,US,United States,CA,California,,,San Francisco,807,America/Los_Angeles,0
,en,NA,North America,CA,Canada,ON,Ontario,,,Toronto,0,America/Toronto,0
""")
{count, geoname_ids} = load_locations(csv_file)
assert count == 1
assert MapSet.size(geoname_ids) == 1
end
test "handles empty optional fields", %{temp_dir: temp_dir} do
csv_file = Path.join(temp_dir, "locations.csv")
File.write!(csv_file, """
geoname_id,locale_code,continent_code,continent_name,country_iso_code,country_name,subdivision_1_iso_code,subdivision_1_name,subdivision_2_iso_code,subdivision_2_name,city_name,metro_code,time_zone,is_in_european_union
5391959,en,NA,North America,US,,,,,,,,America/Los_Angeles,0
""")
{count, _geoname_ids} = load_locations(csv_file)
assert count == 1
location = Repo.get_by!(Location, geoname_id: 5_391_959)
assert location.country_name == nil
assert location.city_name == nil
assert location.subdivision_1_name == nil
assert location.subdivision_2_name == nil
end
test "processes large batches correctly", %{temp_dir: temp_dir} do
csv_file = Path.join(temp_dir, "locations.csv")
# Generate 10,000 locations (more than batch size of 5,000)
header =
"geoname_id,locale_code,continent_code,continent_name,country_iso_code,country_name,subdivision_1_iso_code,subdivision_1_name,subdivision_2_iso_code,subdivision_2_name,city_name,metro_code,time_zone,is_in_european_union\n"
rows =
Enum.map(1..10_000, fn i ->
"#{i},en,NA,North America,US,United States,CA,California,,,City#{i},807,America/Los_Angeles,0"
end)
File.write!(csv_file, header <> Enum.join(rows, "\n") <> "\n")
{count, geoname_ids} = load_locations(csv_file)
assert count == 10_000
assert MapSet.size(geoname_ids) == 10_000
assert Repo.aggregate(Location, :count) == 10_000
end
end
describe "load_blocks/2" do
test "imports IP blocks from CSV file", %{temp_dir: temp_dir} do
csv_file = Path.join(temp_dir, "blocks.csv")
# First, create some locations
location1 = insert(:geoip_location, geoname_id: 5_391_959, country_code: "US")
location2 = insert(:geoip_location, geoname_id: 6_167_865, country_code: "CA")
valid_geoname_ids = MapSet.new([location1.geoname_id, location2.geoname_id])
File.write!(csv_file, """
network,geoname_id,registered_country_geoname_id,represented_country_geoname_id,is_anonymous_proxy,is_satellite_provider,postal_code,latitude,longitude,accuracy_radius
1.0.0.0/24,5391959,5391959,,,0,94102,37.7749,-122.4194,1000
1.0.1.0/24,6167865,6167865,,,0,M5H,43.6532,-79.3832,1000
1.0.2.0/24,5391959,5391959,,,0,94102,37.7749,-122.4194,1000
""")
count = load_blocks(csv_file, valid_geoname_ids)
assert count == 3
# Verify database records
blocks = Repo.all(Block)
assert length(blocks) == 3
block1 = Enum.find(blocks, &(&1.network == "1.0.0.0/24"))
assert block1.geoname_id == 5_391_959
assert block1.start_ip_int == 16_777_216
assert block1.end_ip_int == 16_777_471
end
test "filters out blocks with invalid geoname_id", %{temp_dir: temp_dir} do
csv_file = Path.join(temp_dir, "blocks.csv")
location = insert(:geoip_location, geoname_id: 5_391_959, country_code: "US")
valid_geoname_ids = MapSet.new([location.geoname_id])
File.write!(csv_file, """
network,geoname_id,registered_country_geoname_id,represented_country_geoname_id,is_anonymous_proxy,is_satellite_provider,postal_code,latitude,longitude,accuracy_radius
1.0.0.0/24,5391959,5391959,,,0,94102,37.7749,-122.4194,1000
1.0.1.0/24,9999999,9999999,,,0,M5H,43.6532,-79.3832,1000
""")
count = load_blocks(csv_file, valid_geoname_ids)
assert count == 1
assert Repo.aggregate(Block, :count) == 1
end
test "uses registered_country_geoname_id as fallback", %{temp_dir: temp_dir} do
csv_file = Path.join(temp_dir, "blocks.csv")
location = insert(:geoip_location, geoname_id: 5_391_959, country_code: "US")
valid_geoname_ids = MapSet.new([location.geoname_id])
File.write!(csv_file, """
network,geoname_id,registered_country_geoname_id,represented_country_geoname_id,is_anonymous_proxy,is_satellite_provider,postal_code,latitude,longitude,accuracy_radius
1.0.0.0/24,,5391959,,,0,94102,37.7749,-122.4194,1000
""")
count = load_blocks(csv_file, valid_geoname_ids)
assert count == 1
block = Repo.one!(Block)
assert block.geoname_id == 5_391_959
assert block.registered_country_geoname_id == 5_391_959
end
test "skips blocks with no valid geoname_id", %{temp_dir: temp_dir} do
csv_file = Path.join(temp_dir, "blocks.csv")
valid_geoname_ids = MapSet.new()
File.write!(csv_file, """
network,geoname_id,registered_country_geoname_id,represented_country_geoname_id,is_anonymous_proxy,is_satellite_provider,postal_code,latitude,longitude,accuracy_radius
1.0.0.0/24,,,,,0,94102,37.7749,-122.4194,1000
""")
count = load_blocks(csv_file, valid_geoname_ids)
assert count == 0
assert Repo.aggregate(Block, :count) == 0
end
test "calculates IP ranges correctly", %{temp_dir: temp_dir} do
csv_file = Path.join(temp_dir, "blocks.csv")
location = insert(:geoip_location, geoname_id: 5_391_959, country_code: "US")
valid_geoname_ids = MapSet.new([location.geoname_id])
File.write!(csv_file, """
network,geoname_id,registered_country_geoname_id,represented_country_geoname_id,is_anonymous_proxy,is_satellite_provider,postal_code,latitude,longitude,accuracy_radius
192.168.1.0/24,5391959,5391959,,,0,94102,37.7749,-122.4194,1000
10.0.0.0/8,5391959,5391959,,,0,94102,37.7749,-122.4194,1000
172.16.0.0/12,5391959,5391959,,,0,94102,37.7749,-122.4194,1000
""")
count = load_blocks(csv_file, valid_geoname_ids)
assert count == 3
blocks = Repo.all(Block)
# 192.168.1.0/24 = 192.168.1.0 to 192.168.1.255
block1 = Enum.find(blocks, &(&1.network == "192.168.1.0/24"))
assert block1.start_ip_int == 3_232_235_776
assert block1.end_ip_int == 3_232_236_031
# 10.0.0.0/8 = 10.0.0.0 to 10.255.255.255
block2 = Enum.find(blocks, &(&1.network == "10.0.0.0/8"))
assert block2.start_ip_int == 167_772_160
assert block2.end_ip_int == 184_549_375
# 172.16.0.0/12 = 172.16.0.0 to 172.31.255.255
block3 = Enum.find(blocks, &(&1.network == "172.16.0.0/12"))
assert block3.start_ip_int == 2_886_729_728
assert block3.end_ip_int == 2_887_778_303
end
test "processes large batches correctly", %{temp_dir: temp_dir} do
csv_file = Path.join(temp_dir, "blocks.csv")
location = insert(:geoip_location, geoname_id: 5_391_959, country_code: "US")
valid_geoname_ids = MapSet.new([location.geoname_id])
# Generate 10,000 blocks (more than batch size of 5,000)
header =
"network,geoname_id,registered_country_geoname_id,represented_country_geoname_id,is_anonymous_proxy,is_satellite_provider,postal_code,latitude,longitude,accuracy_radius\n"
rows =
Enum.map(1..10_000, fn i ->
# Use sequential /32 blocks
octet1 = div(i, 256 * 256)
octet2 = rem(div(i, 256), 256)
octet3 = rem(i, 256)
"#{octet1}.#{octet2}.#{octet3}.0/32,5391959,5391959,,,0,94102,37.7749,-122.4194,1000"
end)
File.write!(csv_file, header <> Enum.join(rows, "\n") <> "\n")
count = load_blocks(csv_file, valid_geoname_ids)
assert count == 10_000
assert Repo.aggregate(Block, :count) == 10_000
end
end
describe "run/1 with invalid arguments" do
test "shows usage help when no directory is provided" do
output = capture_io(:stderr, fn -> run([]) end)
assert output =~ "Usage: mix geoip.import <directory>"
end
test "shows usage help when too many arguments are provided" do
output = capture_io(:stderr, fn -> run(["dir1", "dir2"]) end)
assert output =~ "Usage: mix geoip.import <directory>"
end
test "requires TOWEROPS_KEY env var for production mode" do
System.delete_env("TOWEROPS_KEY")
assert catch_exit(
capture_io(:stderr, fn ->
run(["~/geoip", "--production"])
end)
) == {:shutdown, 1}
end
end
describe "parse_location_line/1" do
test "parses a complete location line" do
line =
"5391959,en,NA,North America,US,United States,CA,California,,,San Francisco,807,America/Los_Angeles,0"
result = parse_location_line(line)
assert result.geoname_id == 5_391_959
assert result.country_code == "US"
assert result.country_name == "United States"
assert result.city_name == "San Francisco"
assert result.subdivision_1_name == "California"
assert result.subdivision_2_name == nil
assert result.latitude == nil
assert result.longitude == nil
end
test "returns nil for row with empty geoname_id" do
line = ",en,NA,North America,US,United States,CA,California,,,San Francisco,807,America/Los_Angeles,0"
assert parse_location_line(line) == nil
end
test "returns nil for row with empty country_code" do
line = "5391959,en,NA,North America,,United States,CA,California,,,San Francisco,807,America/Los_Angeles,0"
assert parse_location_line(line) == nil
end
test "handles empty optional fields as nil" do
line = "5391959,en,NA,North America,US,,,,,,,America/Los_Angeles,0"
result = parse_location_line(line)
assert result.geoname_id == 5_391_959
assert result.country_name == nil
assert result.city_name == nil
assert result.subdivision_1_name == nil
assert result.subdivision_2_name == nil
end
test "returns nil for line with too few columns" do
line = "5391959,en,NA"
assert parse_location_line(line) == nil
end
end
describe "parse_block_line/1" do
test "parses a complete block line" do
line = "1.0.0.0/24,5391959,5391959,,,,,94102,37.7749,-122.4194,1000"
result = parse_block_line(line)
assert result.network == "1.0.0.0/24"
assert result.geoname_id == 5_391_959
assert result.registered_country_geoname_id == 5_391_959
end
test "falls back to registered_country_geoname_id when geoname_id is empty" do
line = "1.0.0.0/24,,5391959,,,,,94102,37.7749,-122.4194,1000"
result = parse_block_line(line)
assert result.network == "1.0.0.0/24"
assert result.geoname_id == 5_391_959
assert result.registered_country_geoname_id == 5_391_959
end
test "returns nil when both geoname_ids are empty" do
line = "1.0.0.0/24,,,,,,,94102,37.7749,-122.4194,1000"
assert parse_block_line(line) == nil
end
test "returns nil for line with too few columns" do
line = "1.0.0.0/24"
assert parse_block_line(line) == nil
end
test "returns nil for invalid CIDR" do
line = "not-a-cidr,5391959,5391959,,,,,94102,37.7749,-122.4194,1000"
assert parse_block_line(line) == nil
end
test "parses block with empty registered_country" do
line = "1.0.0.0/24,5391959,,,,,,94102,37.7749,-122.4194,1000"
result = parse_block_line(line)
assert result.network == "1.0.0.0/24"
assert result.geoname_id == 5_391_959
assert result.registered_country_geoname_id == nil
end
end
describe "build_block_entry/3" do
test "builds entry for valid CIDR" do
result = build_block_entry("1.0.0.0/24", 5_391_959, 5_391_959)
assert result.network == "1.0.0.0/24"
assert result.geoname_id == 5_391_959
assert result.registered_country_geoname_id == 5_391_959
assert result.start_ip_int == 16_777_216
assert result.end_ip_int == 16_777_471
end
test "returns nil for nil geoname_id" do
assert build_block_entry("1.0.0.0/24", nil, nil) == nil
end
test "returns nil for invalid CIDR" do
assert build_block_entry("invalid", 5_391_959, nil) == nil
end
test "builds entry with nil registered_country" do
result = build_block_entry("10.0.0.0/8", 1, nil)
assert result.network == "10.0.0.0/8"
assert result.geoname_id == 1
assert result.registered_country_geoname_id == nil
assert result.start_ip_int == 167_772_160
assert result.end_ip_int == 184_549_375
end
end
describe "parse_geoname_id/1" do
test "parses a valid integer" do
assert parse_geoname_id("5391959") == 5_391_959
end
test "returns nil for empty string" do
assert parse_geoname_id("") == nil
end
test "returns nil for non-numeric string" do
assert parse_geoname_id("abc") == nil
end
test "parses integer with trailing chars from CSV parsing" do
assert parse_geoname_id("123") == 123
end
end
describe "production mode (--production)" do
setup %{temp_dir: temp_dir} do
previous_key = System.get_env("TOWEROPS_KEY")
System.put_env("TOWEROPS_KEY", "fake-superuser-token")
on_exit(fn ->
if previous_key do
System.put_env("TOWEROPS_KEY", previous_key)
else
System.delete_env("TOWEROPS_KEY")
end
end)
locations_file = Path.join(temp_dir, "GeoLite2-City-Locations-en.csv")
blocks_file = Path.join(temp_dir, "GeoLite2-City-Blocks-IPv4.csv")
File.write!(locations_file, """
geoname_id,locale_code,continent_code,continent_name,country_iso_code,country_name,subdivision_1_iso_code,subdivision_1_name,subdivision_2_iso_code,subdivision_2_name,city_name,metro_code,time_zone,is_in_european_union
5391959,en,NA,North America,US,United States,CA,California,,,San Francisco,807,America/Los_Angeles,0
""")
File.write!(blocks_file, """
network,geoname_id,registered_country_geoname_id,represented_country_geoname_id,is_anonymous_proxy,is_satellite_provider,postal_code,latitude,longitude,accuracy_radius
1.0.0.0/24,5391959,5391959,,,0,94102,37.7749,-122.4194,1000
""")
{:ok, temp_dir: temp_dir}
end
test "sends data to the configured API URL", %{temp_dir: temp_dir} do
api_url = "http://geoip-test.example.invalid"
System.put_env("TOWEROPS_URL", api_url)
on_exit(fn -> System.delete_env("TOWEROPS_URL") end)
Req.Test.stub(Import, fn conn ->
assert conn.request_path == "/admin/api/geoip/import"
Req.Test.json(conn, %{"ok" => true})
end)
capture_io(fn -> run([temp_dir, "--production"]) end)
end
test "raises when API responds with a non-200 status", %{temp_dir: temp_dir} do
Req.Test.stub(Import, fn conn ->
conn
|> Plug.Conn.put_resp_header("content-type", "application/json")
|> Plug.Conn.send_resp(401, ~s({"error":"unauthorized"}))
end)
assert_raise RuntimeError, ~r/Failed to send batch/, fn ->
capture_io(:stderr, fn -> run([temp_dir, "--production"]) end)
end
end
end
describe "parse_cidr/1" do
test "parses valid /24 CIDR" do
assert parse_cidr("1.0.0.0/24") == {:ok, 16_777_216, 16_777_471}
end
test "parses valid /32 CIDR" do
assert parse_cidr("1.0.0.1/32") == {:ok, 16_777_217, 16_777_217}
end
test "parses valid /8 CIDR" do
assert parse_cidr("10.0.0.0/8") == {:ok, 167_772_160, 184_549_375}
end
test "parses valid /16 CIDR" do
assert parse_cidr("192.168.0.0/16") == {:ok, 3_232_235_520, 3_232_301_055}
end
test "returns :error for missing prefix" do
assert parse_cidr("1.0.0.0") == :error
end
test "returns :error for invalid IP" do
assert parse_cidr("999.999.999.999/24") == :error
end
test "returns :error for invalid prefix" do
assert parse_cidr("1.0.0.0/abc") == :error
end
test "returns :error for empty string" do
assert parse_cidr("") == :error
end
test "returns :error for prefix > 32" do
assert_raise ArithmeticError, fn -> parse_cidr("1.0.0.0/33") end
end
end
# Helper to insert location for tests
defp insert(:geoip_location, attrs) do
defaults = %{
country_code: "US",
country_name: "United States",
city_name: "Test City",
subdivision_1_name: nil,
subdivision_2_name: nil,
latitude: nil,
longitude: nil,
inserted_at: Towerops.Time.now(),
updated_at: Towerops.Time.now()
}
attrs = Map.merge(defaults, Map.new(attrs))
%Location{}
|> Ecto.Changeset.change(attrs)
|> Repo.insert!()
end
end