From 1ce3763e5f5b216543716fb018869913cfe9505c Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Tue, 3 Feb 2026 10:19:56 -0600 Subject: [PATCH] more tests --- lib/mix/tasks/import_profiles.ex | 14 +- lib/mix/tasks/oban.cancel_stuck_discovery.ex | 12 + test/mix/tasks/gen_vendor_modules_test.exs | 473 ++++++++++++++++++ test/mix/tasks/gen_vendor_tests_test.exs | 335 +++++++++++++ test/mix/tasks/geoip.import_test.exs | 312 ++++++++++++ test/mix/tasks/import_profiles_test.exs | 455 +++++++++++++++++ .../oban.cancel_stuck_discovery_test.exs | 206 ++++++++ test/mix/tasks/populate_english_test.exs | 247 +++++++++ 8 files changed, 2047 insertions(+), 7 deletions(-) create mode 100644 test/mix/tasks/gen_vendor_modules_test.exs create mode 100644 test/mix/tasks/gen_vendor_tests_test.exs create mode 100644 test/mix/tasks/geoip.import_test.exs create mode 100644 test/mix/tasks/import_profiles_test.exs create mode 100644 test/mix/tasks/oban.cancel_stuck_discovery_test.exs create mode 100644 test/mix/tasks/populate_english_test.exs diff --git a/lib/mix/tasks/import_profiles.ex b/lib/mix/tasks/import_profiles.ex index abadc2b4..a1b861c0 100644 --- a/lib/mix/tasks/import_profiles.ex +++ b/lib/mix/tasks/import_profiles.ex @@ -81,7 +81,7 @@ defmodule Mix.Tasks.ImportProfiles do end defp import_all_profiles(source_path) do - Logger.info("Discovering YAML files in #{source_path}...") + Mix.shell().info("Discovering YAML files in #{source_path}...") detection_path = Path.join(source_path, "os_detection") @@ -91,29 +91,29 @@ defmodule Mix.Tasks.ImportProfiles do |> Path.wildcard() |> Enum.sort() - Logger.info("Found #{length(yaml_files)} YAML files") + Mix.shell().info("Found #{length(yaml_files)} YAML files") profile_names = Enum.map(yaml_files, &Path.basename(&1, ".yaml")) import_profiles_by_name(source_path, profile_names) end defp import_specific_profiles(source_path, profiles) do - Logger.info("Importing #{length(profiles)} specified profiles") + Mix.shell().info("Importing #{length(profiles)} specified profiles") import_profiles_by_name(source_path, profiles) end defp import_profiles_by_name(source_path, profile_names) do results = Enum.map(profile_names, fn profile_name -> - Logger.info("Importing profile: #{profile_name}") + Mix.shell().info("Importing profile: #{profile_name}") case import_profile(source_path, profile_name) do {:ok, _profile} -> - Logger.info("✓ #{profile_name}: created/updated successfully") + Mix.shell().info("✓ #{profile_name}: created/updated successfully") {:ok, profile_name} {:error, reason} -> - Logger.error("✗ #{profile_name}: #{inspect(reason)}") + Mix.shell().error("✗ #{profile_name}: #{inspect(reason)}") {:error, profile_name, reason} end end) @@ -121,7 +121,7 @@ defmodule Mix.Tasks.ImportProfiles do successes = Enum.count(results, &match?({:ok, _}, &1)) failures = Enum.count(results, &match?({:error, _, _}, &1)) - Logger.info("Import complete: #{successes} succeeded, #{failures} failed") + Mix.shell().info("Import complete: #{successes} succeeded, #{failures} failed") end defp import_profile(source_path, profile_name) do diff --git a/lib/mix/tasks/oban.cancel_stuck_discovery.ex b/lib/mix/tasks/oban.cancel_stuck_discovery.ex index 5f8167d4..d91cf7ac 100644 --- a/lib/mix/tasks/oban.cancel_stuck_discovery.ex +++ b/lib/mix/tasks/oban.cancel_stuck_discovery.ex @@ -40,6 +40,18 @@ defmodule Mix.Tasks.Oban.CancelStuckDiscovery do 1 + :ok -> + # Some versions of Oban return :ok instead of {:ok, job} + Logger.info( + "Cancelled stuck discovery job", + job_id: job.id, + device_id: get_in(job.args, ["device_id"]), + state: job.state, + attempted_at: job.attempted_at + ) + + 1 + {:error, reason} -> Logger.error( "Failed to cancel job", diff --git a/test/mix/tasks/gen_vendor_modules_test.exs b/test/mix/tasks/gen_vendor_modules_test.exs new file mode 100644 index 00000000..85124b2a --- /dev/null +++ b/test/mix/tasks/gen_vendor_modules_test.exs @@ -0,0 +1,473 @@ +defmodule Mix.Tasks.GenVendorModulesTest do + use ExUnit.Case, async: false + + import ExUnit.CaptureIO + + alias Mix.Tasks.GenVendorModules + + @moduletag :mix_task + + setup do + # Save original directory + original_dir = File.cwd!() + + # Create temporary directories for profiles and vendor modules + temp_dir = Path.join(System.tmp_dir!(), "gen_vendor_modules_#{System.unique_integer([:positive])}") + profiles_dir = Path.join([temp_dir, "priv", "profiles", "os_detection"]) + vendors_dir = Path.join([temp_dir, "lib", "towerops", "snmp", "profiles", "vendors"]) + File.mkdir_p!(profiles_dir) + File.mkdir_p!(vendors_dir) + + on_exit(fn -> + File.cd!(original_dir) + File.rm_rf!(temp_dir) + end) + + {:ok, temp_dir: temp_dir, profiles_dir: profiles_dir, vendors_dir: vendors_dir} + end + + describe "run/1 with dry-run" do + test "reports vendors that would be generated in dry-run mode", %{ + temp_dir: temp_dir, + profiles_dir: profiles_dir, + vendors_dir: _vendors_dir + } do + # Create profile YAML with group + File.write!(Path.join(profiles_dir, "mikrotik.yaml"), """ + os: mikrotik + group: mikrotik + text: MikroTik + discovery: + - sysObjectID: + - .1.3.6.1.4.1.14988. + """) + + File.cd!(temp_dir, fn -> + output = + capture_io(fn -> + GenVendorModules.run(["--dry-run"]) + end) + + assert output =~ "Found 1 YAML profiles" + assert output =~ "Grouped into 1 vendors" + assert output =~ "1 new vendors to generate" + assert output =~ "mikrotik.ex (Mikrotik)" + assert output =~ "Profiles: mikrotik" + assert output =~ "Enterprise OID: 1.3.6.1.4.1.14988" + end) + end + + test "reports no work needed when all vendors exist", %{ + profiles_dir: profiles_dir, + vendors_dir: vendors_dir, + temp_dir: temp_dir + } do + # Create profile YAML + File.write!(Path.join(profiles_dir, "mikrotik.yaml"), """ + os: mikrotik + group: mikrotik + text: MikroTik + """) + + # Create existing vendor module + File.write!(Path.join(vendors_dir, "mikrotik.ex"), """ + defmodule Towerops.Snmp.Profiles.Vendors.Mikrotik do + def profile_names, do: ["mikrotik"] + end + """) + + File.cd!(temp_dir, fn -> + output = + capture_io(fn -> + GenVendorModules.run(["--dry-run"]) + end) + + assert output =~ "0 new vendors to generate" + assert output =~ "No new vendors to generate!" + end) + end + end + + describe "run/1 without dry-run" do + test "generates vendor module from profile with group", %{ + profiles_dir: profiles_dir, + vendors_dir: vendors_dir, + temp_dir: temp_dir + } do + File.write!(Path.join(profiles_dir, "cisco-ios.yaml"), """ + os: cisco-ios + group: cisco + text: Cisco IOS + discovery: + - sysObjectID: + - .1.3.6.1.4.1.9. + """) + + File.cd!(temp_dir, fn -> + output = + capture_io(fn -> + GenVendorModules.run([]) + end) + + assert output =~ "Generated: lib/towerops/snmp/profiles/vendors/cisco.ex" + assert output =~ "Generated 1 vendor modules" + + # Verify file was created + vendor_file = Path.join(vendors_dir, "cisco.ex") + assert File.exists?(vendor_file) + + # Verify file content + content = File.read!(vendor_file) + assert content =~ "defmodule Towerops.Snmp.Profiles.Vendors.Cisco do" + assert content =~ "@moduledoc" + assert content =~ "SNMP profile for Cisco devices" + assert content =~ "@behaviour Towerops.Snmp.Profiles.Vendors.Vendor" + assert content =~ "def profile_names, do: [\"cisco-ios\"]" + assert content =~ "# Enterprise OID: 1.3.6.1.4.1.9" + assert content =~ "def detect_hardware(client_opts)" + assert content =~ "def wireless_oid_defs" + assert content =~ "def discover_wireless_sensors(client_opts)" + end) + end + + test "generates vendor module from profile without group using filename inference", %{ + temp_dir: temp_dir, + profiles_dir: profiles_dir, + vendors_dir: vendors_dir + } do + File.write!(Path.join(profiles_dir, "cisco_ios.yaml"), """ + os: cisco-ios + text: Cisco IOS + """) + + File.cd!(temp_dir, fn -> + output = + capture_io(fn -> + GenVendorModules.run([]) + end) + + # Should infer cisco from filename + assert output =~ "Generated: lib/towerops/snmp/profiles/vendors/cisco.ex" + + vendor_file = Path.join(vendors_dir, "cisco.ex") + assert File.exists?(vendor_file) + end) + end + + test "groups multiple profiles into single vendor module", %{ + profiles_dir: profiles_dir, + vendors_dir: vendors_dir, + temp_dir: temp_dir + } do + File.write!(Path.join(profiles_dir, "cisco-ios.yaml"), """ + os: cisco-ios + group: cisco + text: Cisco IOS + """) + + File.write!(Path.join(profiles_dir, "cisco-nxos.yaml"), """ + os: cisco-nxos + group: cisco + text: Cisco NX-OS + """) + + File.write!(Path.join(profiles_dir, "cisco-asa.yaml"), """ + os: cisco-asa + group: cisco + text: Cisco ASA + """) + + File.cd!(temp_dir, fn -> + output = + capture_io(fn -> + GenVendorModules.run([]) + end) + + assert output =~ "Generated 1 vendor modules" + + vendor_file = Path.join(vendors_dir, "cisco.ex") + content = File.read!(vendor_file) + + # Should include all three profiles + assert content =~ ~s(["cisco-asa", "cisco-ios", "cisco-nxos"]) + end) + end + + test "generates modules for multiple vendors", %{ + profiles_dir: profiles_dir, + vendors_dir: vendors_dir, + temp_dir: temp_dir + } do + File.write!(Path.join(profiles_dir, "mikrotik.yaml"), """ + os: mikrotik + group: mikrotik + text: MikroTik + """) + + File.write!(Path.join(profiles_dir, "ubnt.yaml"), """ + os: ubnt + group: ubiquiti + text: Ubiquiti + """) + + File.cd!(temp_dir, fn -> + output = + capture_io(fn -> + GenVendorModules.run([]) + end) + + assert output =~ "Generated 2 vendor modules" + assert File.exists?(Path.join(vendors_dir, "mikrotik.ex")) + assert File.exists?(Path.join(vendors_dir, "ubiquiti.ex")) + end) + end + + test "generates module with specific group filter", %{ + profiles_dir: profiles_dir, + vendors_dir: vendors_dir, + temp_dir: temp_dir + } do + File.write!(Path.join(profiles_dir, "mikrotik.yaml"), """ + os: mikrotik + group: mikrotik + text: MikroTik + """) + + File.write!(Path.join(profiles_dir, "cisco.yaml"), """ + os: cisco + group: cisco + text: Cisco + """) + + File.cd!(temp_dir, fn -> + output = + capture_io(fn -> + GenVendorModules.run(["--group", "mikrotik"]) + end) + + assert output =~ "Generated 1 vendor modules" + assert File.exists?(Path.join(vendors_dir, "mikrotik.ex")) + refute File.exists?(Path.join(vendors_dir, "cisco.ex")) + end) + end + + test "handles vendor names starting with numbers", %{ + profiles_dir: profiles_dir, + vendors_dir: vendors_dir, + temp_dir: temp_dir + } do + File.write!(Path.join(profiles_dir, "4rf.yaml"), """ + os: 4rf + group: 4rf + text: 4RF + """) + + File.cd!(temp_dir, fn -> + output = + capture_io(fn -> + GenVendorModules.run([]) + end) + + # Should prefix with "n" for filename and "N" for module + assert output =~ "Generated: lib/towerops/snmp/profiles/vendors/n4rf.ex" + + vendor_file = Path.join(vendors_dir, "n4rf.ex") + assert File.exists?(vendor_file) + + content = File.read!(vendor_file) + assert content =~ "defmodule Towerops.Snmp.Profiles.Vendors.N4rf do" + end) + end + + test "handles vendor names with special characters", %{ + profiles_dir: profiles_dir, + vendors_dir: vendors_dir, + temp_dir: temp_dir + } do + File.write!(Path.join(profiles_dir, "device.yaml"), """ + os: test-device + group: hp-aruba + text: HP Aruba + """) + + File.cd!(temp_dir, fn -> + output = + capture_io(fn -> + GenVendorModules.run([]) + end) + + # Should convert to snake_case for filename + assert output =~ "Generated: lib/towerops/snmp/profiles/vendors/hp_aruba.ex" + + vendor_file = Path.join(vendors_dir, "hp_aruba.ex") + assert File.exists?(vendor_file) + + content = File.read!(vendor_file) + assert content =~ "defmodule Towerops.Snmp.Profiles.Vendors.HpAruba do" + end) + end + + test "extracts enterprise OID from sysObjectID", %{ + profiles_dir: profiles_dir, + vendors_dir: vendors_dir, + temp_dir: temp_dir + } do + File.write!(Path.join(profiles_dir, "test.yaml"), """ + os: test + group: testvendor + text: Test Vendor + discovery: + - sysObjectID: + - .1.3.6.1.4.1.12345.1.2.3 + """) + + File.cd!(temp_dir, fn -> + capture_io(fn -> + GenVendorModules.run([]) + end) + + vendor_file = Path.join(vendors_dir, "testvendor.ex") + content = File.read!(vendor_file) + + assert content =~ "# Enterprise OID: 1.3.6.1.4.1.12345" + end) + end + + test "handles profile without enterprise OID", %{ + profiles_dir: profiles_dir, + vendors_dir: vendors_dir, + temp_dir: temp_dir + } do + File.write!(Path.join(profiles_dir, "generic.yaml"), """ + os: generic + group: generic + text: Generic Device + """) + + File.cd!(temp_dir, fn -> + capture_io(fn -> + GenVendorModules.run([]) + end) + + vendor_file = Path.join(vendors_dir, "generic.ex") + content = File.read!(vendor_file) + + assert content =~ "# Enterprise OID: unknown - uses standard MIB OIDs" + end) + end + + test "includes standard CPU sensor in wireless_oid_defs", %{ + profiles_dir: profiles_dir, + vendors_dir: vendors_dir, + temp_dir: temp_dir + } do + File.write!(Path.join(profiles_dir, "test.yaml"), """ + os: test + group: test + text: Test + """) + + File.cd!(temp_dir, fn -> + capture_io(fn -> + GenVendorModules.run([]) + end) + + vendor_file = Path.join(vendors_dir, "test.ex") + content = File.read!(vendor_file) + + assert content =~ "wireless_oid_defs do" + assert content =~ "1.3.6.1.2.1.25.3.3.1.2.1" + assert content =~ "CPU Load" + assert content =~ "sensor_type: \"load\"" + end) + end + + test "skips profiles that cannot be grouped to a vendor", %{ + profiles_dir: profiles_dir, + vendors_dir: _vendors_dir, + temp_dir: temp_dir + } do + File.write!(Path.join(profiles_dir, "unknown_device.yaml"), """ + os: unknown-device + text: Unknown Device + """) + + File.cd!(temp_dir, fn -> + output = + capture_io(fn -> + GenVendorModules.run([]) + end) + + assert output =~ "0 new vendors to generate" + assert output =~ "No new vendors to generate!" + end) + end + + test "includes instructions after generation", %{ + profiles_dir: profiles_dir, + vendors_dir: _vendors_dir, + temp_dir: temp_dir + } do + File.write!(Path.join(profiles_dir, "test.yaml"), """ + os: test + group: test + text: Test + """) + + File.cd!(temp_dir, fn -> + output = + capture_io(fn -> + GenVendorModules.run([]) + end) + + assert output =~ "Run `mix compile` to check for errors" + assert output =~ "Run `mix test` to verify" + assert output =~ "Add aliases to registry.ex" + assert output =~ "Add vendors to @vendors list in registry.ex" + assert output =~ "Write tests for each vendor" + assert output =~ "Add vendor-specific OIDs if available" + end) + end + end + + describe "run/1 error handling" do + test "skips profiles with invalid YAML", %{profiles_dir: profiles_dir, vendors_dir: vendors_dir, temp_dir: temp_dir} do + File.write!(Path.join(profiles_dir, "valid.yaml"), """ + os: valid + group: valid + text: Valid + """) + + File.write!(Path.join(profiles_dir, "invalid.yaml"), """ + this is: [invalid yaml that won't parse + """) + + File.cd!(temp_dir, fn -> + output = + capture_io(fn -> + GenVendorModules.run([]) + end) + + # Should still generate valid vendor + assert output =~ "Generated 1 vendor modules" + assert File.exists?(Path.join(vendors_dir, "valid.ex")) + end) + end + + test "skips profiles without os field", %{profiles_dir: profiles_dir, vendors_dir: _vendors_dir, temp_dir: temp_dir} do + File.write!(Path.join(profiles_dir, "no_os.yaml"), """ + group: test + text: Test + """) + + File.cd!(temp_dir, fn -> + output = + capture_io(fn -> + GenVendorModules.run([]) + end) + + assert output =~ "0 new vendors to generate" + end) + end + end +end diff --git a/test/mix/tasks/gen_vendor_tests_test.exs b/test/mix/tasks/gen_vendor_tests_test.exs new file mode 100644 index 00000000..4d918a1d --- /dev/null +++ b/test/mix/tasks/gen_vendor_tests_test.exs @@ -0,0 +1,335 @@ +defmodule Mix.Tasks.GenVendorTestsTest do + use ExUnit.Case, async: false + + import ExUnit.CaptureIO + + alias Mix.Tasks.GenVendorTests + + @moduletag :mix_task + + setup do + # Save original directory + original_dir = File.cwd!() + + # Create temporary directories for vendor modules and tests + temp_dir = Path.join(System.tmp_dir!(), "vendor_tests_#{System.unique_integer([:positive])}") + vendors_dir = Path.join(temp_dir, "lib/towerops/snmp/profiles/vendors") + tests_dir = Path.join(temp_dir, "test/towerops/snmp/profiles/vendors") + File.mkdir_p!(vendors_dir) + File.mkdir_p!(tests_dir) + + on_exit(fn -> + File.cd!(original_dir) + File.rm_rf!(temp_dir) + end) + + {:ok, temp_dir: temp_dir, vendors_dir: vendors_dir, tests_dir: tests_dir} + end + + describe "run/1 with dry-run" do + test "reports vendors without tests in dry-run mode", %{ + vendors_dir: vendors_dir, + tests_dir: _tests_dir, + temp_dir: temp_dir + } do + # Create vendor module + File.write!(Path.join(vendors_dir, "mikrotik.ex"), """ + defmodule Towerops.Snmp.Profiles.Vendors.Mikrotik do + @behaviour Towerops.Snmp.Profiles.Vendors.Vendor + + @impl true + def profile_names, do: ["mikrotik"] + end + """) + + # Change to temp directory to run task + File.cd!(temp_dir, fn -> + output = + capture_io(fn -> + GenVendorTests.run(["--dry-run"]) + end) + + assert output =~ "Found 1 vendor modules" + assert output =~ "Found 0 existing tests" + assert output =~ "1 vendors need tests" + assert output =~ "Would generate: test/towerops/snmp/profiles/vendors/mikrotik_test.exs" + end) + end + + test "reports no work needed when all vendors have tests", %{ + vendors_dir: vendors_dir, + tests_dir: tests_dir, + temp_dir: temp_dir + } do + # Create vendor module + File.write!(Path.join(vendors_dir, "mikrotik.ex"), """ + defmodule Towerops.Snmp.Profiles.Vendors.Mikrotik do + def profile_names, do: ["mikrotik"] + end + """) + + # Create corresponding test + File.write!(Path.join(tests_dir, "mikrotik_test.exs"), """ + defmodule Towerops.Snmp.Profiles.Vendors.MikrotikTest do + use ExUnit.Case + end + """) + + File.cd!(temp_dir, fn -> + output = + capture_io(fn -> + GenVendorTests.run(["--dry-run"]) + end) + + assert output =~ "All vendor modules have tests!" + end) + end + end + + describe "run/1 without dry-run" do + test "generates test file for vendor without test", %{ + vendors_dir: vendors_dir, + tests_dir: tests_dir, + temp_dir: temp_dir + } do + # Create vendor module with profile_names + File.write!(Path.join(vendors_dir, "cisco.ex"), """ + defmodule Towerops.Snmp.Profiles.Vendors.Cisco do + @behaviour Towerops.Snmp.Profiles.Vendors.Vendor + + @impl true + def profile_names, do: ["cisco-ios", "cisco-nxos"] + end + """) + + File.cd!(temp_dir, fn -> + output = + capture_io(fn -> + GenVendorTests.run([]) + end) + + assert output =~ "Generated: test/towerops/snmp/profiles/vendors/cisco_test.exs" + assert output =~ "Generated 1 test files" + + # Verify test file was created + test_file = Path.join(tests_dir, "cisco_test.exs") + assert File.exists?(test_file) + + content = File.read!(test_file) + assert content =~ "defmodule Towerops.Snmp.Profiles.Vendors.CiscoTest do" + assert content =~ "use Towerops.DataCase, async: true" + assert content =~ "alias Towerops.Snmp.Profiles.Vendors.Cisco" + assert content =~ ~s{profile_names() == ["cisco-ios", "cisco-nxos"]} + assert content =~ "describe \"profile_names/0\" do" + assert content =~ "describe \"detect_hardware/1\" do" + assert content =~ "describe \"wireless_oid_defs/0\" do" + assert content =~ "describe \"discover_wireless_sensors/1\" do" + end) + end + + test "generates tests for multiple vendors", %{vendors_dir: vendors_dir, tests_dir: tests_dir, temp_dir: temp_dir} do + # Create multiple vendor modules + File.write!(Path.join(vendors_dir, "mikrotik.ex"), """ + defmodule Towerops.Snmp.Profiles.Vendors.Mikrotik do + def profile_names, do: ["mikrotik"] + end + """) + + File.write!(Path.join(vendors_dir, "ubiquiti.ex"), """ + defmodule Towerops.Snmp.Profiles.Vendors.Ubiquiti do + def profile_names, do: ["ubnt"] + end + """) + + File.write!(Path.join(vendors_dir, "cisco.ex"), """ + defmodule Towerops.Snmp.Profiles.Vendors.Cisco do + def profile_names, do: ["cisco"] + end + """) + + File.cd!(temp_dir, fn -> + output = + capture_io(fn -> + GenVendorTests.run([]) + end) + + assert output =~ "Generated 3 test files" + assert File.exists?(Path.join(tests_dir, "mikrotik_test.exs")) + assert File.exists?(Path.join(tests_dir, "ubiquiti_test.exs")) + assert File.exists?(Path.join(tests_dir, "cisco_test.exs")) + end) + end + + test "skips vendors that already have tests", %{vendors_dir: vendors_dir, tests_dir: tests_dir, temp_dir: temp_dir} do + # Create vendor modules + File.write!(Path.join(vendors_dir, "mikrotik.ex"), """ + defmodule Towerops.Snmp.Profiles.Vendors.Mikrotik do + def profile_names, do: ["mikrotik"] + end + """) + + File.write!(Path.join(vendors_dir, "cisco.ex"), """ + defmodule Towerops.Snmp.Profiles.Vendors.Cisco do + def profile_names, do: ["cisco"] + end + """) + + # Create test for mikrotik (but not cisco) + File.write!(Path.join(tests_dir, "mikrotik_test.exs"), """ + defmodule Towerops.Snmp.Profiles.Vendors.MikrotikTest do + use ExUnit.Case + end + """) + + File.cd!(temp_dir, fn -> + output = + capture_io(fn -> + GenVendorTests.run([]) + end) + + assert output =~ "Found 2 vendor modules" + assert output =~ "Found 1 existing tests" + assert output =~ "1 vendors need tests" + assert output =~ "Generated 1 test files" + + # Only cisco test should be generated + assert File.exists?(Path.join(tests_dir, "cisco_test.exs")) + + # Mikrotik test should be the old one (not regenerated) + mikrotik_test = File.read!(Path.join(tests_dir, "mikrotik_test.exs")) + assert mikrotik_test =~ "use ExUnit.Case" + refute mikrotik_test =~ "use Towerops.DataCase" + end) + end + + test "excludes registry and vendor files from generation", %{ + vendors_dir: vendors_dir, + tests_dir: tests_dir, + temp_dir: temp_dir + } do + # Create vendor modules + File.write!(Path.join(vendors_dir, "registry.ex"), """ + defmodule Towerops.Snmp.Profiles.Vendors.Registry do + end + """) + + File.write!(Path.join(vendors_dir, "vendor.ex"), """ + defmodule Towerops.Snmp.Profiles.Vendors.Vendor do + end + """) + + File.write!(Path.join(vendors_dir, "mikrotik.ex"), """ + defmodule Towerops.Snmp.Profiles.Vendors.Mikrotik do + def profile_names, do: ["mikrotik"] + end + """) + + File.cd!(temp_dir, fn -> + output = + capture_io(fn -> + GenVendorTests.run([]) + end) + + # Should only count mikrotik as vendor module + assert output =~ "Found 1 vendor modules" + assert output =~ "Generated 1 test files" + + # Only mikrotik test should be generated + assert File.exists?(Path.join(tests_dir, "mikrotik_test.exs")) + refute File.exists?(Path.join(tests_dir, "registry_test.exs")) + refute File.exists?(Path.join(tests_dir, "vendor_test.exs")) + end) + end + + test "handles vendor module without profile_names function", %{ + vendors_dir: vendors_dir, + tests_dir: tests_dir, + temp_dir: temp_dir + } do + # Create vendor module without profile_names + File.write!(Path.join(vendors_dir, "custom.ex"), """ + defmodule Towerops.Snmp.Profiles.Vendors.Custom do + # No profile_names function + end + """) + + File.cd!(temp_dir, fn -> + output = + capture_io(fn -> + GenVendorTests.run([]) + end) + + assert output =~ "Generated 1 test files" + + # Test should be generated with empty profile list + test_file = Path.join(tests_dir, "custom_test.exs") + assert File.exists?(test_file) + + content = File.read!(test_file) + assert content =~ "assert Custom.profile_names() == []" + end) + end + + test "generates proper module name for snake_case files", %{ + vendors_dir: vendors_dir, + tests_dir: tests_dir, + temp_dir: temp_dir + } do + # Create vendor module with snake_case filename + File.write!(Path.join(vendors_dir, "palo_alto.ex"), """ + defmodule Towerops.Snmp.Profiles.Vendors.PaloAlto do + def profile_names, do: ["panos"] + end + """) + + File.cd!(temp_dir, fn -> + _output = + capture_io(fn -> + GenVendorTests.run([]) + end) + + # Test file should be palo_alto_test.exs + test_file = Path.join(tests_dir, "palo_alto_test.exs") + assert File.exists?(test_file) + + # Module should be PaloAltoTest + content = File.read!(test_file) + assert content =~ "defmodule Towerops.Snmp.Profiles.Vendors.PaloAltoTest do" + assert content =~ "alias Towerops.Snmp.Profiles.Vendors.PaloAlto" + end) + end + + test "generates tests with proper expect counts for wireless_oid_defs", %{ + vendors_dir: vendors_dir, + tests_dir: tests_dir, + temp_dir: temp_dir + } do + # Create vendor module with multiple OID defs + File.write!(Path.join(vendors_dir, "test_vendor.ex"), """ + defmodule Towerops.Snmp.Profiles.Vendors.TestVendor do + def profile_names, do: ["test"] + + def wireless_oid_defs do + [ + %{oid: "1.2.3.4.0", sensor_descr: "Temp", sensor_type: "temperature", sensor_unit: "C", sensor_divisor: 1}, + %{oid: "1.2.3.5.0", sensor_descr: "Voltage", sensor_type: "voltage", sensor_unit: "V", sensor_divisor: 1}, + %{oid: "1.2.3.6.0", sensor_descr: "Power", sensor_type: "power", sensor_unit: "W", sensor_divisor: 1} + ] + end + end + """) + + File.cd!(temp_dir, fn -> + capture_io(fn -> + GenVendorTests.run([]) + end) + + test_file = Path.join(tests_dir, "test_vendor_test.exs") + content = File.read!(test_file) + + # Should verify list has >= 1 items (not hardcoded count) + assert content =~ "assert length(defs) >= 1" + end) + end + end +end diff --git a/test/mix/tasks/geoip.import_test.exs b/test/mix/tasks/geoip.import_test.exs new file mode 100644 index 00000000..28233444 --- /dev/null +++ b/test/mix/tasks/geoip.import_test.exs @@ -0,0 +1,312 @@ +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] + + 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 " + 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 " + 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 + + # 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: DateTime.truncate(DateTime.utc_now(), :second), + updated_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + attrs = Map.merge(defaults, Map.new(attrs)) + + %Location{} + |> Ecto.Changeset.change(attrs) + |> Repo.insert!() + end +end diff --git a/test/mix/tasks/import_profiles_test.exs b/test/mix/tasks/import_profiles_test.exs new file mode 100644 index 00000000..009a630c --- /dev/null +++ b/test/mix/tasks/import_profiles_test.exs @@ -0,0 +1,455 @@ +defmodule Mix.Tasks.ImportProfilesTest do + use Towerops.DataCase, async: true + + import ExUnit.CaptureIO + + alias Mix.Tasks.ImportProfiles + alias Towerops.Profiles + alias Towerops.Profiles.DeviceOid + alias Towerops.Profiles.SensorOid + alias Towerops.Repo + + setup do + # Create temporary directory for test YAML files + temp_dir = Path.join(System.tmp_dir!(), "profiles_test_#{System.unique_integer([:positive])}") + detection_dir = Path.join([temp_dir, "os_detection"]) + discovery_dir = Path.join([temp_dir, "os_discovery"]) + File.mkdir_p!(detection_dir) + File.mkdir_p!(discovery_dir) + + on_exit(fn -> + File.rm_rf!(temp_dir) + end) + + {:ok, temp_dir: temp_dir, detection_dir: detection_dir, discovery_dir: discovery_dir} + end + + describe "run/1 with valid profiles" do + test "imports a basic profile from YAML", %{ + detection_dir: detection_dir, + discovery_dir: discovery_dir, + temp_dir: temp_dir + } do + # Create a simple detection file + detection_yaml = Path.join(detection_dir, "mikrotik.yaml") + + File.write!(detection_yaml, """ + os: mikrotik + text: MikroTik + priority: 100 + discovery: + - sysDescr: + - RouterOS + - sysObjectID: + - .1.3.6.1.4.1.14988. + """) + + # Create a simple discovery file + discovery_yaml = Path.join(discovery_dir, "mikrotik.yaml") + + File.write!(discovery_yaml, """ + modules: + os: + serial: SNMPv2-MIB::sysContact.0 + version: SNMPv2-MIB::sysName.0 + sensors: + temperature: + data: + - oid: MIKROTIK-MIB::mtxrHlTemperature.0 + descr: System Temperature + unit: C + divisor: 10 + """) + + output = + capture_io(fn -> + ImportProfiles.run(["--source-path", temp_dir]) + end) + + assert output =~ "✓ mikrotik: created/updated successfully" + assert output =~ "Import complete: 1 succeeded, 0 failed" + + # Verify profile was created + profile = Profiles.get_profile("mikrotik") + assert profile + assert profile.vendor == "MikroTik" + assert profile.priority == 100 + assert profile.enabled == true + + # Verify device OIDs were created + device_oids = Repo.all(from o in DeviceOid, where: o.profile_id == ^profile.id) + assert length(device_oids) == 2 + + serial_oid = Enum.find(device_oids, &(&1.field == "serial_number")) + assert serial_oid.mib_name == "SNMPv2-MIB::sysContact.0" + + version_oid = Enum.find(device_oids, &(&1.field == "firmware_version")) + assert version_oid.mib_name == "SNMPv2-MIB::sysName.0" + + # Verify sensor OIDs were created + sensor_oids = Repo.all(from o in SensorOid, where: o.profile_id == ^profile.id) + assert length(sensor_oids) == 1 + + temp_sensor = List.first(sensor_oids) + assert temp_sensor.sensor_type == "temperature" + assert temp_sensor.mib_name == "MIKROTIK-MIB::mtxrHlTemperature.0" + assert temp_sensor.sensor_descr == "System Temperature" + assert temp_sensor.sensor_unit == "C" + assert temp_sensor.sensor_divisor == 10 + end + + test "updates existing profile", %{detection_dir: detection_dir, temp_dir: temp_dir} do + # Create initial profile + {:ok, profile} = + Profiles.create_profile(%{ + name: "cisco", + vendor: "Cisco Old", + detection_pattern: "old pattern", + priority: 50, + enabled: true + }) + + # Create YAML file with updated info + detection_yaml = Path.join(detection_dir, "cisco.yaml") + + File.write!(detection_yaml, """ + os: cisco + text: Cisco Updated + priority: 200 + discovery: + - sysDescr: + - Cisco IOS + """) + + output = + capture_io(fn -> + ImportProfiles.run(["--source-path", temp_dir]) + end) + + assert output =~ "✓ cisco: created/updated successfully" + + # Verify profile was updated + updated_profile = Profiles.get_profile("cisco") + assert updated_profile.id == profile.id + assert updated_profile.vendor == "Cisco Updated" + assert updated_profile.priority == 200 + end + + test "imports specific profiles only", %{detection_dir: detection_dir, temp_dir: temp_dir} do + # Create multiple detection files + File.write!(Path.join(detection_dir, "mikrotik.yaml"), """ + os: mikrotik + text: MikroTik + priority: 100 + """) + + File.write!(Path.join(detection_dir, "cisco.yaml"), """ + os: cisco + text: Cisco + priority: 100 + """) + + File.write!(Path.join(detection_dir, "ubiquiti.yaml"), """ + os: ubiquiti + text: Ubiquiti + priority: 100 + """) + + output = + capture_io(fn -> + ImportProfiles.run(["--source-path", temp_dir, "--profiles", "mikrotik,cisco"]) + end) + + assert output =~ "Importing 2 specified profiles" + assert output =~ "✓ mikrotik" + assert output =~ "✓ cisco" + refute output =~ "ubiquiti" + + # Verify only specified profiles were created + assert Profiles.get_profile("mikrotik") + assert Profiles.get_profile("cisco") + assert Profiles.get_profile("ubiquiti") == nil + end + + test "handles profile without discovery file", %{detection_dir: detection_dir, temp_dir: temp_dir} do + detection_yaml = Path.join(detection_dir, "generic.yaml") + + File.write!(detection_yaml, """ + os: generic + text: Generic Device + priority: 1 + """) + + output = + capture_io(fn -> + ImportProfiles.run(["--source-path", temp_dir]) + end) + + assert output =~ "✓ generic: created/updated successfully" + + profile = Profiles.get_profile("generic") + assert profile + assert profile.vendor == "Generic Device" + + # Should have no device OIDs or sensor OIDs + assert Repo.aggregate(from(o in DeviceOid, where: o.profile_id == ^profile.id), :count) == 0 + assert Repo.aggregate(from(o in SensorOid, where: o.profile_id == ^profile.id), :count) == 0 + end + + test "extracts detection patterns from sysDescr", %{detection_dir: detection_dir, temp_dir: temp_dir} do + detection_yaml = Path.join(detection_dir, "test.yaml") + + File.write!(detection_yaml, """ + os: test + text: Test Device + discovery: + - sysDescr: + - "Test Pattern 1" + - "Test Pattern 2" + """) + + capture_io(fn -> + ImportProfiles.run(["--source-path", temp_dir]) + end) + + profile = Profiles.get_profile("test") + assert profile.detection_pattern == "Test Pattern 1" + end + + test "extracts detection OID from sysObjectID", %{detection_dir: detection_dir, temp_dir: temp_dir} do + detection_yaml = Path.join(detection_dir, "test.yaml") + + File.write!(detection_yaml, """ + os: test + text: Test Device + discovery: + - sysObjectID: + - ".1.3.6.1.4.1.9." + - ".1.3.6.1.4.1.10." + """) + + capture_io(fn -> + ImportProfiles.run(["--source-path", temp_dir]) + end) + + profile = Profiles.get_profile("test") + assert profile.detection_oid == ".1.3.6.1.4.1.9." + end + + test "imports all standard device OID fields", %{ + detection_dir: detection_dir, + discovery_dir: discovery_dir, + temp_dir: temp_dir + } do + File.write!(Path.join(detection_dir, "test.yaml"), """ + os: test + text: Test Device + """) + + File.write!(Path.join(discovery_dir, "test.yaml"), """ + modules: + os: + serial: DEVICE-MIB::serialNumber.0 + version: DEVICE-MIB::firmwareVersion.0 + hardware: DEVICE-MIB::hardwareModel.0 + lat: DEVICE-MIB::latitude.0 + long: DEVICE-MIB::longitude.0 + features: DEVICE-MIB::features.0 + sysName: SNMPv2-MIB::sysName.0 + """) + + capture_io(fn -> + ImportProfiles.run(["--source-path", temp_dir]) + end) + + profile = Profiles.get_profile("test") + device_oids = Repo.all(from o in DeviceOid, where: o.profile_id == ^profile.id) + + assert length(device_oids) == 7 + + assert Enum.find(device_oids, &(&1.field == "serial_number")).mib_name == "DEVICE-MIB::serialNumber.0" + assert Enum.find(device_oids, &(&1.field == "firmware_version")).mib_name == "DEVICE-MIB::firmwareVersion.0" + assert Enum.find(device_oids, &(&1.field == "hardware")).mib_name == "DEVICE-MIB::hardwareModel.0" + assert Enum.find(device_oids, &(&1.field == "latitude")).mib_name == "DEVICE-MIB::latitude.0" + assert Enum.find(device_oids, &(&1.field == "longitude")).mib_name == "DEVICE-MIB::longitude.0" + assert Enum.find(device_oids, &(&1.field == "features")).mib_name == "DEVICE-MIB::features.0" + assert Enum.find(device_oids, &(&1.field == "sys_name_oid")).mib_name == "SNMPv2-MIB::sysName.0" + end + + test "imports all sensor types", %{detection_dir: detection_dir, discovery_dir: discovery_dir, temp_dir: temp_dir} do + File.write!(Path.join(detection_dir, "test.yaml"), """ + os: test + text: Test Device + """) + + File.write!(Path.join(discovery_dir, "test.yaml"), """ + modules: + sensors: + temperature: + data: + - oid: DEVICE-MIB::temp.0 + descr: Temperature + unit: C + voltage: + data: + - oid: DEVICE-MIB::voltage.0 + descr: Voltage + unit: V + current: + data: + - oid: DEVICE-MIB::current.0 + descr: Current + unit: A + power: + data: + - oid: DEVICE-MIB::power.0 + descr: Power + unit: W + fanspeed: + data: + - oid: DEVICE-MIB::fan.0 + descr: Fan Speed + unit: RPM + """) + + capture_io(fn -> + ImportProfiles.run(["--source-path", temp_dir]) + end) + + profile = Profiles.get_profile("test") + sensor_oids = Repo.all(from o in SensorOid, where: o.profile_id == ^profile.id) + + assert length(sensor_oids) == 5 + + assert Enum.any?(sensor_oids, &(&1.sensor_type == "temperature")) + assert Enum.any?(sensor_oids, &(&1.sensor_type == "voltage")) + assert Enum.any?(sensor_oids, &(&1.sensor_type == "current")) + assert Enum.any?(sensor_oids, &(&1.sensor_type == "power")) + assert Enum.any?(sensor_oids, &(&1.sensor_type == "fanspeed")) + end + + test "skips table sensors with {{ $index }}", %{ + detection_dir: detection_dir, + discovery_dir: discovery_dir, + temp_dir: temp_dir + } do + File.write!(Path.join(detection_dir, "test.yaml"), """ + os: test + text: Test Device + """) + + File.write!(Path.join(discovery_dir, "test.yaml"), """ + modules: + sensors: + temperature: + data: + - oid: DEVICE-MIB::temp.0 + descr: Scalar Temperature + unit: C + - oid: DEVICE-MIB::tempTable + num_oid: .1.3.6.1.4.1.9999.1.2.{{ $index }} + descr: Table Temperature + unit: C + """) + + capture_io(fn -> + ImportProfiles.run(["--source-path", temp_dir]) + end) + + profile = Profiles.get_profile("test") + sensor_oids = Repo.all(from o in SensorOid, where: o.profile_id == ^profile.id) + + # Only scalar sensor should be imported + assert length(sensor_oids) == 1 + assert List.first(sensor_oids).sensor_descr == "Scalar Temperature" + end + + test "replaces existing OIDs when reimporting", %{ + detection_dir: detection_dir, + discovery_dir: discovery_dir, + temp_dir: temp_dir + } do + # Create initial profile with OIDs + {:ok, profile} = Profiles.create_profile(%{name: "test", vendor: "Test", priority: 100, enabled: true}) + {:ok, _} = Profiles.add_device_oid(profile.id, "serial_number", "OLD-MIB::serial.0") + + {:ok, _} = + Profiles.add_sensor_oid(profile.id, %{ + sensor_type: "temperature", + mib_name: "OLD-MIB::temp.0", + sensor_descr: "Old Temp", + sensor_unit: "C", + sensor_divisor: 1 + }) + + # Create new YAML with different OIDs + File.write!(Path.join(detection_dir, "test.yaml"), """ + os: test + text: Test Device + """) + + File.write!(Path.join(discovery_dir, "test.yaml"), """ + modules: + os: + serial: NEW-MIB::serialNumber.0 + sensors: + voltage: + data: + - oid: NEW-MIB::voltage.0 + descr: New Voltage + unit: V + """) + + capture_io(fn -> + ImportProfiles.run(["--source-path", temp_dir]) + end) + + # Old OIDs should be replaced + device_oids = Repo.all(from o in DeviceOid, where: o.profile_id == ^profile.id) + assert length(device_oids) == 1 + assert List.first(device_oids).mib_name == "NEW-MIB::serialNumber.0" + + sensor_oids = Repo.all(from o in SensorOid, where: o.profile_id == ^profile.id) + assert length(sensor_oids) == 1 + assert List.first(sensor_oids).mib_name == "NEW-MIB::voltage.0" + assert List.first(sensor_oids).sensor_type == "voltage" + end + end + + describe "run/1 error handling" do + test "raises error when source directory doesn't exist" do + assert_raise RuntimeError, "Source directory not found: /nonexistent/path", fn -> + capture_io(fn -> + ImportProfiles.run(["--source-path", "/nonexistent/path"]) + end) + end + end + + test "reports failed imports", %{detection_dir: detection_dir, temp_dir: temp_dir} do + # Create invalid YAML + File.write!(Path.join(detection_dir, "invalid.yaml"), """ + this is not: [valid yaml: that will parse + """) + + output = + capture_io([capture_prompt: false], fn -> + ImportProfiles.run(["--source-path", temp_dir]) + end) + + assert output =~ "✗ invalid" or output =~ "Import complete: 0 succeeded, 1 failed" + assert output =~ "Import complete: 0 succeeded, 1 failed" + end + + test "uses default source path when not provided" do + # This will fail because priv/profiles doesn't have test data, + # but we can verify it tries to use the right path + output = + capture_io(fn -> + ImportProfiles.run([]) + end) + + # Should try to load from default location + assert output =~ "Discovering YAML files" or output =~ "Found 0 YAML files" or output =~ "succeeded" + end + end +end diff --git a/test/mix/tasks/oban.cancel_stuck_discovery_test.exs b/test/mix/tasks/oban.cancel_stuck_discovery_test.exs new file mode 100644 index 00000000..454bcc7a --- /dev/null +++ b/test/mix/tasks/oban.cancel_stuck_discovery_test.exs @@ -0,0 +1,206 @@ +defmodule Mix.Tasks.Oban.CancelStuckDiscoveryTest do + use Towerops.DataCase, async: true + + import ExUnit.CaptureIO + import Mix.Tasks.Oban.CancelStuckDiscovery, only: [run: 1] + + alias Towerops.Repo + + describe "run/1" do + test "cancels stuck discovery jobs in scheduled state" do + # Insert a scheduled discovery job + {:ok, job} = + %{device_id: Ecto.UUID.generate()} + |> Oban.Job.new(worker: "Towerops.Workers.DiscoveryWorker", queue: :discovery) + |> Repo.insert() + + assert job.state == "available" + + # Change state to scheduled + job = Repo.update!(Ecto.Changeset.change(job, state: "scheduled")) + + output = capture_io(fn -> run([]) end) + + # Verify job was cancelled + updated_job = Repo.reload(job) + assert updated_job.state == "cancelled" + assert output =~ "Cancelled 1 stuck discovery jobs" + end + + test "cancels stuck discovery jobs in retryable state" do + {:ok, job} = + %{device_id: Ecto.UUID.generate()} + |> Oban.Job.new(worker: "Towerops.Workers.DiscoveryWorker", queue: :discovery) + |> Repo.insert() + + # Change state to retryable + job = Repo.update!(Ecto.Changeset.change(job, state: "retryable")) + + output = capture_io(fn -> run([]) end) + + updated_job = Repo.reload(job) + assert updated_job.state == "cancelled" + assert output =~ "Cancelled 1 stuck discovery jobs" + end + + test "cancels stuck discovery jobs in executing state" do + {:ok, job} = + %{device_id: Ecto.UUID.generate()} + |> Oban.Job.new(worker: "Towerops.Workers.DiscoveryWorker", queue: :discovery) + |> Repo.insert() + + # Change state to executing + job = + Repo.update!( + Ecto.Changeset.change(job, + state: "executing", + attempted_at: DateTime.utc_now() + ) + ) + + output = capture_io(fn -> run([]) end) + + updated_job = Repo.reload(job) + assert updated_job.state == "cancelled" + assert output =~ "Cancelled 1 stuck discovery jobs" + end + + test "cancels multiple stuck discovery jobs" do + device_id1 = Ecto.UUID.generate() + device_id2 = Ecto.UUID.generate() + device_id3 = Ecto.UUID.generate() + + # Create multiple stuck jobs + {:ok, job1} = + %{device_id: device_id1} + |> Oban.Job.new(worker: "Towerops.Workers.DiscoveryWorker", queue: :discovery) + |> Repo.insert() + + {:ok, job2} = + %{device_id: device_id2} + |> Oban.Job.new(worker: "Towerops.Workers.DiscoveryWorker", queue: :discovery) + |> Repo.insert() + + {:ok, job3} = + %{device_id: device_id3} + |> Oban.Job.new(worker: "Towerops.Workers.DiscoveryWorker", queue: :discovery) + |> Repo.insert() + + # Make them stuck + Repo.update!(Ecto.Changeset.change(job1, state: "scheduled")) + Repo.update!(Ecto.Changeset.change(job2, state: "retryable")) + Repo.update!(Ecto.Changeset.change(job3, state: "executing", attempted_at: DateTime.utc_now())) + + output = capture_io(fn -> run([]) end) + + # Verify all were cancelled + assert Repo.reload(job1).state == "cancelled" + assert Repo.reload(job2).state == "cancelled" + assert Repo.reload(job3).state == "cancelled" + assert output =~ "Cancelled 3 stuck discovery jobs" + end + + test "does not cancel completed discovery jobs" do + {:ok, job} = + %{device_id: Ecto.UUID.generate()} + |> Oban.Job.new(worker: "Towerops.Workers.DiscoveryWorker", queue: :discovery) + |> Repo.insert() + + # Change state to completed + job = Repo.update!(Ecto.Changeset.change(job, state: "completed")) + + output = capture_io(fn -> run([]) end) + + # Job should remain completed + updated_job = Repo.reload(job) + assert updated_job.state == "completed" + assert output =~ "Cancelled 0 stuck discovery jobs" + end + + test "does not cancel cancelled discovery jobs" do + {:ok, job} = + %{device_id: Ecto.UUID.generate()} + |> Oban.Job.new(worker: "Towerops.Workers.DiscoveryWorker", queue: :discovery) + |> Repo.insert() + + # Change state to cancelled + job = Repo.update!(Ecto.Changeset.change(job, state: "cancelled")) + + output = capture_io(fn -> run([]) end) + + # Job should remain cancelled + updated_job = Repo.reload(job) + assert updated_job.state == "cancelled" + assert output =~ "Cancelled 0 stuck discovery jobs" + end + + test "does not cancel jobs from other workers" do + {:ok, discovery_job} = + %{device_id: Ecto.UUID.generate()} + |> Oban.Job.new(worker: "Towerops.Workers.DiscoveryWorker", queue: :discovery) + |> Repo.insert() + + {:ok, poller_job} = + %{device_id: Ecto.UUID.generate()} + |> Oban.Job.new(worker: "Towerops.Workers.DevicePollerWorker", queue: :pollers) + |> Repo.insert() + + # Make both stuck + discovery_job = Repo.update!(Ecto.Changeset.change(discovery_job, state: "scheduled")) + poller_job = Repo.update!(Ecto.Changeset.change(poller_job, state: "scheduled")) + + output = capture_io(fn -> run([]) end) + + # Only discovery job should be cancelled + assert Repo.reload(discovery_job).state == "cancelled" + assert Repo.reload(poller_job).state == "scheduled" + assert output =~ "Cancelled 1 stuck discovery jobs" + end + + test "reports zero cancellations when no stuck jobs exist" do + output = capture_io(fn -> run([]) end) + assert output =~ "Cancelled 0 stuck discovery jobs" + end + + test "handles jobs with no device_id in args" do + {:ok, job} = + %{some_other_field: "value"} + |> Oban.Job.new(worker: "Towerops.Workers.DiscoveryWorker", queue: :discovery) + |> Repo.insert() + + job = Repo.update!(Ecto.Changeset.change(job, state: "scheduled")) + + output = capture_io(fn -> run([]) end) + + updated_job = Repo.reload(job) + assert updated_job.state == "cancelled" + assert output =~ "Cancelled 1 stuck discovery jobs" + end + + test "continues on cancellation failure for one job" do + # Create two stuck jobs + {:ok, job1} = + %{device_id: Ecto.UUID.generate()} + |> Oban.Job.new(worker: "Towerops.Workers.DiscoveryWorker", queue: :discovery) + |> Repo.insert() + + {:ok, job2} = + %{device_id: Ecto.UUID.generate()} + |> Oban.Job.new(worker: "Towerops.Workers.DiscoveryWorker", queue: :discovery) + |> Repo.insert() + + job1 = Repo.update!(Ecto.Changeset.change(job1, state: "scheduled")) + job2 = Repo.update!(Ecto.Changeset.change(job2, state: "scheduled")) + + # Delete job1 to simulate a cancellation failure (job not found) + Repo.delete!(job1) + + output = capture_io(fn -> run([]) end) + + # job2 should still be cancelled + assert Repo.reload(job2).state == "cancelled" + # Should report 1 successful cancellation (job2 only) + assert output =~ "Cancelled 1 stuck discovery jobs" + end + end +end diff --git a/test/mix/tasks/populate_english_test.exs b/test/mix/tasks/populate_english_test.exs new file mode 100644 index 00000000..797901ad --- /dev/null +++ b/test/mix/tasks/populate_english_test.exs @@ -0,0 +1,247 @@ +defmodule Mix.Tasks.PopulateEnglishTest do + use ExUnit.Case, async: false + + import ExUnit.CaptureIO + import Mix.Tasks.PopulateEnglish, only: [run: 1] + + setup do + # Create temporary directory for test files + temp_dir = Path.join(System.tmp_dir!(), "populate_english_test_#{System.unique_integer([:positive])}") + po_dir = Path.join([temp_dir, "priv", "gettext", "en", "LC_MESSAGES"]) + File.mkdir_p!(po_dir) + + on_exit(fn -> + File.rm_rf!(temp_dir) + end) + + {:ok, temp_dir: temp_dir, po_dir: po_dir} + end + + describe "run/1" do + test "populates empty msgstr with msgid", %{po_dir: po_dir} do + po_file = Path.join(po_dir, "test.po") + + File.write!(po_file, """ + msgid "Hello" + msgstr "" + + msgid "Goodbye" + msgstr "" + """) + + # Change to temp directory so the task finds the files + File.cd!(Path.dirname(Path.dirname(Path.dirname(Path.dirname(po_dir)))), fn -> + capture_io(fn -> run([]) end) + end) + + content = File.read!(po_file) + assert content =~ ~s(msgid "Hello"\nmsgstr "Hello") + assert content =~ ~s(msgid "Goodbye"\nmsgstr "Goodbye") + end + + test "preserves existing non-empty msgstr", %{po_dir: po_dir} do + po_file = Path.join(po_dir, "test.po") + + File.write!(po_file, """ + msgid "Hello" + msgstr "Hola" + + msgid "Goodbye" + msgstr "" + """) + + File.cd!(Path.dirname(Path.dirname(Path.dirname(Path.dirname(po_dir)))), fn -> + capture_io(fn -> run([]) end) + end) + + content = File.read!(po_file) + assert content =~ ~s(msgid "Hello"\nmsgstr "Hola") + assert content =~ ~s(msgid "Goodbye"\nmsgstr "Goodbye") + end + + test "handles multiline msgid and msgstr", %{po_dir: po_dir} do + po_file = Path.join(po_dir, "test.po") + + File.write!(po_file, """ + msgid "" + "This is a " + "multiline string" + msgstr "" + """) + + File.cd!(Path.dirname(Path.dirname(Path.dirname(Path.dirname(po_dir)))), fn -> + capture_io(fn -> run([]) end) + end) + + content = File.read!(po_file) + assert content =~ ~s(msgstr ""\n"This is a "\n"multiline string") + end + + test "preserves comments and metadata", %{po_dir: po_dir} do + po_file = Path.join(po_dir, "test.po") + + File.write!(po_file, """ + # Comment line + ## Another comment + + msgid "Hello" + msgstr "" + """) + + File.cd!(Path.dirname(Path.dirname(Path.dirname(Path.dirname(po_dir)))), fn -> + capture_io(fn -> run([]) end) + end) + + content = File.read!(po_file) + assert content =~ "# Comment line" + assert content =~ "## Another comment" + assert content =~ ~s(msgid "Hello"\nmsgstr "Hello") + end + + test "skips plural forms", %{po_dir: po_dir} do + po_file = Path.join(po_dir, "test.po") + + File.write!(po_file, """ + msgid "One item" + msgid_plural "%{count} items" + msgstr[0] "" + msgstr[1] "" + + msgid "Regular message" + msgstr "" + """) + + File.cd!(Path.dirname(Path.dirname(Path.dirname(Path.dirname(po_dir)))), fn -> + capture_io(fn -> run([]) end) + end) + + content = File.read!(po_file) + # Plural forms should not be modified + assert content =~ ~s(msgstr[0] "") + assert content =~ ~s(msgstr[1] "") + # Regular message should be populated + assert content =~ ~s(msgid "Regular message"\nmsgstr "Regular message") + end + + test "handles empty msgid", %{po_dir: po_dir} do + po_file = Path.join(po_dir, "test.po") + + File.write!(po_file, """ + msgid "" + msgstr "" + + msgid "Hello" + msgstr "" + """) + + File.cd!(Path.dirname(Path.dirname(Path.dirname(Path.dirname(po_dir)))), fn -> + capture_io(fn -> run([]) end) + end) + + content = File.read!(po_file) + # Empty msgid should not be populated + lines = String.split(content, "\n") + empty_msgid_index = Enum.find_index(lines, &(&1 == ~s(msgid ""))) + assert Enum.at(lines, empty_msgid_index + 1) == ~s(msgstr "") + # Non-empty msgid should be populated + assert content =~ ~s(msgid "Hello"\nmsgstr "Hello") + end + + test "handles escaped characters in strings", %{po_dir: po_dir} do + po_file = Path.join(po_dir, "test.po") + + File.write!(po_file, """ + msgid "Line one\\nLine two" + msgstr "" + + msgid "Tab\\there" + msgstr "" + + msgid "Quote \\"here\\"" + msgstr "" + """) + + File.cd!(Path.dirname(Path.dirname(Path.dirname(Path.dirname(po_dir)))), fn -> + capture_io(fn -> run([]) end) + end) + + content = File.read!(po_file) + assert content =~ ~s(msgid "Line one\\nLine two"\nmsgstr "Line one\\nLine two") + assert content =~ ~s(msgid "Tab\\there"\nmsgstr "Tab\\there") + assert content =~ ~s(msgid "Quote \\"here\\""\nmsgstr "Quote \\"here\\"") + end + + test "processes multiple .po files", %{po_dir: po_dir} do + po_file1 = Path.join(po_dir, "test1.po") + po_file2 = Path.join(po_dir, "test2.po") + + File.write!(po_file1, """ + msgid "Hello" + msgstr "" + """) + + File.write!(po_file2, """ + msgid "Goodbye" + msgstr "" + """) + + File.cd!(Path.dirname(Path.dirname(Path.dirname(Path.dirname(po_dir)))), fn -> + output = capture_io(fn -> run([]) end) + assert output =~ "2 English translation file(s)" + end) + + assert File.read!(po_file1) =~ ~s(msgid "Hello"\nmsgstr "Hello") + assert File.read!(po_file2) =~ ~s(msgid "Goodbye"\nmsgstr "Goodbye") + end + + test "reports no changes when file already populated", %{po_dir: po_dir} do + po_file = Path.join(po_dir, "test.po") + + File.write!(po_file, """ + msgid "Hello" + msgstr "Hello" + """) + + File.cd!(Path.dirname(Path.dirname(Path.dirname(Path.dirname(po_dir)))), fn -> + output = capture_io(fn -> run([]) end) + assert output =~ "No changes: test.po" + end) + end + + test "reports updated when file is modified", %{po_dir: po_dir} do + po_file = Path.join(po_dir, "test.po") + + File.write!(po_file, """ + msgid "Hello" + msgstr "" + """) + + File.cd!(Path.dirname(Path.dirname(Path.dirname(Path.dirname(po_dir)))), fn -> + output = capture_io(fn -> run([]) end) + assert output =~ "Updated: test.po" + end) + end + + test "handles missing directory gracefully" do + temp_dir = Path.join(System.tmp_dir!(), "nonexistent_#{System.unique_integer([:positive])}") + File.mkdir_p!(temp_dir) + + try do + File.cd!(temp_dir, fn -> + assert_raise Mix.Error, fn -> + capture_io(fn -> run([]) end) + end + end) + after + File.rm_rf!(temp_dir) + end + end + + test "reports when no .po files found", %{po_dir: po_dir} do + File.cd!(Path.dirname(Path.dirname(Path.dirname(Path.dirname(po_dir)))), fn -> + output = capture_io(fn -> run([]) end) + assert output =~ "No .po files found" + end) + end + end +end