diff --git a/config/test.exs b/config/test.exs index be461258..c8b9ea28 100644 --- a/config/test.exs +++ b/config/test.exs @@ -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 diff --git a/lib/towerops/snmp/client.ex b/lib/towerops/snmp/client.ex index f26532ea..f64b8ba6 100644 --- a/lib/towerops/snmp/client.ex +++ b/lib/towerops/snmp/client.ex @@ -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 diff --git a/lib/towerops/snmp/discovery.ex b/lib/towerops/snmp/discovery.ex index 30b35e6e..6be2ad67 100644 --- a/lib/towerops/snmp/discovery.ex +++ b/lib/towerops/snmp/discovery.ex @@ -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}") diff --git a/lib/towerops/snmp/profiles/base.ex b/lib/towerops/snmp/profiles/base.ex index dc039eea..d8255a0a 100644 --- a/lib/towerops/snmp/profiles/base.ex +++ b/lib/towerops/snmp/profiles/base.ex @@ -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 diff --git a/lib/towerops/snmp/snmp_behaviour.ex b/lib/towerops/snmp/snmp_behaviour.ex new file mode 100644 index 00000000..6bdf2850 --- /dev/null +++ b/lib/towerops/snmp/snmp_behaviour.ex @@ -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 diff --git a/mix.exs b/mix.exs index 47c6fb65..b0ecd07c 100644 --- a/mix.exs +++ b/mix.exs @@ -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 diff --git a/mix.lock b/mix.lock index c54481b0..df8bb5d3 100644 --- a/mix.lock +++ b/mix.lock @@ -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"}, diff --git a/test/test_helper.exs b/test/test_helper.exs index fca72b06..aba79d60 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -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) diff --git a/test/towerops/snmp/client_test.exs b/test/towerops/snmp/client_test.exs new file mode 100644 index 00000000..64eacd53 --- /dev/null +++ b/test/towerops/snmp/client_test.exs @@ -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 diff --git a/test/towerops/snmp/discovery_test.exs b/test/towerops/snmp/discovery_test.exs new file mode 100644 index 00000000..27233bac --- /dev/null +++ b/test/towerops/snmp/discovery_test.exs @@ -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 diff --git a/test/towerops/snmp/poller_test.exs b/test/towerops/snmp/poller_test.exs new file mode 100644 index 00000000..f22bb70a --- /dev/null +++ b/test/towerops/snmp/poller_test.exs @@ -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 diff --git a/test/towerops/snmp/profiles/base_test.exs b/test/towerops/snmp/profiles/base_test.exs new file mode 100644 index 00000000..7d5bb17e --- /dev/null +++ b/test/towerops/snmp/profiles/base_test.exs @@ -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 diff --git a/test/towerops/snmp/profiles/mikrotik_test.exs b/test/towerops/snmp/profiles/mikrotik_test.exs new file mode 100644 index 00000000..82426a88 --- /dev/null +++ b/test/towerops/snmp/profiles/mikrotik_test.exs @@ -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 diff --git a/test/towerops_web/live/equipment_live_test.exs b/test/towerops_web/live/equipment_live_test.exs index 2bdc1929..cee40ecb 100644 --- a/test/towerops_web/live/equipment_live_test.exs +++ b/test/towerops_web/live/equipment_live_test.exs @@ -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)