defmodule Towerops.Integration.DiscoveryParityTest do @moduledoc """ Integration tests for SNMP discovery/polling parity. Runs against a real SNMP device (MikroTik RB5009UG+S+) using the actual SnmpKit library (not mocks) and verifies our discovery pipeline captures everything that snmpwalk/snmpget returns. These tests are excluded from normal `mix test` runs via the :integration tag. Run with: mix test test/integration/discovery_parity_test.exs --include integration """ use Towerops.DataCase, async: false import Towerops.DevicesFixtures alias Towerops.Snmp alias Towerops.Snmp.Client alias Towerops.Snmp.Discovery alias Towerops.Snmp.Profiles.Base alias Towerops.Snmp.Profiles.Dynamic require Logger @moduletag :integration # Test device: MikroTik RB5009UG+S+ running RouterOS @test_ip "204.110.191.1" @test_community "testlocal" @test_version "2c" @client_opts [ ip: @test_ip, community: @test_community, version: @test_version, port: 161, timeout: 10_000 ] # Standard OIDs for ground truth comparisons @sys_uptime_oid "1.3.6.1.2.1.1.3.0" @if_index_oid "1.3.6.1.2.1.2.2.1.1" @if_descr_oid "1.3.6.1.2.1.2.2.1.2" @if_type_oid "1.3.6.1.2.1.2.2.1.3" @if_speed_oid "1.3.6.1.2.1.2.2.1.5" @if_phys_address_oid "1.3.6.1.2.1.2.2.1.6" @if_admin_status_oid "1.3.6.1.2.1.2.2.1.7" @if_oper_status_oid "1.3.6.1.2.1.2.2.1.8" @if_name_oid "1.3.6.1.2.1.31.1.1.1.1" @if_high_speed_oid "1.3.6.1.2.1.31.1.1.1.15" @if_alias_oid "1.3.6.1.2.1.31.1.1.1.18" @if_in_octets_oid "1.3.6.1.2.1.2.2.1.10" @if_out_octets_oid "1.3.6.1.2.1.2.2.1.16" @entity_sensor_type_oid "1.3.6.1.2.1.99.1.1.1.1" @entity_phys_descr_oid "1.3.6.1.2.1.47.1.1.1.1.2" # IP-MIB OIDs @ip_ad_ent_addr_oid "1.3.6.1.2.1.4.20.1.1" @ip_ad_ent_if_index_oid "1.3.6.1.2.1.4.20.1.2" @ip_ad_ent_net_mask_oid "1.3.6.1.2.1.4.20.1.3" # HR-MIB OIDs @hr_processor_load_oid "1.3.6.1.2.1.25.3.3.1.2" @hr_storage_descr_oid "1.3.6.1.2.1.25.2.3.1.3" @hr_storage_size_oid "1.3.6.1.2.1.25.2.3.1.5" @hr_storage_used_oid "1.3.6.1.2.1.25.2.3.1.6" # MikroTik enterprise OIDs @mtxr_gauge_name_oid "1.3.6.1.4.1.14988.1.1.3.100.1.2" @mtxr_gauge_value_oid "1.3.6.1.4.1.14988.1.1.3.100.1.3" @mtxr_serial_oid "1.3.6.1.4.1.14988.1.1.4.1.0" @mtxr_firmware_oid "1.3.6.1.4.1.14988.1.1.4.4.0" @mtxr_model_oid "1.3.6.1.4.1.14988.1.1.7.8.0" @mtxr_dhcp_lease_count_oid "1.3.6.1.4.1.14988.1.1.6.1.0" @mtxr_hl_voltage_oid "1.3.6.1.4.1.14988.1.1.3.11.0" setup do # Override SNMP adapter from mock to real SnmpKit original_adapter = Application.get_env(:towerops, :snmp_adapter) Application.put_env(:towerops, :snmp_adapter, SnmpKit) # Check device connectivity with a quick sysUpTime GET reachable = case Client.get(@client_opts, @sys_uptime_oid) do {:ok, _} -> true {:error, reason} -> Logger.warning("Test device #{@test_ip} unreachable: #{inspect(reason)}") false end on_exit(fn -> if original_adapter do Application.put_env(:towerops, :snmp_adapter, original_adapter) else Application.delete_env(:towerops, :snmp_adapter) end end) %{reachable: reachable} end describe "raw SNMP connectivity" do @tag timeout: 30_000 test "GET sysUpTime.0 returns integer", %{reachable: reachable} do skip_unless_reachable(reachable) {:ok, uptime} = Client.get(@client_opts, @sys_uptime_oid) assert is_integer(uptime), "Expected integer uptime, got: #{inspect(uptime)}" assert uptime >= 0 end @tag timeout: 60_000 test "WALK ifIndex returns non-empty results", %{reachable: reachable} do skip_unless_reachable(reachable) {:ok, results} = Client.walk(@client_opts, @if_index_oid) assert map_size(results) > 0, "Expected at least one interface" for {_oid, value} <- results do assert is_integer(value) and value > 0, "ifIndex values must be positive integers, got: #{inspect(value)}" end end end describe "system info parity" do @tag timeout: 60_000 test "discover_system_info returns all SNMPv2-MIB fields", %{reachable: reachable} do skip_unless_reachable(reachable) {:ok, system_info} = Base.discover_system_info(@client_opts) # All 6 standard system fields should be present and populated assert String.length(system_info.sys_descr) > 0 assert String.starts_with?(system_info.sys_object_id, "1.3.6.1") assert String.length(system_info.sys_name) > 0 assert is_integer(system_info.sys_uptime) assert system_info.sys_uptime >= 0 # Contact and location should be present (may be empty strings) assert Map.has_key?(system_info, :sys_contact) assert Map.has_key?(system_info, :sys_location) Logger.info( "System: descr=#{String.slice(system_info.sys_descr, 0..80)}, " <> "name=#{system_info.sys_name}, oid=#{system_info.sys_object_id}, " <> "contact=#{system_info.sys_contact}, location=#{system_info.sys_location}" ) end @tag timeout: 60_000 test "raw SNMP values match discovery output", %{reachable: reachable} do skip_unless_reachable(reachable) {:ok, raw_descr} = Client.get(@client_opts, "1.3.6.1.2.1.1.1.0") {:ok, raw_name} = Client.get(@client_opts, "1.3.6.1.2.1.1.5.0") {:ok, raw_contact} = Client.get(@client_opts, "1.3.6.1.2.1.1.4.0") {:ok, raw_location} = Client.get(@client_opts, "1.3.6.1.2.1.1.6.0") {:ok, raw_object_id} = Client.get(@client_opts, "1.3.6.1.2.1.1.2.0") {:ok, system_info} = Base.discover_system_info(@client_opts) assert byte_size(raw_descr) > 0 assert byte_size(system_info.sys_descr) > 0 assert system_info.sys_name == to_string(raw_name) # sysObjectID comes as OID list from raw SNMP, discovery converts to dotted string valid? = is_list(raw_object_id) or is_binary(raw_object_id) assert valid? assert String.starts_with?(system_info.sys_object_id, "1.3.6.1") # Contact and location should match raw values assert system_info.sys_contact == to_string(raw_contact) assert system_info.sys_location == to_string(raw_location) end end describe "profile selection" do @tag timeout: 60_000 test "selects RouterOS YAML profile for MikroTik device", %{reachable: reachable} do skip_unless_reachable(reachable) {:ok, system_info} = Base.discover_system_info(@client_opts) profile = Discovery.select_profile(system_info, @client_opts) # This device has sysObjectID .1.3.6.1.4.1.14988.1 which should match routeros profile assert match?({:yaml, %{name: _}}, profile), "Expected YAML profile match for MikroTik device, got: #{inspect(profile)}" {:yaml, yaml_profile} = profile Logger.info("Selected YAML profile: #{yaml_profile.name}") # Verify profile has sensor definitions (routeros.yaml has temperature, voltage, etc.) # credo:disable-for-next-line Jump.CredoChecks.ConditionalAssertion assert Map.has_key?(yaml_profile, :sensors) or Map.has_key?(yaml_profile, :table_sensor_oids), "RouterOS profile should have sensor definitions" end end describe "interface discovery parity" do @tag timeout: 120_000 test "interface count matches raw ifIndex walk", %{reachable: reachable} do skip_unless_reachable(reachable) {:ok, raw_if_indices} = Client.walk(@client_opts, @if_index_oid) expected_count = map_size(raw_if_indices) {:ok, interfaces} = Base.discover_interfaces(@client_opts) assert length(interfaces) == expected_count, "Discovery found #{length(interfaces)} interfaces, " <> "raw walk found #{expected_count}" Logger.info("Interface count matches: #{expected_count}") end @tag timeout: 120_000 test "ifIndex values match raw walk exactly", %{reachable: reachable} do skip_unless_reachable(reachable) {:ok, raw_if_indices} = Client.walk(@client_opts, @if_index_oid) {:ok, interfaces} = Base.discover_interfaces(@client_opts) raw_indices = raw_if_indices |> Map.values() |> MapSet.new() discovered_indices = MapSet.new(interfaces, & &1.if_index) assert MapSet.equal?(raw_indices, discovered_indices), "ifIndex mismatch - raw: #{inspect(MapSet.to_list(raw_indices))}, " <> "discovered: #{inspect(MapSet.to_list(discovered_indices))}" end @tag timeout: 120_000 test "all ifTable fields populated per interface", %{reachable: reachable} do skip_unless_reachable(reachable) # Get raw ground truth for all standard IF-MIB fields {:ok, raw_descrs} = Client.walk(@client_opts, @if_descr_oid) {:ok, raw_types} = Client.walk(@client_opts, @if_type_oid) {:ok, raw_admin} = Client.walk(@client_opts, @if_admin_status_oid) {:ok, raw_oper} = Client.walk(@client_opts, @if_oper_status_oid) {:ok, interfaces} = Base.discover_interfaces(@client_opts) # Count matches for all fields assert length(interfaces) == map_size(raw_descrs), "ifDescr count mismatch: #{length(interfaces)} vs #{map_size(raw_descrs)}" assert length(interfaces) == map_size(raw_types), "ifType count mismatch: #{length(interfaces)} vs #{map_size(raw_types)}" assert length(interfaces) == map_size(raw_admin), "ifAdminStatus count mismatch: #{length(interfaces)} vs #{map_size(raw_admin)}" assert length(interfaces) == map_size(raw_oper), "ifOperStatus count mismatch: #{length(interfaces)} vs #{map_size(raw_oper)}" for iface <- interfaces do # ifDescr must be present on every interface assert is_binary(iface.if_descr), "Interface #{iface.if_index} missing if_descr" assert String.length(iface.if_descr) > 0, "Interface #{iface.if_index} has empty if_descr" # ifType must be a positive integer (IANAifType) assert is_integer(iface.if_type) and iface.if_type > 0, "Interface #{iface.if_index} invalid ifType: #{inspect(iface.if_type)}" # Admin/oper status - device returns string atoms or integers valid_admin = [1, 2, 3, "up", "down", "testing"] valid_oper = [ 1, 2, 3, 4, 5, 6, 7, "up", "down", "testing", "unknown", "dormant", "notPresent", "lowerLayerDown" ] assert iface.if_admin_status in valid_admin, "Interface #{iface.if_index} invalid admin status: #{inspect(iface.if_admin_status)}" assert iface.if_oper_status in valid_oper, "Interface #{iface.if_index} invalid oper status: #{inspect(iface.if_oper_status)}" end end @tag timeout: 120_000 test "ifXTable extended fields populated", %{reachable: reachable} do skip_unless_reachable(reachable) # Ground truth for ifXTable {:ok, raw_names} = Client.walk(@client_opts, @if_name_oid) {:ok, raw_aliases} = Client.walk(@client_opts, @if_alias_oid) {:ok, raw_high_speed} = Client.walk(@client_opts, @if_high_speed_oid) {:ok, raw_speeds} = Client.walk(@client_opts, @if_speed_oid) {:ok, interfaces} = Base.discover_interfaces(@client_opts) # ifName should be discovered for all interfaces assert map_size(raw_names) == length(interfaces), "Raw ifName count (#{map_size(raw_names)}) != interface count (#{length(interfaces)})" interfaces_with_name = Enum.count(interfaces, & &1[:if_name]) Logger.info( "ifXTable: #{interfaces_with_name}/#{length(interfaces)} have ifName, " <> "#{map_size(raw_aliases)} have ifAlias, " <> "#{map_size(raw_high_speed)} have ifHighSpeed, " <> "#{map_size(raw_speeds)} have ifSpeed" ) assert interfaces_with_name == map_size(raw_names), "Discovery missed #{map_size(raw_names) - interfaces_with_name} ifName entries" # Verify ifPhysAddress (MAC address) is populated where available {:ok, raw_macs} = Client.walk(@client_opts, @if_phys_address_oid) interfaces_with_mac = Enum.count(interfaces, fn iface -> iface[:if_phys_address] != nil and iface[:if_phys_address] != "" end) # Count non-empty MACs from raw walk (PPP interfaces have empty MACs) raw_nonempty_macs = Enum.count(raw_macs, fn {_oid, val} -> val != "" and val != nil and val != <<0, 0, 0, 0, 0, 0>> end) Logger.info("MAC addresses: #{interfaces_with_mac} discovered, #{raw_nonempty_macs} non-empty in raw walk") end end describe "sensor discovery parity" do @tag timeout: 120_000 test "ENTITY-SENSOR-MIB sensor count matches raw walk", %{reachable: reachable} do skip_unless_reachable(reachable) case Client.walk(@client_opts, @entity_sensor_type_oid) do {:ok, raw_sensors} when map_size(raw_sensors) > 0 -> expected_count = map_size(raw_sensors) {:ok, sensors} = Base.discover_sensors(@client_opts) Logger.info("ENTITY-SENSOR-MIB: raw=#{expected_count}, discovered=#{length(sensors)}") assert length(sensors) == expected_count, "Sensor count mismatch: raw=#{expected_count}, discovered=#{length(sensors)}" {:ok, raw_sensors} when map_size(raw_sensors) == 0 -> Logger.info("Device has no ENTITY-SENSOR-MIB sensors (expected for MikroTik)") {:ok, sensors} = Base.discover_sensors(@client_opts) assert sensors == [] {:error, _reason} -> Logger.info("ENTITY-SENSOR-MIB not supported (expected for MikroTik)") {:ok, _sensors} = Base.discover_sensors(@client_opts) end end @tag timeout: 120_000 test "MikroTik gauge sensors discovered via YAML profile", %{reachable: reachable} do skip_unless_reachable(reachable) # Ground truth: raw MikroTik gauge table {:ok, raw_gauge_names} = Client.walk(@client_opts, @mtxr_gauge_name_oid) {:ok, raw_gauge_values} = Client.walk(@client_opts, @mtxr_gauge_value_oid) Logger.info( "Raw MikroTik gauges: #{map_size(raw_gauge_names)} names, " <> "#{map_size(raw_gauge_values)} values" ) for {oid, name} <- raw_gauge_names do index = oid |> String.split(".") |> List.last() value_oid = @mtxr_gauge_value_oid <> ".#{index}" value = Map.get(raw_gauge_values, value_oid) Logger.info(" Gauge #{index}: name=#{inspect(name)}, value=#{inspect(value)}") end # Now check that YAML profile discovery finds these {:ok, system_info} = Base.discover_system_info(@client_opts) profile = Discovery.select_profile(system_info, @client_opts) assert match?({:yaml, _}, profile), "Expected YAML profile for MikroTik" {:yaml, yaml_profile} = profile {:ok, yaml_sensors} = Dynamic.discover_sensors(yaml_profile, @client_opts) # Log all discovered sensors for comparison Logger.info("YAML profile discovered #{length(yaml_sensors)} sensors:") for sensor <- yaml_sensors do Logger.info( " #{sensor.sensor_type}/#{sensor.sensor_index}: " <> "#{sensor.sensor_descr} = #{inspect(sensor[:last_value])} " <> "(oid=#{sensor.sensor_oid}, unit=#{sensor.sensor_unit}, div=#{sensor.sensor_divisor})" ) end # MikroTik gauges should produce at least one temperature sensor temp_sensors = Enum.filter(yaml_sensors, &(&1.sensor_type == "temperature")) assert temp_sensors != [], "Expected MikroTik temperature sensors (mtxrGaugeTable has cpu-temperature)" # Verify raw gauge value matches a discovered sensor if map_size(raw_gauge_names) > 0 do gauge_sensor_oids = yaml_sensors |> Enum.map(& &1.sensor_oid) |> Enum.filter(&String.contains?(&1, "14988.1.1.3.100")) assert gauge_sensor_oids != [], "Expected sensors from mtxrGaugeTable (1.3.6.1.4.1.14988.1.1.3.100)" end end @tag timeout: 120_000 test "MikroTik voltage sensors discovered", %{reachable: reachable} do skip_unless_reachable(reachable) # Raw voltage value case Client.get(@client_opts, @mtxr_hl_voltage_oid) do {:ok, raw_voltage} -> Logger.info("Raw mtxrHlVoltage: #{inspect(raw_voltage)}") {:ok, system_info} = Base.discover_system_info(@client_opts) {:yaml, yaml_profile} = Discovery.select_profile(system_info, @client_opts) {:ok, sensors} = Dynamic.discover_sensors(yaml_profile, @client_opts) voltage_sensors = Enum.filter(sensors, &(&1.sensor_type == "voltage")) Logger.info("Voltage sensors discovered: #{length(voltage_sensors)}") for vs <- voltage_sensors do Logger.info(" #{vs.sensor_descr}: value=#{inspect(vs[:last_value])}, oid=#{vs.sensor_oid}") end assert voltage_sensors != [], "Expected voltage sensors from MikroTik" {:error, reason} -> Logger.warning("mtxrHlVoltage not available: #{inspect(reason)}") end end @tag timeout: 120_000 test "MikroTik count sensors discovered (DHCP, firewall)", %{reachable: reachable} do skip_unless_reachable(reachable) # Check raw DHCP lease count case Client.get(@client_opts, @mtxr_dhcp_lease_count_oid) do {:ok, raw_dhcp} -> Logger.info("Raw mtxrDHCPLeaseCount: #{inspect(raw_dhcp)}") {:error, reason} -> Logger.warning("mtxrDHCPLeaseCount not available: #{inspect(reason)}") end {:ok, system_info} = Base.discover_system_info(@client_opts) {:yaml, yaml_profile} = Discovery.select_profile(system_info, @client_opts) {:ok, sensors} = Dynamic.discover_sensors(yaml_profile, @client_opts) count_sensors = Enum.filter(sensors, &(&1.sensor_type == "count")) Logger.info("Count sensors discovered: #{length(count_sensors)}") for cs <- count_sensors do Logger.info(" #{cs.sensor_descr}: value=#{inspect(cs[:last_value])}, oid=#{cs.sensor_oid}") end # MikroTik should have DHCP lease count and/or firewall connection counts assert count_sensors != [], "Expected count sensors (DHCP leases, firewall connections)" end @tag timeout: 120_000 test "all discovered sensor fields have valid types", %{reachable: reachable} do skip_unless_reachable(reachable) {:ok, system_info} = Base.discover_system_info(@client_opts) profile = Discovery.select_profile(system_info, @client_opts) sensors = case profile do {:yaml, yaml_profile} -> {:ok, s} = Dynamic.discover_sensors(yaml_profile, @client_opts) s Base -> {:ok, s} = Base.discover_sensors(@client_opts) s end for sensor <- sensors do assert is_binary(sensor.sensor_type), "sensor_type must be string: #{inspect(sensor)}" assert is_binary(sensor.sensor_index), "sensor_index must be string: #{inspect(sensor)}" assert is_binary(sensor.sensor_oid), "sensor_oid must be string: #{inspect(sensor)}" # credo:disable-for-next-line Jump.CredoChecks.ConditionalAssertion assert String.starts_with?(sensor.sensor_oid, "1.3.6.1") or String.starts_with?(sensor.sensor_oid, ".1.3.6.1"), "sensor_oid must be valid OID: #{sensor.sensor_oid}" assert is_binary(sensor.sensor_descr), "sensor_descr must be string: #{inspect(sensor)}" assert is_binary(sensor.sensor_unit), "sensor_unit must be string: #{inspect(sensor)}" assert is_number(sensor.sensor_divisor), "sensor_divisor must be number: #{inspect(sensor)}" assert sensor.sensor_divisor > 0, "sensor_divisor must be positive: #{sensor.sensor_divisor}" end # Log sensor type breakdown by_type = Enum.group_by(sensors, & &1.sensor_type) for {type, group} <- by_type do Logger.info(" #{type}: #{length(group)} sensors") end Logger.info("Validated #{length(sensors)} sensors across #{map_size(by_type)} types") end end describe "device identification parity" do @tag timeout: 120_000 test "MikroTik-specific OIDs provide serial, firmware, model", %{reachable: reachable} do skip_unless_reachable(reachable) # Raw ground truth from MikroTik enterprise OIDs {:ok, raw_serial} = Client.get(@client_opts, @mtxr_serial_oid) {:ok, raw_firmware} = Client.get(@client_opts, @mtxr_firmware_oid) {:ok, raw_model} = Client.get(@client_opts, @mtxr_model_oid) Logger.info( "Raw MikroTik: serial=#{inspect(raw_serial)}, " <> "firmware=#{inspect(raw_firmware)}, model=#{inspect(raw_model)}" ) assert is_binary(raw_serial) and String.length(raw_serial) > 0 assert is_binary(raw_firmware) and String.length(raw_firmware) > 0 assert is_binary(raw_model) and String.length(raw_model) > 0 # Now verify discovery captures these {:ok, system_info} = Base.discover_system_info(@client_opts) {:yaml, yaml_profile} = Discovery.select_profile(system_info, @client_opts) device_info = Dynamic.identify_device(yaml_profile, @client_opts, system_info) Logger.info( "Discovery device_info: manufacturer=#{inspect(device_info[:manufacturer])}, " <> "model=#{inspect(device_info[:model])}, " <> "firmware=#{inspect(device_info[:firmware_version])}, " <> "serial=#{inspect(device_info[:serial_number])}" ) assert byte_size(device_info.manufacturer) > 0 assert byte_size(device_info.model) > 0 # Serial and firmware should come from MikroTik-specific OIDs if device_info[:serial_number] do assert String.length(device_info.serial_number) > 0 Logger.info("Serial number captured: #{device_info.serial_number}") else Logger.warning("Serial number NOT captured from MikroTik OIDs (parity gap)") end if device_info[:firmware_version] do assert String.length(device_info.firmware_version) > 0 Logger.info("Firmware version captured: #{device_info.firmware_version}") else Logger.warning("Firmware version NOT captured from MikroTik OIDs (parity gap)") end end end describe "ENTITY-MIB physical entities" do @tag timeout: 120_000 test "physical entity descriptions discovered", %{reachable: reachable} do skip_unless_reachable(reachable) {:ok, raw_entities} = Client.walk(@client_opts, @entity_phys_descr_oid) Logger.info("ENTITY-MIB physical entities: #{map_size(raw_entities)}") for {oid, descr} <- raw_entities do index = oid |> String.split(".") |> List.last() Logger.info(" Entity #{index}: #{inspect(descr)}") end # Verify our discovery can read these too {:ok, entities} = Base.discover_physical_entities(@client_opts) refute Enum.empty?(entities) Logger.info("Discovery found #{length(entities)} physical entities") # MikroTik has ENTITY-MIB entries (RouterOS info, USB controllers, etc.) if map_size(raw_entities) > 0 do assert entities != [], "Expected physical entities - raw walk found #{map_size(raw_entities)}" end end end describe "secondary discovery parity" do @tag timeout: 120_000 test "VLANs from Q-BRIDGE-MIB", %{reachable: reachable} do skip_unless_reachable(reachable) {:ok, vlans} = Base.discover_vlans(@client_opts) refute Enum.empty?(vlans) Logger.info("Discovered #{length(vlans)} VLANs (MikroTik typically returns 0)") end @tag timeout: 120_000 test "IP addresses match raw IP-MIB walk", %{reachable: reachable} do skip_unless_reachable(reachable) # Raw ground truth {:ok, raw_ip_addrs} = Client.walk(@client_opts, @ip_ad_ent_addr_oid) {:ok, raw_ip_if_index} = Client.walk(@client_opts, @ip_ad_ent_if_index_oid) {:ok, raw_ip_masks} = Client.walk(@client_opts, @ip_ad_ent_net_mask_oid) Logger.info( "Raw IP-MIB: #{map_size(raw_ip_addrs)} addresses, " <> "#{map_size(raw_ip_if_index)} if-indices, #{map_size(raw_ip_masks)} netmasks" ) for {oid, addr} <- raw_ip_addrs do index = String.replace_prefix(oid, "1.3.6.1.2.1.4.20.1.1.", "") if_idx_oid = "1.3.6.1.2.1.4.20.1.2.#{index}" mask_oid = "1.3.6.1.2.1.4.20.1.3.#{index}" if_idx = Map.get(raw_ip_if_index, if_idx_oid) mask = Map.get(raw_ip_masks, mask_oid) Logger.info(" #{inspect(addr)} (ifIndex=#{inspect(if_idx)}, mask=#{inspect(mask)})") end # Discovery output {:ok, ip_addresses} = Base.discover_all_ip_addresses(@client_opts) # Should discover at least as many IPs as raw walk assert length(ip_addresses) >= map_size(raw_ip_addrs), "Discovery found #{length(ip_addresses)} IPs, raw walk found #{map_size(raw_ip_addrs)}" # Target IP must be among discovered addresses all_ips = Enum.map(ip_addresses, & &1[:ip_address]) Logger.info("Discovered IPs: #{inspect(all_ips)}") assert @test_ip in all_ips, "Target IP #{@test_ip} not found among discovered IPs: #{inspect(all_ips)}" end @tag timeout: 120_000 test "processors match raw HR-MIB walk", %{reachable: reachable} do skip_unless_reachable(reachable) {:ok, raw_proc_loads} = Client.walk(@client_opts, @hr_processor_load_oid) Logger.info("Raw HR-MIB processors: #{map_size(raw_proc_loads)}") for {oid, load} <- raw_proc_loads do index = oid |> String.split(".") |> List.last() Logger.info(" Processor #{index}: load=#{load}%") end {:ok, processors} = Base.discover_processors(@client_opts) # Count should match raw walk assert length(processors) == map_size(raw_proc_loads), "Processor count mismatch: discovered=#{length(processors)}, " <> "raw=#{map_size(raw_proc_loads)}" for proc <- processors do if proc[:load] do assert is_number(proc.load) assert proc.load >= 0 and proc.load <= 100, "CPU load out of range: #{proc.load}" end end Logger.info("Discovered #{length(processors)} processors (matches raw walk)") end @tag timeout: 120_000 test "storage matches raw HR-MIB walk (disk types only)", %{reachable: reachable} do skip_unless_reachable(reachable) # Raw HR-MIB includes ALL storage types: RAM, FixedDisk, Other, etc. {:ok, raw_storage_descr} = Client.walk(@client_opts, @hr_storage_descr_oid) {:ok, raw_storage_size} = Client.walk(@client_opts, @hr_storage_size_oid) {:ok, raw_storage_used} = Client.walk(@client_opts, @hr_storage_used_oid) {:ok, raw_storage_types} = Client.walk(@client_opts, "1.3.6.1.2.1.25.2.3.1.2") Logger.info("Raw HR-MIB storage entries (all types): #{map_size(raw_storage_descr)}") # Disk-type OIDs that discover_storage keeps (RAM and Other are filtered out) disk_type_oids = [ # fixedDisk [1, 3, 6, 1, 2, 1, 25, 2, 1, 4], # removableDisk [1, 3, 6, 1, 2, 1, 25, 2, 1, 5], # floppyDisk [1, 3, 6, 1, 2, 1, 25, 2, 1, 6], # compactDisc [1, 3, 6, 1, 2, 1, 25, 2, 1, 7], # ramDisk [1, 3, 6, 1, 2, 1, 25, 2, 1, 8], # flashMemory [1, 3, 6, 1, 2, 1, 25, 2, 1, 9], # networkDisk [1, 3, 6, 1, 2, 1, 25, 2, 1, 10] ] raw_disk_count = Enum.count(raw_storage_types, fn {_oid, type_val} -> type_val in disk_type_oids end) for {oid, descr} <- raw_storage_descr do index = oid |> String.split(".") |> List.last() type_oid = "1.3.6.1.2.1.25.2.3.1.2.#{index}" size_oid = "1.3.6.1.2.1.25.2.3.1.5.#{index}" used_oid = "1.3.6.1.2.1.25.2.3.1.6.#{index}" type_val = Map.get(raw_storage_types, type_oid) is_disk = type_val in disk_type_oids size = Map.get(raw_storage_size, size_oid) used = Map.get(raw_storage_used, used_oid) Logger.info( " #{inspect(descr)}: type=#{inspect(type_val)}, disk=#{is_disk}, " <> "size=#{inspect(size)}, used=#{inspect(used)}" ) end {:ok, storage} = Base.discover_storage(@client_opts) # discover_storage only returns disk-type entries (not RAM or Other) assert length(storage) == raw_disk_count, "Storage count mismatch: discovered=#{length(storage)}, " <> "raw disk-type=#{raw_disk_count} (total raw=#{map_size(raw_storage_descr)})" for entry <- storage do assert entry[:total_bytes] != nil, "Storage entry missing total_bytes" assert is_number(entry.total_bytes) assert entry.total_bytes >= 0 end Logger.info( "Discovered #{length(storage)} disk storage entries (raw has #{raw_disk_count} disk, #{map_size(raw_storage_descr)} total)" ) end end describe "full discovery pipeline" do @tag timeout: 300_000 test "discover_device saves complete data to DB", %{reachable: reachable} do skip_unless_reachable(reachable) device = device_fixture(%{ ip_address: @test_ip, snmp_enabled: true, snmp_version: @test_version, snmp_community: @test_community, snmp_port: 161 }) {:ok, snmp_device} = Discovery.discover_device(device) # Verify SNMP device record has all fields assert snmp_device.id assert byte_size(snmp_device.sys_descr) > 0 assert byte_size(snmp_device.sys_object_id) > 0 assert byte_size(snmp_device.sys_name) > 0 assert byte_size(snmp_device.manufacturer) > 0 assert byte_size(snmp_device.model) > 0 assert snmp_device.last_discovery_at Logger.info( "Discovery: manufacturer=#{snmp_device.manufacturer}, " <> "model=#{snmp_device.model}, firmware=#{snmp_device.firmware_version}, " <> "serial=#{snmp_device.serial_number}" ) # Verify interfaces persisted and match raw walk count interfaces = Snmp.list_interfaces(snmp_device.id) {:ok, raw_if_indices} = Client.walk(@client_opts, @if_index_oid) assert length(interfaces) == map_size(raw_if_indices), "Persisted #{length(interfaces)} interfaces, raw walk has #{map_size(raw_if_indices)}" Logger.info("Persisted #{length(interfaces)} interfaces (matches raw walk)") # Verify sensors persisted (MikroTik should have profile-specific sensors) sensors = Snmp.list_sensors(snmp_device.id) Logger.info("Persisted #{length(sensors)} sensors") if sensors != [] do by_type = Enum.group_by(sensors, & &1.sensor_type) for {type, group} <- by_type do Logger.info(" #{type}: #{length(group)} sensors") for s <- group do Logger.info(" #{s.sensor_descr} (#{s.sensor_oid})") end end end # Verify processors persisted processors = Snmp.list_processors(snmp_device.id) {:ok, raw_proc} = Client.walk(@client_opts, @hr_processor_load_oid) assert length(processors) == map_size(raw_proc), "Persisted #{length(processors)} processors, raw walk has #{map_size(raw_proc)}" Logger.info("Persisted #{length(processors)} processors (matches raw walk)") # Verify storage persisted (only disk-type entries, not RAM/Other) storage = Snmp.list_storage(snmp_device.id) refute Enum.empty?(storage), "Expected at least one disk storage entry" Logger.info("Persisted #{length(storage)} disk storage entries") # Verify IP addresses persisted ip_addresses = Snmp.list_ip_addresses(snmp_device.id) {:ok, raw_ips} = Client.walk(@client_opts, @ip_ad_ent_addr_oid) Logger.info("Persisted #{length(ip_addresses)} IP addresses (raw walk has #{map_size(raw_ips)})") end @tag timeout: 300_000 test "rediscovery updates without creating duplicates", %{reachable: reachable} do skip_unless_reachable(reachable) device = device_fixture(%{ ip_address: @test_ip, snmp_enabled: true, snmp_version: @test_version, snmp_community: @test_community, snmp_port: 161 }) # First discovery {:ok, snmp_device_1} = Discovery.discover_device(device) interfaces_1 = Snmp.list_interfaces(snmp_device_1.id) sensors_1 = Snmp.list_sensors(snmp_device_1.id) processors_1 = Snmp.list_processors(snmp_device_1.id) storage_1 = Snmp.list_storage(snmp_device_1.id) # Second discovery (should update, not duplicate) {:ok, snmp_device_2} = Discovery.discover_device(device) interfaces_2 = Snmp.list_interfaces(snmp_device_2.id) sensors_2 = Snmp.list_sensors(snmp_device_2.id) processors_2 = Snmp.list_processors(snmp_device_2.id) storage_2 = Snmp.list_storage(snmp_device_2.id) # Same SNMP device record (upserted, not duplicated) assert snmp_device_1.id == snmp_device_2.id # Same counts (synced, not appended) assert length(interfaces_1) == length(interfaces_2), "Interface count changed: #{length(interfaces_1)} -> #{length(interfaces_2)}" assert length(sensors_1) == length(sensors_2), "Sensor count changed: #{length(sensors_1)} -> #{length(sensors_2)}" assert length(processors_1) == length(processors_2), "Processor count changed: #{length(processors_1)} -> #{length(processors_2)}" assert length(storage_1) == length(storage_2), "Storage count changed: #{length(storage_1)} -> #{length(storage_2)}" Logger.info( "Rediscovery preserved: #{length(interfaces_2)} interfaces, " <> "#{length(sensors_2)} sensors, #{length(processors_2)} processors, " <> "#{length(storage_2)} storage" ) end end describe "polling after discovery" do @tag timeout: 300_000 test "sensor OIDs return numeric values after discovery", %{reachable: reachable} do skip_unless_reachable(reachable) device = device_fixture(%{ ip_address: @test_ip, snmp_enabled: true, snmp_version: @test_version, snmp_community: @test_community, snmp_port: 161 }) {:ok, snmp_device} = Discovery.discover_device(device) sensors = Snmp.list_sensors(snmp_device.id) if Enum.empty?(sensors) do Logger.warning("No sensors to poll - skipping sensor polling test") else # Poll each sensor's OID and verify we get a readable value results = Enum.map(sensors, fn sensor -> case Client.get(@client_opts, sensor.sensor_oid) do {:ok, value} when is_number(value) -> {:numeric, sensor.sensor_descr, value} {:ok, value} -> {:non_numeric, sensor.sensor_descr, value} {:error, reason} -> {:error, sensor.sensor_descr, reason} end end) numeric = Enum.count(results, &(elem(&1, 0) == :numeric)) non_numeric = Enum.count(results, &(elem(&1, 0) == :non_numeric)) errors = Enum.count(results, &(elem(&1, 0) == :error)) Logger.info( "Polled #{length(sensors)} sensors: " <> "#{numeric} numeric, #{non_numeric} non-numeric, #{errors} errors" ) for {status, descr, val} <- results do Logger.info(" [#{status}] #{descr}: #{inspect(val)}") end assert numeric > 0, "Expected at least one sensor to return numeric data" end end @tag timeout: 120_000 test "interface counters (ifInOctets/ifOutOctets) are readable", %{reachable: reachable} do skip_unless_reachable(reachable) {:ok, in_octets} = Client.walk(@client_opts, @if_in_octets_oid) {:ok, out_octets} = Client.walk(@client_opts, @if_out_octets_oid) assert map_size(in_octets) > 0, "Expected ifInOctets data" assert map_size(out_octets) > 0, "Expected ifOutOctets data" # Counter values should be non-negative integers for {oid, value} <- in_octets do assert is_integer(value) and value >= 0, "ifInOctets #{oid} invalid: #{inspect(value)}" end for {oid, value} <- out_octets do assert is_integer(value) and value >= 0, "ifOutOctets #{oid} invalid: #{inspect(value)}" end Logger.info("Interface counters: #{map_size(in_octets)} in, #{map_size(out_octets)} out") end end # Helper to skip tests when device is unreachable. defp skip_unless_reachable(true), do: :ok defp skip_unless_reachable(false) do flunk("Device #{@test_ip} unreachable - skipping") end end