moar tests

This commit is contained in:
Graham McIntire 2026-01-06 15:43:54 -06:00
parent 23a0ba68b9
commit c3303cc194
No known key found for this signature in database
14 changed files with 1947 additions and 9 deletions

View file

@ -46,4 +46,5 @@ config :towerops, ToweropsWeb.Endpoint,
# Use mocks for testing
config :towerops,
ping_module: Towerops.Monitoring.PingMock,
poller_module: Towerops.Snmp.PollerMock
poller_module: Towerops.Snmp.PollerMock,
snmp_adapter: Towerops.Snmp.SnmpMock

View file

@ -37,7 +37,7 @@ defmodule Towerops.Snmp.Client do
target = build_target(opts)
snmp_opts = build_snmp_opts(opts)
case SnmpKit.get(target, oid, snmp_opts) do
case snmp_adapter().get(target, oid, snmp_opts) do
{:ok, value} ->
{:ok, extract_snmp_value(value)}
@ -84,7 +84,7 @@ defmodule Towerops.Snmp.Client do
# Get each OID and collect results
results =
Enum.map(oids, fn oid ->
case SnmpKit.get(target, oid, snmp_opts) do
case snmp_adapter().get(target, oid, snmp_opts) do
{:ok, value} -> {:ok, extract_snmp_value(value)}
error -> error
end
@ -116,11 +116,11 @@ defmodule Towerops.Snmp.Client do
target = build_target(opts)
snmp_opts = build_snmp_opts(opts)
case SnmpKit.walk(target, start_oid, snmp_opts) do
case snmp_adapter().walk(target, start_oid, snmp_opts) do
{:ok, results} when is_list(results) ->
# snmpkit returns a list of maps, convert to OID -> value map
walked_data =
Map.new(results, fn %{oid: oid, value: value} -> {oid, value} end)
Map.new(results, fn %{oid: oid, value: value} -> {oid, extract_snmp_value(value)} end)
{:ok, walked_data}
@ -168,11 +168,11 @@ defmodule Towerops.Snmp.Client do
snmp_opts = Keyword.put(snmp_opts, :max_repetitions, max_repetitions)
case SnmpKit.get_bulk(target, start_oid, snmp_opts) do
case snmp_adapter().get_bulk(target, start_oid, snmp_opts) do
{:ok, results} when is_list(results) ->
# snmpkit returns a list of maps, convert to OID -> value map
bulk_data =
Map.new(results, fn %{oid: oid, value: value} -> {oid, value} end)
Map.new(results, fn %{oid: oid, value: value} -> {oid, extract_snmp_value(value)} end)
{:ok, bulk_data}
@ -227,6 +227,10 @@ defmodule Towerops.Snmp.Client do
# Private functions
defp snmp_adapter do
Application.get_env(:towerops, :snmp_adapter, SnmpKit)
end
defp build_target(opts) do
Keyword.fetch!(opts, :ip)
end

View file

@ -134,7 +134,8 @@ defmodule Towerops.Snmp.Discovery do
def discover_all(org_id) do
equipment_list =
Equipment
|> where([e], e.organization_id == ^org_id and e.snmp_enabled == true)
|> join(:inner, [e], s in assoc(e, :site))
|> where([e, s], s.organization_id == ^org_id and e.snmp_enabled == true)
|> Repo.all()
Logger.info("Starting SNMP discovery for #{length(equipment_list)} devices in org #{org_id}")

View file

@ -266,6 +266,7 @@ defmodule Towerops.Snmp.Profiles.Base do
end
defp parse_integer(value) when is_integer(value), do: value
defp parse_integer(""), do: nil
defp parse_integer(value) when is_binary(value), do: String.to_integer(value)
defp parse_integer(_), do: nil

View file

@ -0,0 +1,15 @@
defmodule Towerops.Snmp.SnmpBehaviour do
@moduledoc """
Behaviour for SNMP operations.
Allows mocking SnmpKit in tests using Mox.
"""
@type target :: String.t()
@type oid :: String.t() | [non_neg_integer()]
@type snmp_opts :: keyword()
@type snmp_value :: term()
@callback get(target(), oid(), snmp_opts()) :: {:ok, snmp_value()} | {:error, term()}
@callback walk(target(), oid(), snmp_opts()) :: {:ok, [map()]} | {:error, term()}
@callback get_bulk(target(), oid(), snmp_opts()) :: {:ok, [map()]} | {:error, term()}
end

View file

@ -68,11 +68,13 @@ defmodule Towerops.MixProject do
{:bandit, "~> 1.5"},
{:ecto_psql_extras, "~> 0.6"},
{:mox, "~> 1.0", only: :test},
{:stream_data, "~> 1.1", only: :test},
{:styler, "~> 1.10", only: [:dev, :test], runtime: false},
{:mix_test_watch, "~> 1.0", only: [:dev, :test], runtime: false},
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:sobelow, "~> 0.14.1", only: [:dev, :test], runtime: false}
{:sobelow, "~> 0.14.1", only: [:dev, :test], runtime: false},
{:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false}
]
end

View file

@ -28,6 +28,7 @@
"libcluster": {:hex, :libcluster, "3.5.0", "5ee4cfde4bdf32b2fef271e33ce3241e89509f4344f6c6a8d4069937484866ba", [:mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ebf6561fcedd765a4cd43b4b8c04b1c87f4177b5fb3cbdfe40a780499d72f743"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
"mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"},
"mix_test_watch": {:hex, :mix_test_watch, "1.4.0", "d88bcc4fbe3198871266e9d2f00cd8ae350938efbb11d3fa1da091586345adbb", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "2b4693e17c8ead2ef56d4f48a0329891e8c2d0d73752c0f09272a2b17dc38d1b"},
"mox": {:hex, :mox, "1.2.0", "a2cd96b4b80a3883e3100a221e8adc1b98e4c3a332a8fc434c39526babafd5b3", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "c7b92b3cc69ee24a7eeeaf944cd7be22013c52fcb580c1f33f50845ec821089a"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
@ -47,6 +48,7 @@
"req": {:hex, :req, "0.5.16", "99ba6a36b014458e52a8b9a0543bfa752cb0344b2a9d756651db1281d4ba4450", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "974a7a27982b9b791df84e8f6687d21483795882a7840e8309abdbe08bb06f09"},
"snmpkit": {:hex, :snmpkit, "1.3.19", "b09c38cea619a0a67d4fb0f3bfa3b9b749d42c400c2e7bd9446fef82f0d728a7", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}, {:yaml_elixir, "~> 2.9", [hex: :yaml_elixir, repo: "hexpm", optional: true]}], "hexpm", "b05c1f3911204c4d781234187a6f1350c369fcffe2058a85b49735b1259eb973"},
"sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"},
"stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"},
"styler": {:hex, :styler, "1.10.0", "343f1f7bb19a8893c2841a9ae90665b68d2edf6cc37b964a5099e60c78815c2e", [:mix], [], "hexpm", "6a78876611869466139e63722df4cbbb56b18a842d88c19f23ca844d914ad91a"},
"swoosh": {:hex, :swoosh, "1.19.9", "4eb2c471b8cf06adbdcaa1d57a0ad53c0ed9348ce8586a06cc491f9f0dbcb553", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "516898263a64925c31723c56bc7999a26e97b04e869707f681f4c9bca7ee1688"},
"table_rex": {:hex, :table_rex, "4.1.0", "fbaa8b1ce154c9772012bf445bfb86b587430fb96f3b12022d3f35ee4a68c918", [:mix], [], "hexpm", "95932701df195d43bc2d1c6531178fc8338aa8f38c80f098504d529c43bc2601"},

View file

@ -7,5 +7,16 @@ ExUnit.configure(exclude: [:integration])
# Define mocks for testing
Mox.defmock(Towerops.Monitoring.PingMock, for: Towerops.Monitoring.PingBehaviour)
Mox.defmock(Towerops.Snmp.PollerMock, for: Towerops.Snmp.PollerBehaviour)
Mox.defmock(Towerops.Snmp.SnmpMock, for: Towerops.Snmp.SnmpBehaviour)
# Define stub module for SNMP mock (returns errors for all calls)
defmodule Towerops.Snmp.SnmpMockStub do
@moduledoc false
@behaviour Towerops.Snmp.SnmpBehaviour
def get(_target, _oid, _opts), do: {:error, :timeout}
def walk(_target, _oid, _opts), do: {:error, :timeout}
def get_bulk(_target, _oid, _opts), do: {:error, :timeout}
end
Ecto.Adapters.SQL.Sandbox.mode(Towerops.Repo, :manual)

View file

