978 lines
36 KiB
Elixir
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
|