towerops/test/integration/discovery_parity_test.exs
2026-06-15 11:15:46 -05:00

978 lines
36 KiB
Elixir

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