@ -0,0 +1,395 @@
defmodule Towerops.Snmp.ClientTest do
use ExUnit.Case, async: true
use ExUnitProperties
import Mox
alias Towerops.Snmp.Client
alias Towerops.Snmp.SnmpMock
# Set up mocks
setup :verify_on_exit!
@test_opts [ip: "192.168.1.1", community: "public", version: "2c"]
describe "get/2" do
test "returns ok tuple with extracted value on success" do
expect(SnmpMock, :get, fn "192.168.1.1", "1.3.6.1.2.1.1.3.0", _opts ->
{:ok, {:timeticks, 12_345}}
end)
assert {:ok, 12_345} = Client.get(@test_opts, "1.3.6.1.2.1.1.3.0")
end
test "extracts value from different SNMP types" do
expect(SnmpMock, :get, fn _, _, _ -> {:ok, {:octet_string, "test"}} end)
assert {:ok, "test"} = Client.get(@test_opts, "1.3.6.1.2.1.1.1.0")
end
test "returns error tuple on failure" do
expect(SnmpMock, :get, fn _, _, _ -> {:error, :timeout} end)
assert {:error, :timeout} = Client.get(@test_opts, "1.3.6.1.2.1.1.3.0")
end
test "handles no_such_object error" do
expect(SnmpMock, :get, fn _, _, _ -> {:error, :no_such_object} end)
assert {:error, :no_such_object} = Client.get(@test_opts, "1.3.6.1.99.99.99")
end
test "handles no_such_instance error" do
expect(SnmpMock, :get, fn _, _, _ -> {:error, :no_such_instance} end)
assert {:error, :no_such_instance} = Client.get(@test_opts, "1.3.6.1.2.1.1.99.0")
end
test "handles end_of_mib_view error" do
expect(SnmpMock, :get, fn _, _, _ -> {:error, :end_of_mib_view} end)
assert {:error, :end_of_mib_view} = Client.get(@test_opts, "1.3.6.1.9.9.9.9")
end
test "accepts list OID format" do
expect(SnmpMock, :get, fn _, [1, 3, 6, 1, 2, 1, 1, 3, 0], _ ->
{:ok, {:timeticks, 12_345}}
end)
assert {:ok, 12_345} = Client.get(@test_opts, [1, 3, 6, 1, 2, 1, 1, 3, 0])
end
property "extracts all SNMP value types correctly" do
check all(
{type, value} <-
StreamData.one_of([
StreamData.tuple({
StreamData.constant(:integer),
StreamData.integer()
}),
StreamData.tuple({
StreamData.constant(:octet_string),
StreamData.string(:alphanumeric)
}),
StreamData.tuple({
StreamData.constant(:counter32),
StreamData.integer(0..4_294_967_295)
}),
StreamData.tuple({
StreamData.constant(:gauge32),
StreamData.integer(0..4_294_967_295)
})
])
) do
expect(SnmpMock, :get, fn _, _, _ -> {:ok, {type, value}} end)
assert {:ok, ^value} = Client.get(@test_opts, "1.3.6.1.2.1.1.1.0")
end
end
end
describe "get_multiple/2" do
test "returns ok with list of values when all succeed" do
expect(SnmpMock, :get, 2, fn _, oid, _ ->
case oid do
"1.3.6.1.2.1.1.1.0" -> {:ok, {:octet_string, "Router"}}
"1.3.6.1.2.1.1.3.0" -> {:ok, {:timeticks, 12_345}}
end
end)
oids = ["1.3.6.1.2.1.1.1.0", "1.3.6.1.2.1.1.3.0"]
assert {:ok, ["Router", 12_345]} = Client.get_multiple(@test_opts, oids)
end
test "returns error when any OID fails" do
expect(SnmpMock, :get, 2, fn _, oid, _ ->
case oid do
"1.3.6.1.2.1.1.1.0" -> {:ok, {:octet_string, "Router"}}
"1.3.6.1.2.1.99.99.0" -> {:error, :no_such_object}
end
end)
oids = ["1.3.6.1.2.1.1.1.0", "1.3.6.1.2.1.99.99.0"]
assert {:error, :partial_failure} = Client.get_multiple(@test_opts, oids)
end
property "handles variable number of OIDs" do
check all(count <- StreamData.integer(1..10)) do
expect(SnmpMock, :get, count, fn _, _, _ ->
{:ok, {:integer, 42}}
end)
oids = Enum.map(1..count, fn i -> "1.3.6.1.2.1.1.#{i}.0" end)
assert {:ok, values} = Client.get_multiple(@test_opts, oids)
assert length(values) == count
assert Enum.all?(values, &(&1 == 42))
end
end
end
describe "walk/2" do
test "returns map of OID to value on success" do
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.2.2.1.2", _ ->
{:ok,
[
%{oid: "1.3.6.1.2.1.2.2.1.2.1", value: "eth0"},
%{oid: "1.3.6.1.2.1.2.2.1.2.2", value: "eth1"}
]}
end)
assert {:ok, results} = Client.walk(@test_opts, "1.3.6.1.2.1.2.2.1.2")
assert is_map(results)
assert results["1.3.6.1.2.1.2.2.1.2.1"] == "eth0"
assert results["1.3.6.1.2.1.2.2.1.2.2"] == "eth1"
end
test "returns error tuple on failure" do
expect(SnmpMock, :walk, fn _, _, _ -> {:error, :timeout} end)
assert {:error, :timeout} = Client.walk(@test_opts, "1.3.6.1.2.1.2.2.1.2")
end
test "handles empty walk results" do
expect(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
assert {:ok, results} = Client.walk(@test_opts, "1.3.6.1.2.1.99.99")
assert results == %{}
end
property "converts list of results to map" do
check all(count <- StreamData.integer(1..20)) do
results =
Enum.map(1..count, fn i ->
%{oid: "1.3.6.1.2.1.2.2.1.2.#{i}", value: "eth#{i}"}
end)
expect(SnmpMock, :walk, fn _, _, _ -> {:ok, results} end)
assert {:ok, walked} = Client.walk(@test_opts, "1.3.6.1.2.1.2.2.1.2")
assert map_size(walked) == count
end
end
end
describe "get_bulk/3" do
test "returns map of OID to value on success" do
expect(SnmpMock, :get_bulk, fn _, "1.3.6.1.2.1.2.2.1.10", opts ->
assert opts[:max_repetitions] == 10
{:ok,
[
%{oid: "1.3.6.1.2.1.2.2.1.10.1", value: 1000},
%{oid: "1.3.6.1.2.1.2.2.1.10.2", value: 2000}
]}
end)
assert {:ok, results} = Client.get_bulk(@test_opts, "1.3.6.1.2.1.2.2.1.10", max_repetitions: 10)
assert is_map(results)
assert results["1.3.6.1.2.1.2.2.1.10.1"] == 1000
end
test "uses default max_repetitions of 10" do
expect(SnmpMock, :get_bulk, fn _, _, opts ->
assert opts[:max_repetitions] == 10
{:ok, []}
end)
Client.get_bulk(@test_opts, "1.3.6.1.2.1.2.2.1.10")
end
test "uses custom max_repetitions" do
expect(SnmpMock, :get_bulk, fn _, _, opts ->
assert opts[:max_repetitions] == 50
{:ok, []}
end)
Client.get_bulk(@test_opts, "1.3.6.1.2.1.2.2.1.10", max_repetitions: 50)
end
test "returns error tuple on failure" do
expect(SnmpMock, :get_bulk, fn _, _, _ -> {:error, :timeout} end)
assert {:error, :timeout} = Client.get_bulk(@test_opts, "1.3.6.1.2.1.2.2.1.10")
end
end
describe "test_connection/1" do
test "returns success when sysUpTime is retrieved" do
expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.3.0", _ ->
{:ok, {:timeticks, 12_345}}
end)
assert {:ok, "Connection successful"} = Client.test_connection(@test_opts)
end
test "returns error when connection fails" do
expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.3.0", _ ->
{:error, :timeout}
end)
assert {:error, :timeout} = Client.test_connection(@test_opts)
end
test "returns error when host is unreachable" do
expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.3.0", _ ->
{:error, :network_unreachable}
end)
assert {:error, :network_unreachable} = Client.test_connection(@test_opts)
end
end
describe "connection options" do
property "valid connection options include all required fields" do
check all(
ip <- StreamData.string(:alphanumeric, min_length: 7, max_length: 15),
community <- StreamData.string(:alphanumeric, min_length: 1, max_length: 32),
version <- StreamData.member_of(["1", "2c"]),
port <- StreamData.integer(1..65_535),
timeout <- StreamData.integer(1000..30_000)
) do
opts = [
ip: ip,
community: community,
version: version,
port: port,
timeout: timeout
]
# Should have all required fields
assert Keyword.has_key?(opts, :ip)
assert Keyword.has_key?(opts, :community)
assert Keyword.has_key?(opts, :version)
# Optional fields with valid values
assert opts[:port] > 0 and opts[:port] <= 65_535
assert opts[:timeout] >= 1000
end
end
test "minimal valid options" do
opts = [ip: "192.168.1.1", community: "public", version: "2c"]
assert Keyword.has_key?(opts, :ip)
assert Keyword.has_key?(opts, :community)
assert Keyword.has_key?(opts, :version)
end
end
describe "OID formats" do
property "accepts string OIDs" do
check all(oid_parts <- StreamData.list_of(StreamData.positive_integer(), min_length: 3, max_length: 10)) do
oid = Enum.join(oid_parts, ".")
# Valid OID format
assert oid =~ ~r/^[\d.]+$/
assert is_binary(oid)
end
end
property "accepts list OIDs" do
check all(oid_list <- StreamData.list_of(StreamData.non_negative_integer(), min_length: 3, max_length: 10)) do
# All elements are integers
assert Enum.all?(oid_list, &is_integer/1)
# All elements are non-negative
assert Enum.all?(oid_list, &(&1 >= 0))
end
end
end
describe "IP address formats" do
property "IP addresses are strings" do
check all(octets <- StreamData.list_of(StreamData.integer(0..255), length: 4)) do
ip = Enum.join(octets, ".")
opts = [ip: ip, community: "public", version: "2c"]
assert is_binary(opts[:ip])
end
end
test "IPv4 format" do
valid_ips = [
"192.168.1.1",
"10.0.0.1",
"172.16.0.1",
"8.8.8.8",
"127.0.0.1"
]
for ip <- valid_ips do
opts = [ip: ip, community: "public", version: "2c"]
assert opts[:ip] == ip
end
end
test "hostname format" do
valid_hosts = [
"router.example.com",
"switch-01",
"device123"
]
for host <- valid_hosts do
opts = [ip: host, community: "public", version: "2c"]
assert opts[:ip] == host
end
end
end
describe "SNMP version handling" do
test "supports SNMPv1" do
opts = [ip: "192.168.1.1", community: "public", version: "1"]
assert opts[:version] == "1"
end
test "supports SNMPv2c" do
opts = [ip: "192.168.1.1", community: "public", version: "2c"]
assert opts[:version] == "2c"
end
property "version is always v1 or v2c" do
check all(version <- StreamData.member_of(["1", "2c", :v1, :v2c])) do
opts = [ip: "192.168.1.1", community: "public", version: version]
normalized_version =
case opts[:version] do
"1" -> :v1
"2c" -> :v2c
:v1 -> :v1
:v2c -> :v2c
end
assert normalized_version in [:v1, :v2c]
end
end
end
describe "community strings" do
property "community strings can be any non-empty string" do
check all(community <- StreamData.string(:alphanumeric, min_length: 1, max_length: 64)) do
opts = [ip: "192.168.1.1", community: community, version: "2c"]
assert is_binary(opts[:community])
assert String.length(opts[:community]) > 0
end
end
test "common community strings" do
common_communities = ["public", "private", "community", "snmp"]
for community <- common_communities do
opts = [ip: "192.168.1.1", community: community, version: "2c"]
assert opts[:community] == community
end
end
end
describe "return value formats" do
property "error reasons are atoms" do
check all(
reason <-
StreamData.member_of([
:timeout,
:no_such_object,
:no_such_instance,
:end_of_mib_view,
:network_unreachable
])
) do
expect(SnmpMock, :get, fn _, _, _ -> {:error, reason} end)
assert {:error, ^reason} = Client.get(@test_opts, "1.3.6.1.2.1.1.1.0")
end
end
end
end

View file

@ -0,0 +1,479 @@
defmodule Towerops.Snmp.DiscoveryTest do
use Towerops.DataCase, async: true
import Mox
import Towerops.AccountsFixtures
alias Towerops.Equipment
alias Towerops.Snmp.Device
alias Towerops.Snmp.Discovery
alias Towerops.Snmp.Interface
alias Towerops.Snmp.Sensor
alias Towerops.Snmp.SnmpMock
setup :verify_on_exit!
setup do
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
%{organization: organization, site: site}
end
describe "discover_equipment/1" do
test "returns error when SNMP is not enabled", %{site: site} do
{:ok, equipment} =
Equipment.create_equipment(%{
name: "Router 1",
ip_address: "192.168.1.1",
site_id: site.id,
snmp_enabled: false
})
assert {:error, :snmp_not_enabled} = Discovery.discover_equipment(equipment)
end
test "successfully discovers MikroTik device", %{site: site} do
{:ok, equipment} =
Equipment.create_equipment(%{
name: "MikroTik Router",
ip_address: "192.168.1.1",
site_id: site.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public",
snmp_port: 161
})
# Mock successful connection test
expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.3.0", _ ->
{:ok, {:timeticks, 12_345}}
end)
# Mock system info discovery
expect(SnmpMock, :get, 6, fn _, oid, _ ->
case oid do
"1.3.6.1.2.1.1.1.0" ->
{:ok, {:octet_string, "RouterOS RB750"}}
"1.3.6.1.2.1.1.2.0" ->
{:ok, {:object_identifier, [1, 3, 6, 1, 4, 1, 14_988, 1]}}
"1.3.6.1.2.1.1.3.0" ->
{:ok, {:timeticks, 12_345}}
"1.3.6.1.2.1.1.4.0" ->
{:ok, {:octet_string, "admin@example.com"}}
"1.3.6.1.2.1.1.5.0" ->
{:ok, {:octet_string, "router1"}}
"1.3.6.1.2.1.1.6.0" ->
{:ok, {:octet_string, "Data Center"}}
_ ->
{:error, :no_such_object}
end
end)
# Mock interface discovery
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.2.2.1.1", _ ->
{:ok,
[
%{oid: "1.3.6.1.2.1.2.2.1.1.1", value: {:integer, 1}},
%{oid: "1.3.6.1.2.1.2.2.1.1.2", value: {:integer, 2}}
]}
end)
# Mock interface data fetches for each interface
expect(SnmpMock, :get, 16, fn _, oid, _ ->
cond do
String.ends_with?(oid, ".1") ->
parts = String.split(oid, ".")
second_to_last = Enum.at(parts, -2)
cond do
second_to_last == "2" -> {:ok, {:octet_string, "ether1"}}
second_to_last == "3" -> {:ok, {:integer, 6}}
second_to_last == "5" -> {:ok, {:gauge32, 1_000_000_000}}
second_to_last == "6" -> {:ok, {:octet_string, <<0x00, 0x11, 0x22, 0x33, 0x44, 0x55>>}}
second_to_last == "7" -> {:ok, {:integer, 1}}
second_to_last == "8" -> {:ok, {:integer, 1}}
second_to_last == "1" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "ether1"}}
second_to_last == "18" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "WAN"}}
true -> {:error, :no_such_object}
end
String.ends_with?(oid, ".2") ->
parts = String.split(oid, ".")
second_to_last = Enum.at(parts, -2)
cond do
second_to_last == "2" -> {:ok, {:octet_string, "ether2"}}
second_to_last == "3" -> {:ok, {:integer, 6}}
second_to_last == "5" -> {:ok, {:gauge32, 1_000_000_000}}
second_to_last == "6" -> {:ok, {:octet_string, <<0x00, 0x11, 0x22, 0x33, 0x44, 0x56>>}}
second_to_last == "7" -> {:ok, {:integer, 1}}
second_to_last == "8" -> {:ok, {:integer, 1}}
second_to_last == "1" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "ether2"}}
second_to_last == "18" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "LAN"}}
true -> {:error, :no_such_object}
end
true ->
{:error, :no_such_object}
end
end)
# Mock MikroTik health sensor discovery (15 sensors: voltages, temps, power, fans)
# All MikroTik-specific OIDs will return no_such_object since device doesn't have them
expect(SnmpMock, :get, 15, fn _, _, _ ->
{:error, :no_such_object}
end)
# Mock sensor discovery - MikroTik profiles make many walk calls for sensors
# CPU (1), storage (3), POE (4), optical (5), ENTITY-SENSOR-MIB (1) = 14 walks
stub(SnmpMock, :walk, fn _, oid, _ ->
if String.starts_with?(oid, "1.3.6.1.2.1.99") do
{:ok, []}
else
{:error, :no_such_object}
end
end)
assert {:ok, device} = Discovery.discover_equipment(equipment)
assert device.manufacturer == "MikroTik"
assert device.model == "RB750"
assert device.sys_name == "router1"
# Verify interfaces were created
interfaces = Repo.all(from i in Interface, where: i.snmp_device_id == ^device.id)
assert length(interfaces) == 2
interface1 = Enum.find(interfaces, &(&1.if_index == 1))
assert interface1.if_descr == "ether1"
assert interface1.if_name == "ether1"
assert interface1.if_alias == "WAN"
end
test "successfully discovers Cisco device", %{site: site} do
{:ok, equipment} =
Equipment.create_equipment(%{
name: "Cisco Switch",
ip_address: "192.168.1.2",
site_id: site.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public",
snmp_port: 161
})
# Mock successful connection test
expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.3.0", _ ->
{:ok, {:timeticks, 54_321}}
end)
# Mock system info discovery
expect(SnmpMock, :get, 6, fn _, oid, _ ->
case oid do
"1.3.6.1.2.1.1.1.0" ->
{:ok, {:octet_string, "Cisco IOS Software, C2960 Software"}}
"1.3.6.1.2.1.1.2.0" ->
{:ok, {:object_identifier, [1, 3, 6, 1, 4, 1, 9, 1, 1208]}}
"1.3.6.1.2.1.1.3.0" ->
{:ok, {:timeticks, 54_321}}
"1.3.6.1.2.1.1.4.0" ->
{:ok, {:octet_string, "netadmin@cisco.com"}}
"1.3.6.1.2.1.1.5.0" ->
{:ok, {:octet_string, "switch1"}}
"1.3.6.1.2.1.1.6.0" ->
{:ok, {:octet_string, "Building A"}}
_ ->
{:error, :no_such_object}
end
end)
# Mock interface discovery - no interfaces for simplicity
# Walk called 3 times: interfaces (1), cisco sensors (1), base sensors fallback (1)
expect(SnmpMock, :walk, 3, fn _, _, _ ->
{:ok, []}
end)
assert {:ok, device} = Discovery.discover_equipment(equipment)
assert device.manufacturer == "Cisco"
assert device.model == "C2960"
assert device.sys_name == "switch1"
end
test "handles connection failure", %{site: site} do
{:ok, equipment} =
Equipment.create_equipment(%{
name: "Unreachable Device",
ip_address: "192.168.1.99",
site_id: site.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public",
snmp_port: 161
})
# Mock failed connection test
expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.3.0", _ ->
{:error, :timeout}
end)
assert {:error, :timeout} = Discovery.discover_equipment(equipment)
end
test "handles partial discovery failure gracefully", %{site: site} do
{:ok, equipment} =
Equipment.create_equipment(%{
name: "Partial Device",
ip_address: "192.168.1.3",
site_id: site.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public",
snmp_port: 161
})
# Mock successful connection test
expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.3.0", _ ->
{:ok, {:timeticks, 999}}
end)
# Mock system info discovery
expect(SnmpMock, :get, 6, fn _, oid, _ ->
case oid do
"1.3.6.1.2.1.1.1.0" ->
{:ok, {:octet_string, "Generic Device"}}
_ ->
{:ok, {:octet_string, ""}}
end
end)
# Mock failed interface discovery
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.2.2.1.1", _ ->
{:error, :timeout}
end)
# Mock failed sensor discovery
expect(SnmpMock, :walk, fn _, oid, _ ->
if String.starts_with?(oid, "1.3.6.1.2.1.99") do
{:error, :timeout}
else
{:error, :no_such_object}
end
end)
# Should succeed even with failed interface/sensor discovery
assert {:ok, device} = Discovery.discover_equipment(equipment)
assert device.manufacturer == "Unknown"
# Verify no interfaces or sensors were created
interfaces = Repo.all(from i in Interface, where: i.snmp_device_id == ^device.id)
sensors = Repo.all(from s in Sensor, where: s.snmp_device_id == ^device.id)
assert interfaces == []
assert sensors == []
end
test "updates existing device on re-discovery", %{site: site} do
{:ok, equipment} =
Equipment.create_equipment(%{
name: "Existing Device",
ip_address: "192.168.1.4",
site_id: site.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public",
snmp_port: 161
})
# Create initial device
{:ok, initial_device} =
%Device{}
|> Device.changeset(%{
equipment_id: equipment.id,
manufacturer: "Old Manufacturer",
model: "Old Model",
sys_name: "old-name"
})
|> Repo.insert()
# Mock connection and system info for re-discovery
expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.3.0", _ ->
{:ok, {:timeticks, 12_345}}
end)
expect(SnmpMock, :get, 6, fn _, oid, _ ->
case oid do
"1.3.6.1.2.1.1.1.0" ->
{:ok, {:octet_string, "Linux server 5.4.0"}}
"1.3.6.1.2.1.1.5.0" ->
{:ok, {:octet_string, "updated-name"}}
_ ->
{:ok, {:octet_string, ""}}
end
end)
# NetSnmp also tries UCD sensors (load, memory, disk) which make additional get calls
stub(SnmpMock, :get, fn _, _, _ ->
{:error, :no_such_object}
end)
# NetSnmp profile makes many walk calls for sensors
# Interfaces (1), LM sensors (6), UCD sensors (3), ENTITY-SENSOR-MIB fallback (1)
stub(SnmpMock, :walk, fn _, _, _ ->
{:ok, []}
end)
assert {:ok, updated_device} = Discovery.discover_equipment(equipment)
# Should be same device, updated
assert updated_device.id == initial_device.id
assert updated_device.manufacturer == "Linux"
assert updated_device.model == "Linux"
assert updated_device.sys_name == "updated-name"
end
end
describe "discover_all/1" do
test "discovers multiple devices concurrently", %{organization: organization, site: site} do
# Create multiple SNMP-enabled equipment
{:ok, equipment1} =
Equipment.create_equipment(%{
name: "Device 1",
ip_address: "192.168.1.10",
site_id: site.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public",
snmp_port: 161
})
{:ok, equipment2} =
Equipment.create_equipment(%{
name: "Device 2",
ip_address: "192.168.1.11",
site_id: site.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public",
snmp_port: 161
})
{:ok, _equipment3} =
Equipment.create_equipment(%{
name: "Device 3 - No SNMP",
ip_address: "192.168.1.12",
site_id: site.id,
snmp_enabled: false
})
# Mock successful discovery for device1
allow(SnmpMock, self(), fn -> :ok end)
stub(SnmpMock, :get, fn _target, oid, _opts ->
case oid do
"1.3.6.1.2.1.1.3.0" ->
{:ok, {:timeticks, 12_345}}
"1.3.6.1.2.1.1.1.0" ->
{:ok, {:octet_string, "Test Device"}}
_ ->
{:ok, {:octet_string, ""}}
end
end)
stub(SnmpMock, :walk, fn _, _, _ ->
{:ok, []}
end)
assert {:ok, summary} = Discovery.discover_all(organization.id)
# Should only discover SNMP-enabled devices
assert summary.success == 2
assert summary.failed == 0
# Verify devices were created
assert Repo.get_by(Device, equipment_id: equipment1.id)
assert Repo.get_by(Device, equipment_id: equipment2.id)
end
test "handles mixed success and failure", %{organization: organization, site: site} do
{:ok, equipment1} =
Equipment.create_equipment(%{
name: "Good Device",
ip_address: "192.168.1.20",
site_id: site.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public",
snmp_port: 161
})
{:ok, _equipment2} =
Equipment.create_equipment(%{
name: "Bad Device",
ip_address: "192.168.1.21",
site_id: site.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "wrong",
snmp_port: 161
})
allow(SnmpMock, self(), fn -> :ok end)
stub(SnmpMock, :get, fn target, oid, _opts ->
# Fail for .21, succeed for .20
if target == "192.168.1.21" do
{:error, :auth_failure}
else
case oid do
"1.3.6.1.2.1.1.3.0" -> {:ok, {:timeticks, 12_345}}
"1.3.6.1.2.1.1.1.0" -> {:ok, {:octet_string, "Good Device"}}
_ -> {:ok, {:octet_string, ""}}
end
end
end)
stub(SnmpMock, :walk, fn target, _, _ ->
if target == "192.168.1.21" do
{:error, :auth_failure}
else
{:ok, []}
end
end)
assert {:ok, summary} = Discovery.discover_all(organization.id)
assert summary.success == 1
assert summary.failed == 1
assert :auth_failure in summary.errors
# Verify only good device was created
assert Repo.get_by(Device, equipment_id: equipment1.id)
end
end
end

View file

@ -0,0 +1,169 @@
defmodule Towerops.Snmp.PollerTest do
use Towerops.DataCase, async: true
import Mox
alias Towerops.Snmp.Poller
alias Towerops.Snmp.SnmpMock
setup :verify_on_exit!
describe "check_device/1" do
test "returns response time on successful check" do
client_opts = [
ip: "192.168.1.1",
community: "public",
version: "2c",
port: 161,
timeout: 5000
]
expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.3.0", _ ->
{:ok, {:timeticks, 12_345_678}}
end)
assert {:ok, response_time} = Poller.check_device(client_opts)
assert is_integer(response_time)
assert response_time >= 0
end
test "returns error on timeout" do
client_opts = [
ip: "192.168.1.99",
community: "public",
version: "2c",
port: 161,
timeout: 1000
]
expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.3.0", _ ->
{:error, :timeout}
end)
assert {:error, :timeout} = Poller.check_device(client_opts)
end
test "returns error on authentication failure" do
client_opts = [
ip: "192.168.1.1",
community: "wrong_community",
version: "2c",
port: 161,
timeout: 5000
]
expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.3.0", _ ->
{:error, :auth_failure}
end)
assert {:error, :auth_failure} = Poller.check_device(client_opts)
end
test "returns error on network unreachable" do
client_opts = [
ip: "10.255.255.1",
community: "public",
version: "2c",
port: 161,
timeout: 5000
]
expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.3.0", _ ->
{:error, :network_unreachable}
end)
assert {:error, :network_unreachable} = Poller.check_device(client_opts)
end
test "measures response time accurately" do
client_opts = [
ip: "192.168.1.1",
community: "public",
version: "2c",
port: 161,
timeout: 5000
]
# Simulate a delayed response
expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.3.0", _ ->
Process.sleep(10)
{:ok, {:timeticks, 999}}
end)
assert {:ok, response_time} = Poller.check_device(client_opts)
# Response time should be at least 10ms due to the sleep
assert response_time >= 10
end
end
describe "build_client_opts/1" do
test "builds client options from equipment map" do
equipment = %{
ip_address: "192.168.1.100",
snmp_community: "private",
snmp_version: "2c",
snmp_port: 161
}
client_opts = Poller.build_client_opts(equipment)
assert client_opts[:ip] == "192.168.1.100"
assert client_opts[:community] == "private"
assert client_opts[:version] == "2c"
assert client_opts[:port] == 161
assert client_opts[:timeout] == 5000
end
test "uses default port 161 when snmp_port is nil" do
equipment = %{
ip_address: "192.168.1.100",
snmp_community: "public",
snmp_version: "2c",
snmp_port: nil
}
client_opts = Poller.build_client_opts(equipment)
assert client_opts[:port] == 161
end
test "handles custom SNMP port" do
equipment = %{
ip_address: "192.168.1.100",
snmp_community: "public",
snmp_version: "2c",
snmp_port: 1161
}
client_opts = Poller.build_client_opts(equipment)
assert client_opts[:port] == 1161
end
test "builds options for SNMPv1" do
equipment = %{
ip_address: "10.0.0.1",
snmp_community: "public",
snmp_version: "1",
snmp_port: 161
}
client_opts = Poller.build_client_opts(equipment)
assert client_opts[:version] == "1"
end
test "includes standard timeout of 5 seconds" do
equipment = %{
ip_address: "192.168.1.1",
snmp_community: "public",
snmp_version: "2c",
snmp_port: 161
}
client_opts = Poller.build_client_opts(equipment)
assert client_opts[:timeout] == 5000
end
end
end

View file

@ -0,0 +1,365 @@
defmodule Towerops.Snmp.Profiles.BaseTest do
use Towerops.DataCase, async: true
import Mox
alias Towerops.Snmp.Profiles.Base
alias Towerops.Snmp.SnmpMock
setup :verify_on_exit!
@client_opts [
ip: "192.168.1.1",
community: "public",
version: "2c",
port: 161,
timeout: 5000
]
describe "discover_system_info/1" do
test "successfully discovers system information" do
expect(SnmpMock, :get, 6, fn _, oid, _ ->
case oid do
"1.3.6.1.2.1.1.1.0" ->
{:ok, {:octet_string, "Linux server 5.4.0-42-generic"}}
"1.3.6.1.2.1.1.2.0" ->
{:ok, {:object_identifier, [1, 3, 6, 1, 4, 1, 8072, 3, 2, 10]}}
"1.3.6.1.2.1.1.3.0" ->
{:ok, {:timeticks, 123_456_789}}
"1.3.6.1.2.1.1.4.0" ->
{:ok, {:octet_string, "admin@example.com"}}
"1.3.6.1.2.1.1.5.0" ->
{:ok, {:octet_string, "webserver01"}}
"1.3.6.1.2.1.1.6.0" ->
{:ok, {:octet_string, "DC1 Rack 42"}}
_ ->
{:error, :no_such_object}
end
end)
assert {:ok, system_info} = Base.discover_system_info(@client_opts)
assert system_info.sys_descr == "Linux server 5.4.0-42-generic"
assert system_info.sys_object_id == "1.3.6.1.4.1.8072.3.2.10"
assert system_info.sys_name == "webserver01"
assert system_info.sys_uptime == 123_456_789
assert system_info.sys_contact == "admin@example.com"
assert system_info.sys_location == "DC1 Rack 42"
end
test "handles partial failure gracefully" do
expect(SnmpMock, :get, 6, fn _, oid, _ ->
case oid do
"1.3.6.1.2.1.1.1.0" ->
{:ok, {:octet_string, "Test Device"}}
_ ->
{:error, :no_such_object}
end
end)
# Should fail because get_multiple returns error on any failure
assert {:error, :partial_failure} = Base.discover_system_info(@client_opts)
end
test "handles timeout errors" do
expect(SnmpMock, :get, 6, fn _, _, _ ->
{:error, :timeout}
end)
assert {:error, :partial_failure} = Base.discover_system_info(@client_opts)
end
end
describe "discover_interfaces/1" do
test "successfully discovers multiple interfaces" do
# Mock ifIndex walk
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.2.2.1.1", _ ->
{:ok,
[
%{oid: "1.3.6.1.2.1.2.2.1.1.1", value: {:integer, 1}},
%{oid: "1.3.6.1.2.1.2.2.1.1.2", value: {:integer, 2}},
%{oid: "1.3.6.1.2.1.2.2.1.1.3", value: {:integer, 3}}
]}
end)
# Mock interface data for each interface (8 OIDs per interface × 3 interfaces = 24 calls)
expect(SnmpMock, :get, 24, fn _, oid, _ ->
cond do
String.ends_with?(oid, ".1") ->
parts = String.split(oid, ".")
second_to_last = Enum.at(parts, -2)
cond do
second_to_last == "2" -> {:ok, {:octet_string, "eth0"}}
second_to_last == "3" -> {:ok, {:integer, 6}}
second_to_last == "5" -> {:ok, {:gauge32, 1_000_000_000}}
second_to_last == "6" -> {:ok, {:octet_string, <<0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0x01>>}}
second_to_last == "7" -> {:ok, {:integer, 1}}
second_to_last == "8" -> {:ok, {:integer, 1}}
second_to_last == "1" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "eth0"}}
second_to_last == "18" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "WAN"}}
true -> {:error, :no_such_object}
end
String.ends_with?(oid, ".2") ->
parts = String.split(oid, ".")
second_to_last = Enum.at(parts, -2)
cond do
second_to_last == "2" -> {:ok, {:octet_string, "eth1"}}
second_to_last == "3" -> {:ok, {:integer, 6}}
second_to_last == "5" -> {:ok, {:gauge32, 100_000_000}}
second_to_last == "6" -> {:ok, {:octet_string, <<0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0x02>>}}
second_to_last == "7" -> {:ok, {:integer, 1}}
second_to_last == "8" -> {:ok, {:integer, 2}}
second_to_last == "1" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "eth1"}}
second_to_last == "18" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "LAN"}}
true -> {:error, :no_such_object}
end
String.ends_with?(oid, ".3") ->
parts = String.split(oid, ".")
second_to_last = Enum.at(parts, -2)
cond do
second_to_last == "2" -> {:ok, {:octet_string, "lo"}}
second_to_last == "3" -> {:ok, {:integer, 24}}
second_to_last == "5" -> {:ok, {:gauge32, 10_000_000}}
second_to_last == "6" -> {:ok, {:octet_string, <<>>}}
second_to_last == "7" -> {:ok, {:integer, 1}}
second_to_last == "8" -> {:ok, {:integer, 1}}
second_to_last == "1" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "lo"}}
second_to_last == "18" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "Loopback"}}
true -> {:error, :no_such_object}
end
true ->
{:error, :no_such_object}
end
end)
assert {:ok, interfaces} = Base.discover_interfaces(@client_opts)
assert length(interfaces) == 3
eth0 = Enum.find(interfaces, &(&1.if_index == 1))
assert eth0.if_descr == "eth0"
assert eth0.if_name == "eth0"
assert eth0.if_alias == "WAN"
assert eth0.if_type == 6
assert eth0.if_speed == 1_000_000_000
assert eth0.if_phys_address == "aa:bb:cc:dd:ee:01"
assert eth0.if_admin_status == "up"
assert eth0.if_oper_status == "up"
eth1 = Enum.find(interfaces, &(&1.if_index == 2))
assert eth1.if_descr == "eth1"
assert eth1.if_oper_status == "down"
lo = Enum.find(interfaces, &(&1.if_index == 3))
assert lo.if_descr == "lo"
assert lo.if_phys_address == nil
end
test "handles empty interface list" do
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.2.2.1.1", _ ->
{:ok, []}
end)
assert {:ok, interfaces} = Base.discover_interfaces(@client_opts)
assert interfaces == []
end
test "handles walk failure" do
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.2.2.1.1", _ ->
{:error, :timeout}
end)
assert {:error, :timeout} = Base.discover_interfaces(@client_opts)
end
end
describe "discover_sensors/1" do
test "discovers sensors from ENTITY-SENSOR-MIB" do
# Mock sensor type walk
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.99.1.1.1.1", _ ->
{:ok,
[
%{oid: "1.3.6.1.2.1.99.1.1.1.1.1", value: {:integer, 8}},
%{oid: "1.3.6.1.2.1.99.1.1.1.1.2", value: {:integer, 3}}
]}
end)
# Mock sensor data for each sensor (4 OIDs per sensor × 2 sensors = 8 calls)
expect(SnmpMock, :get, 8, fn _, oid, _ ->
cond do
String.ends_with?(oid, ".1") ->
case oid |> String.split(".") |> Enum.at(-2) do
"1" -> {:ok, {:integer, 8}}
"2" -> {:ok, {:integer, 0}}
"4" -> {:ok, {:integer, 45}}
"5" -> {:ok, {:integer, 1}}
_ -> {:error, :no_such_object}
end
String.ends_with?(oid, ".2") ->
case oid |> String.split(".") |> Enum.at(-2) do
"1" -> {:ok, {:integer, 3}}
"2" -> {:ok, {:integer, -3}}
"4" -> {:ok, {:integer, 12_000}}
"5" -> {:ok, {:integer, 1}}
_ -> {:error, :no_such_object}
end
true ->
{:error, :no_such_object}
end
end)
assert {:ok, sensors} = Base.discover_sensors(@client_opts)
assert length(sensors) == 2
temp_sensor = Enum.find(sensors, &(&1.sensor_index == "1"))
assert temp_sensor.sensor_type == "celsius"
assert temp_sensor.sensor_unit == "°C"
assert temp_sensor.sensor_divisor == 1
assert temp_sensor.last_value == 45.0
assert temp_sensor.status == "ok"
voltage_sensor = Enum.find(sensors, &(&1.sensor_index == "2"))
assert voltage_sensor.sensor_type == "volts"
assert voltage_sensor.sensor_unit == "V"
assert voltage_sensor.sensor_divisor == 1_000
assert voltage_sensor.last_value == 12_000.0
end
test "returns empty list when ENTITY-SENSOR-MIB not supported" do
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.99.1.1.1.1", _ ->
{:ok, []}
end)
assert {:ok, sensors} = Base.discover_sensors(@client_opts)
assert sensors == []
end
test "returns empty list on walk error" do
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.99.1.1.1.1", _ ->
{:error, :no_such_object}
end)
assert {:ok, sensors} = Base.discover_sensors(@client_opts)
assert sensors == []
end
test "filters out sensors with missing data" do
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.99.1.1.1.1", _ ->
{:ok,
[
%{oid: "1.3.6.1.2.1.99.1.1.1.1.1", value: {:integer, 8}},
%{oid: "1.3.6.1.2.1.99.1.1.1.1.2", value: {:integer, 8}}
]}
end)
# First sensor succeeds, second fails
expect(SnmpMock, :get, 8, fn _, oid, _ ->
if String.ends_with?(oid, ".1") do
case oid |> String.split(".") |> Enum.at(-2) do
"1" -> {:ok, {:integer, 8}}
"2" -> {:ok, {:integer, 0}}
"4" -> {:ok, {:integer, 42}}
"5" -> {:ok, {:integer, 1}}
_ -> {:error, :no_such_object}
end
else
{:error, :timeout}
end
end)
assert {:ok, sensors} = Base.discover_sensors(@client_opts)
# Should only have the successful sensor
assert length(sensors) == 1
assert hd(sensors).sensor_index == "1"
end
end
describe "identify_device/1" do
test "identifies Cisco device from sysDescr" do
system_info = %{
sys_descr: "Cisco IOS Software, C2960 Software (C2960-LANBASEK9-M), Version 15.0(2)SE"
}
device_info = Base.identify_device(system_info)
assert device_info.manufacturer == "Cisco"
assert device_info.model == "C2960"
end
test "identifies Linux server from sysDescr" do
system_info = %{
sys_descr: "Linux server 5.4.0-42-generic #46-Ubuntu SMP"
}
device_info = Base.identify_device(system_info)
assert device_info.manufacturer == "Linux"
assert device_info.model == "Server"
end
test "identifies Windows server from sysDescr" do
system_info = %{
sys_descr: "Windows Server 2019 Standard"
}
device_info = Base.identify_device(system_info)
assert device_info.manufacturer == "Microsoft"
assert device_info.model == "Windows Server"
end
test "falls back to unknown for unrecognized devices" do
system_info = %{
sys_descr: "Some Unknown Device"
}
device_info = Base.identify_device(system_info)
assert device_info.manufacturer == "Unknown"
assert device_info.model == "Generic Device"
end
test "extracts Cisco model from complex sysDescr" do
system_info = %{
sys_descr: "Cisco IOS Software, WS-C3750X Software (cat3k_caa-universalk9-M), Version 15.2(4)E1"
}
device_info = Base.identify_device(system_info)
assert device_info.manufacturer == "Cisco"
assert device_info.model == "WS-C3750X"
end
test "preserves other system_info fields" do
system_info = %{
sys_descr: "Linux server",
sys_name: "webserver01",
sys_contact: "admin@example.com",
sys_location: "DC1"
}
device_info = Base.identify_device(system_info)
assert device_info.sys_name == "webserver01"
assert device_info.sys_contact == "admin@example.com"
assert device_info.sys_location == "DC1"
end
end
end

View file

@ -0,0 +1,477 @@
defmodule Towerops.Snmp.Profiles.MikrotikTest do
use ExUnit.Case, async: true
use ExUnitProperties
import Mox
alias Towerops.Snmp.Profiles.Mikrotik
alias Towerops.Snmp.SnmpMock
setup :verify_on_exit!
@test_opts [ip: "192.168.1.1", community: "public", version: "2c"]
describe "discover_system_info/1" do
test "merges base system info with MikroTik-specific info" do
# Mock Base.discover_system_info (via Client.get_multiple for system OIDs)
base_system_oids = [
"1.3.6.1.2.1.1.1.0",
"1.3.6.1.2.1.1.2.0",
"1.3.6.1.2.1.1.3.0",
"1.3.6.1.2.1.1.4.0",
"1.3.6.1.2.1.1.5.0",
"1.3.6.1.2.1.1.6.0"
]
expect(SnmpMock, :get, length(base_system_oids), fn _, oid, _ ->
case oid do
"1.3.6.1.2.1.1.1.0" ->
{:ok, {:octet_string, "RouterOS RB5009UG+S+ (stable) 7.13.2"}}
"1.3.6.1.2.1.1.2.0" ->
{:ok, {:object_identifier, [1, 3, 6, 1, 4, 1, 14_988, 1]}}
"1.3.6.1.2.1.1.3.0" ->
{:ok, {:timeticks, 123_456_789}}
"1.3.6.1.2.1.1.4.0" ->
{:ok, {:octet_string, "admin@example.com"}}
"1.3.6.1.2.1.1.5.0" ->
{:ok, {:octet_string, "rb5009-main"}}
"1.3.6.1.2.1.1.6.0" ->
{:ok, {:octet_string, "Data Center"}}
end
end)
# Mock MikroTik-specific OIDs
mikrotik_oids = [
"1.3.6.1.4.1.14988.1.1.7.3.0",
"1.3.6.1.4.1.14988.1.1.7.4.0",
"1.3.6.1.4.1.14988.1.1.7.9.0",
"1.3.6.1.4.1.14988.1.1.7.8.0",
"1.3.6.1.4.1.14988.1.1.4.4.0"
]
expect(SnmpMock, :get, length(mikrotik_oids), fn _, oid, _ ->
case oid do
"1.3.6.1.4.1.14988.1.1.7.3.0" -> {:ok, {:octet_string, "ABC123DEF456"}}
"1.3.6.1.4.1.14988.1.1.7.4.0" -> {:ok, {:octet_string, "7.13.2"}}
"1.3.6.1.4.1.14988.1.1.7.9.0" -> {:ok, {:octet_string, "RB5009UG+S+"}}
"1.3.6.1.4.1.14988.1.1.7.8.0" -> {:ok, {:octet_string, "RB5009"}}
"1.3.6.1.4.1.14988.1.1.4.4.0" -> {:ok, {:octet_string, "level6"}}
end
end)
assert {:ok, info} = Mikrotik.discover_system_info(@test_opts)
# Check base info
assert info.sys_descr == "RouterOS RB5009UG+S+ (stable) 7.13.2"
assert info.sys_name == "rb5009-main"
assert info.sys_location == "Data Center"
# Check MikroTik-specific info
assert info.serial_number == "ABC123DEF456"
assert info.firmware_version == "7.13.2"
assert info.board_name == "RB5009UG+S+"
end
test "handles missing MikroTik-specific OIDs gracefully" do
# Mock Base system info
expect(SnmpMock, :get, 6, fn _, oid, _ ->
case oid do
"1.3.6.1.2.1.1.1.0" -> {:ok, {:octet_string, "RouterOS Device"}}
"1.3.6.1.2.1.1.2.0" -> {:ok, {:object_identifier, [1, 3, 6, 1, 4, 1, 14_988]}}
"1.3.6.1.2.1.1.3.0" -> {:ok, {:timeticks, 12_345}}
"1.3.6.1.2.1.1.4.0" -> {:ok, {:octet_string, "admin"}}
"1.3.6.1.2.1.1.5.0" -> {:ok, {:octet_string, "router"}}
"1.3.6.1.2.1.1.6.0" -> {:ok, {:octet_string, "office"}}
end
end)
# Mock MikroTik OIDs returning error
expect(SnmpMock, :get, 5, fn _, _, _ -> {:error, :no_such_object} end)
assert {:ok, info} = Mikrotik.discover_system_info(@test_opts)
# Should still have base info
assert info.sys_descr == "RouterOS Device"
assert info.sys_name == "router"
end
end
describe "discover_interfaces/1" do
test "delegates to Base.discover_interfaces" do
# Mock IF-MIB walk for interface indices
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.2.2.1.1", _ ->
{:ok,
[
%{oid: "1.3.6.1.2.1.2.2.1.1.1", value: 1},
%{oid: "1.3.6.1.2.1.2.2.1.1.2", value: 2}
]}
end)
# Mock interface data for each interface
expect(SnmpMock, :get, 16, fn _, oid, _ ->
cond do
String.ends_with?(oid, ".2.2.1.2.1") -> {:ok, {:octet_string, "ether1"}}
String.ends_with?(oid, ".2.2.1.2.2") -> {:ok, {:octet_string, "ether2"}}
String.ends_with?(oid, ".2.2.1.3.1") -> {:ok, {:integer, 6}}
String.ends_with?(oid, ".2.2.1.3.2") -> {:ok, {:integer, 6}}
String.ends_with?(oid, ".2.2.1.5.1") -> {:ok, {:gauge32, 1_000_000_000}}
String.ends_with?(oid, ".2.2.1.5.2") -> {:ok, {:gauge32, 1_000_000_000}}
String.ends_with?(oid, ".2.2.1.6.1") -> {:ok, {:octet_string, <<0x00, 0x0C, 0x42, 0xAB, 0xCD, 0xEF>>}}
String.ends_with?(oid, ".2.2.1.6.2") -> {:ok, {:octet_string, <<0x00, 0x0C, 0x42, 0x12, 0x34, 0x56>>}}
String.ends_with?(oid, ".2.2.1.7.1") -> {:ok, {:integer, 1}}
String.ends_with?(oid, ".2.2.1.7.2") -> {:ok, {:integer, 1}}
String.ends_with?(oid, ".2.2.1.8.1") -> {:ok, {:integer, 1}}
String.ends_with?(oid, ".2.2.1.8.2") -> {:ok, {:integer, 1}}
String.ends_with?(oid, ".31.1.1.1.1.1") -> {:ok, {:octet_string, "ether1"}}
String.ends_with?(oid, ".31.1.1.1.1.2") -> {:ok, {:octet_string, "ether2"}}
String.ends_with?(oid, ".31.1.1.1.18.1") -> {:ok, {:octet_string, "WAN"}}
String.ends_with?(oid, ".31.1.1.1.18.2") -> {:ok, {:octet_string, "LAN"}}
end
end)
assert {:ok, interfaces} = Mikrotik.discover_interfaces(@test_opts)
assert length(interfaces) == 2
assert Enum.any?(interfaces, &(&1.if_descr == "ether1"))
assert Enum.any?(interfaces, &(&1.if_descr == "ether2"))
end
end
describe "discover_sensors/1" do
test "discovers health sensors (temperature, voltage, fan)" do
# Mock health sensor OIDs
expect(SnmpMock, :get, 15, fn _, oid, _ ->
case oid do
# Voltages
"1.3.6.1.4.1.14988.1.1.3.1.0" -> {:ok, {:integer, 12}}
"1.3.6.1.4.1.14988.1.1.3.2.0" -> {:ok, {:integer, 33}}
"1.3.6.1.4.1.14988.1.1.3.3.0" -> {:ok, {:integer, 50}}
"1.3.6.1.4.1.14988.1.1.3.4.0" -> {:ok, {:integer, 120}}
"1.3.6.1.4.1.14988.1.1.3.8.0" -> {:ok, {:integer, 240}}
# Temperatures
"1.3.6.1.4.1.14988.1.1.3.5.0" -> {:ok, {:integer, 450}}
"1.3.6.1.4.1.14988.1.1.3.6.0" -> {:ok, {:integer, 550}}
"1.3.6.1.4.1.14988.1.1.3.7.0" -> {:ok, {:integer, 480}}
"1.3.6.1.4.1.14988.1.1.3.10.0" -> {:ok, {:integer, 500}}
"1.3.6.1.4.1.14988.1.1.3.11.0" -> {:ok, {:integer, 520}}
# Power & Current
"1.3.6.1.4.1.14988.1.1.3.13.0" -> {:ok, {:integer, 1500}}
"1.3.6.1.4.1.14988.1.1.3.12.0" -> {:ok, {:integer, 250}}
# Fans
"1.3.6.1.4.1.14988.1.1.3.17.0" -> {:ok, {:integer, 3000}}
"1.3.6.1.4.1.14988.1.1.3.18.0" -> {:ok, {:integer, 0}}
# Processor frequency
"1.3.6.1.4.1.14988.1.1.3.14.0" -> {:ok, {:integer, 1400}}
end
end)
# Mock all walk calls - use stub to allow any number of calls (code may short-circuit)
stub(SnmpMock, :walk, fn _, oid, _ ->
case oid do
# CPU sensors
"1.3.6.1.2.1.25.3.3.1.2" -> {:ok, [%{oid: "1.3.6.1.2.1.25.3.3.1.2.1", value: 45}]}
# Storage sensors
"1.3.6.1.2.1.25.2.3.1.3" -> {:ok, [%{oid: "1.3.6.1.2.1.25.2.3.1.3.1", value: "RAM"}]}
"1.3.6.1.2.1.25.2.3.1.5" -> {:ok, [%{oid: "1.3.6.1.2.1.25.2.3.1.5.1", value: 1000}]}
"1.3.6.1.2.1.25.2.3.1.6" -> {:ok, [%{oid: "1.3.6.1.2.1.25.2.3.1.6.1", value: 750}]}
# POE sensors (empty)
"1.3.6.1.4.1.14988.1.1.15.1.1.3" -> {:error, :no_such_object}
"1.3.6.1.4.1.14988.1.1.15.1.1.5" -> {:error, :no_such_object}
"1.3.6.1.4.1.14988.1.1.15.1.1.6" -> {:error, :no_such_object}
"1.3.6.1.4.1.14988.1.1.15.1.1.7" -> {:error, :no_such_object}
# Optical sensors (empty)
"1.3.6.1.4.1.14988.1.1.16.1.1.2" -> {:error, :no_such_object}
"1.3.6.1.4.1.14988.1.1.16.1.1.3" -> {:error, :no_such_object}
"1.3.6.1.4.1.14988.1.1.16.1.1.5" -> {:error, :no_such_object}
"1.3.6.1.4.1.14988.1.1.16.1.1.7" -> {:error, :no_such_object}
"1.3.6.1.4.1.14988.1.1.16.1.1.8" -> {:error, :no_such_object}
# Entity sensors from Base
"1.3.6.1.2.1.99.1.1.1.1" -> {:ok, []}
end
end)
assert {:ok, sensors} = Mikrotik.discover_sensors(@test_opts)
# Should have temperature sensors
temp_sensors = Enum.filter(sensors, &String.contains?(&1.sensor_type, "temperature"))
assert length(temp_sensors) > 0
# Should have voltage sensors
voltage_sensors = Enum.filter(sensors, &String.contains?(&1.sensor_type, "voltage"))
assert length(voltage_sensors) > 0
# Should have fan sensors
fan_sensors = Enum.filter(sensors, &(&1.sensor_type == "fan1"))
assert length(fan_sensors) > 0
# Check one sensor in detail
cpu_temp = Enum.find(sensors, &(&1.sensor_type == "cpu_temperature"))
assert cpu_temp
assert cpu_temp.sensor_unit == "°C"
assert cpu_temp.last_value == 55.0
assert cpu_temp.status == "ok"
end
test "discovers CPU load sensors" do
# Mock health sensors (none available)
expect(SnmpMock, :get, 15, fn _, _, _ -> {:error, :no_such_object} end)
# Mock all walk calls
stub(SnmpMock, :walk, fn _, oid, _ ->
case oid do
# CPU load per core
"1.3.6.1.2.1.25.3.3.1.2" ->
{:ok,
[
%{oid: "1.3.6.1.2.1.25.3.3.1.2.1", value: 25},
%{oid: "1.3.6.1.2.1.25.3.3.1.2.2", value: 50},
%{oid: "1.3.6.1.2.1.25.3.3.1.2.3", value: 75},
%{oid: "1.3.6.1.2.1.25.3.3.1.2.4", value: 95}
]}
# Storage, POE, optical, entity as empty
_ ->
{:error, :no_such_object}
end
end)
assert {:ok, sensors} = Mikrotik.discover_sensors(@test_opts)
cpu_sensors = Enum.filter(sensors, &(&1.sensor_type == "cpu_load"))
assert length(cpu_sensors) == 4
# Check status determination
cpu1 = Enum.find(sensors, &(&1.sensor_index == "cpu1"))
assert cpu1.status == "ok"
assert cpu1.last_value == 25.0
cpu4 = Enum.find(sensors, &(&1.sensor_index == "cpu4"))
assert cpu4.status == "critical"
assert cpu4.last_value == 95.0
end
test "discovers storage sensors with proper status" do
# Mock health sensors (none)
expect(SnmpMock, :get, 15, fn _, _, _ -> {:error, :no_such_object} end)
# Mock all walk calls
stub(SnmpMock, :walk, fn _, oid, _ ->
case oid do
# CPU (empty)
"1.3.6.1.2.1.25.3.3.1.2" ->
{:ok, []}
# Storage sensors
"1.3.6.1.2.1.25.2.3.1.3" ->
{:ok,
[
%{oid: "1.3.6.1.2.1.25.2.3.1.3.1", value: "Physical memory"},
%{oid: "1.3.6.1.2.1.25.2.3.1.3.2", value: "/disk1"}
]}
"1.3.6.1.2.1.25.2.3.1.5" ->
{:ok,
[
%{oid: "1.3.6.1.2.1.25.2.3.1.5.1", value: 8192},
%{oid: "1.3.6.1.2.1.25.2.3.1.5.2", value: 10_000}
]}
"1.3.6.1.2.1.25.2.3.1.6" ->
{:ok,
[
%{oid: "1.3.6.1.2.1.25.2.3.1.6.1", value: 7168},
%{oid: "1.3.6.1.2.1.25.2.3.1.6.2", value: 9600}
]}
# POE, optical, entity as empty
_ ->
{:error, :no_such_object}
end
end)
assert {:ok, sensors} = Mikrotik.discover_sensors(@test_opts)
memory_sensor = Enum.find(sensors, &(&1.sensor_type == "memory_usage"))
assert memory_sensor
assert memory_sensor.status == "warning"
assert memory_sensor.last_value == 87.5
disk_sensor = Enum.find(sensors, &(&1.sensor_type == "disk_usage"))
assert disk_sensor
assert disk_sensor.status == "critical"
assert disk_sensor.last_value == 96.0
end
test "discovers POE sensors per port" do
# Mock health, cpu, storage as empty
expect(SnmpMock, :get, 15, fn _, _, _ -> {:error, :no_such_object} end)
# Mock all walk calls
stub(SnmpMock, :walk, fn _, oid, _ ->
case oid do
# CPU sensors
"1.3.6.1.2.1.25.3.3.1.2" ->
{:ok, []}
# Storage sensors
"1.3.6.1.2.1.25.2.3.1.3" ->
{:error, :no_such_object}
"1.3.6.1.2.1.25.2.3.1.5" ->
{:error, :no_such_object}
"1.3.6.1.2.1.25.2.3.1.6" ->
{:error, :no_such_object}
# POE sensors
"1.3.6.1.4.1.14988.1.1.15.1.1.3" ->
{:ok, [%{oid: "1.3.6.1.4.1.14988.1.1.15.1.1.3.1", value: "ether1"}]}
"1.3.6.1.4.1.14988.1.1.15.1.1.5" ->
{:ok, [%{oid: "1.3.6.1.4.1.14988.1.1.15.1.1.5.1", value: 480}]}
"1.3.6.1.4.1.14988.1.1.15.1.1.6" ->
{:ok, [%{oid: "1.3.6.1.4.1.14988.1.1.15.1.1.6.1", value: 500}]}
"1.3.6.1.4.1.14988.1.1.15.1.1.7" ->
{:ok, [%{oid: "1.3.6.1.4.1.14988.1.1.15.1.1.7.1", value: 240}]}
# Optical sensors (empty)
"1.3.6.1.4.1.14988.1.1.16.1.1.2" ->
{:error, :no_such_object}
"1.3.6.1.4.1.14988.1.1.16.1.1.3" ->
{:error, :no_such_object}
"1.3.6.1.4.1.14988.1.1.16.1.1.5" ->
{:error, :no_such_object}
"1.3.6.1.4.1.14988.1.1.16.1.1.7" ->
{:error, :no_such_object}
"1.3.6.1.4.1.14988.1.1.16.1.1.8" ->
{:error, :no_such_object}
# Entity sensors from Base
"1.3.6.1.2.1.99.1.1.1.1" ->
{:ok, []}
end
end)
assert {:ok, sensors} = Mikrotik.discover_sensors(@test_opts)
poe_sensors = Enum.filter(sensors, &String.contains?(&1.sensor_type, "poe_"))
assert length(poe_sensors) == 3
voltage = Enum.find(sensors, &(&1.sensor_type == "poe_voltage"))
assert voltage.last_value == 48.0
assert voltage.sensor_unit == "V"
end
end
describe "identify_device/1" do
test "identifies MikroTik device from sysDescr with model" do
system_info = %{
sys_descr: "RouterOS RB5009UG+S+ (stable) 7.13.2",
sys_name: "router1",
firmware_version: "7.13.2"
}
result = Mikrotik.identify_device(system_info)
assert result.manufacturer == "MikroTik"
assert result.model == "RB5009UG+S+"
assert result.firmware_version == "7.13.2"
end
test "identifies generic RouterOS device when model not found" do
system_info = %{
sys_descr: "RouterOS software version 7.12",
sys_name: "router1"
}
result = Mikrotik.identify_device(system_info)
assert result.manufacturer == "MikroTik"
assert result.model == "RouterOS Device"
end
test "extracts firmware from sysDescr when not in system_info" do
system_info = %{
sys_descr: "RouterOS RB2011 version 6.49.7"
}
result = Mikrotik.identify_device(system_info)
assert result.manufacturer == "MikroTik"
assert result.model == "RB2011"
assert result.firmware_version == "6.49.7"
end
property "always returns manufacturer and model" do
check all(sys_descr <- StreamData.string(:alphanumeric)) do
system_info = %{sys_descr: sys_descr}
result = Mikrotik.identify_device(system_info)
assert Map.has_key?(result, :manufacturer)
assert Map.has_key?(result, :model)
end
end
end
describe "sensor status determination" do
test "CPU status changes with load" do
expect(SnmpMock, :get, 15, fn _, _, _ -> {:error, :no_such_object} end)
stub(SnmpMock, :walk, fn _, oid, _ ->
case oid do
"1.3.6.1.2.1.25.3.3.1.2" ->
{:ok,
[
%{oid: "1.3.6.1.2.1.25.3.3.1.2.1", value: 60},
%{oid: "1.3.6.1.2.1.25.3.3.1.2.2", value: 85},
%{oid: "1.3.6.1.2.1.25.3.3.1.2.3", value: 95}
]}
_ ->
{:error, :no_such_object}
end
end)
{:ok, sensors} = Mikrotik.discover_sensors(@test_opts)
cpu1 = Enum.find(sensors, &(&1.sensor_index == "cpu1"))
cpu2 = Enum.find(sensors, &(&1.sensor_index == "cpu2"))
cpu3 = Enum.find(sensors, &(&1.sensor_index == "cpu3"))
assert cpu1.status == "ok"
assert cpu2.status == "warning"
assert cpu3.status == "critical"
end
test "memory status changes with usage percentage" do
expect(SnmpMock, :get, 15, fn _, _, _ -> {:error, :no_such_object} end)
stub(SnmpMock, :walk, fn _, oid, _ ->
case oid do
"1.3.6.1.2.1.25.3.3.1.2" -> {:ok, []}
"1.3.6.1.2.1.25.2.3.1.3" -> {:ok, [%{oid: "1.3.6.1.2.1.25.2.3.1.3.1", value: "memory"}]}
"1.3.6.1.2.1.25.2.3.1.5" -> {:ok, [%{oid: "1.3.6.1.2.1.25.2.3.1.5.1", value: 1000}]}
"1.3.6.1.2.1.25.2.3.1.6" -> {:ok, [%{oid: "1.3.6.1.2.1.25.2.3.1.6.1", value: 970}]}
_ -> {:error, :no_such_object}
end
end)
{:ok, sensors} = Mikrotik.discover_sensors(@test_opts)
memory = Enum.find(sensors, &(&1.sensor_type == "memory_usage"))
assert memory.status == "critical"
assert memory.last_value == 97.0
end
end
end

View file

@ -1,9 +1,25 @@
defmodule ToweropsWeb.EquipmentLiveTest do
use ToweropsWeb.ConnCase
import Mox
import Phoenix.LiveViewTest
alias Towerops.Snmp.SnmpMock
setup :register_and_log_in_user
setup :verify_on_exit!
# Set up SNMP mock globally to allow background tasks
setup :set_mox_global
setup do
# Stub SNMP mock globally to allow background task calls
Mox.stub(SnmpMock, :get, fn _target, _oid, _opts -> {:error, :timeout} end)
Mox.stub(SnmpMock, :walk, fn _target, _oid, _opts -> {:error, :timeout} end)
Mox.stub(SnmpMock, :get_bulk, fn _target, _oid, _opts -> {:error, :timeout} end)
:ok
end
setup %{user: user} do
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)