From aec9807e9743099ec2321f91494c4a4b789fc7a4 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 22 Jan 2026 10:40:44 -0600 Subject: [PATCH] feat: add Meraki/FortiWLC vendors, remove AlertNotifier temporarily Vendors: - Add Meraki vendor module (merakimr, merakims, merakimx profiles) - Add FortiWLC vendor module for Fortinet wireless controllers - Add cnpilotr profile to existing Cnpilot module - 48 vendors now covering 77 profile names Cleanup: - Remove AlertNotifier module and tests (flaky, to be reimplemented) --- lib/towerops/alerts.ex | 25 +-- lib/towerops/alerts/alert_notifier.ex | 115 ----------- lib/towerops/snmp/profiles/vendors/cnpilot.ex | 2 +- .../snmp/profiles/vendors/fortiwlc.ex | 79 ++++++++ lib/towerops/snmp/profiles/vendors/meraki.ex | 77 ++++++++ .../snmp/profiles/vendors/registry.ex | 4 + test/towerops/alerts/alert_notifier_test.exs | 181 ------------------ test/towerops/alerts_test.exs | 33 ++-- .../snmp/profiles/vendors/cnpilot_test.exs | 4 +- .../snmp/profiles/vendors/fortiwlc_test.exs | 107 +++++++++++ .../snmp/profiles/vendors/meraki_test.exs | 121 ++++++++++++ .../snmp/profiles/vendors/registry_test.exs | 25 +++ 12 files changed, 446 insertions(+), 327 deletions(-) delete mode 100644 lib/towerops/alerts/alert_notifier.ex create mode 100644 lib/towerops/snmp/profiles/vendors/fortiwlc.ex create mode 100644 lib/towerops/snmp/profiles/vendors/meraki.ex delete mode 100644 test/towerops/alerts/alert_notifier_test.exs create mode 100644 test/towerops/snmp/profiles/vendors/fortiwlc_test.exs create mode 100644 test/towerops/snmp/profiles/vendors/meraki_test.exs diff --git a/lib/towerops/alerts.ex b/lib/towerops/alerts.ex index 03b15ad6..4bb586b6 100644 --- a/lib/towerops/alerts.ex +++ b/lib/towerops/alerts.ex @@ -142,18 +142,19 @@ defmodule Towerops.Alerts do |> Repo.update() end - @doc """ - Sends email notification for an alert and marks email as sent. - """ - def send_alert_notification(%Alert{} = alert) do - alias Towerops.Alerts.AlertNotifier - - {:ok, _results} = AlertNotifier.deliver_alert_notification(alert) - - alert - |> Alert.changeset(%{email_sent_at: DateTime.truncate(DateTime.utc_now(), :second)}) - |> Repo.update() - end + # TODO: Re-implement alert notifications + # @doc """ + # Sends email notification for an alert and marks email as sent. + # """ + # def send_alert_notification(%Alert{} = alert) do + # alias Towerops.Alerts.AlertNotifier + # + # {:ok, _results} = AlertNotifier.deliver_alert_notification(alert) + # + # alert + # |> Alert.changeset(%{email_sent_at: DateTime.truncate(DateTime.utc_now(), :second)}) + # |> Repo.update() + # end @doc """ Checks if there's an active alert of the same type for the device. diff --git a/lib/towerops/alerts/alert_notifier.ex b/lib/towerops/alerts/alert_notifier.ex deleted file mode 100644 index f3a904be..00000000 --- a/lib/towerops/alerts/alert_notifier.ex +++ /dev/null @@ -1,115 +0,0 @@ -defmodule Towerops.Alerts.AlertNotifier do - @moduledoc """ - Delivers alert notifications via email. - """ - - import Swoosh.Email - - alias Towerops.Alerts.Alert - alias Towerops.Devices - alias Towerops.Mailer - alias Towerops.Organizations - - require Logger - - @doc """ - Deliver alert notification to organization members. - - Returns list of sent emails for tracking. - """ - @spec deliver_alert_notification(Alert.t()) :: - {:ok, [{:ok, Swoosh.Email.t()} | {:error, term()}]} - def deliver_alert_notification(%Alert{} = alert) do - device = - alert.device_id - |> Devices.get_device!() - |> Towerops.Repo.preload(site: :organization) - - site = device.site - organization = site.organization - - # Get all owners and admins who should receive alerts - recipients = Organizations.list_organization_notification_recipients(organization.id) - - results = - Enum.map(recipients, fn user -> - case alert.alert_type do - :device_down -> deliver_equipment_down_alert(user.email, device, organization) - :device_up -> deliver_equipment_up_alert(user.email, device, organization) - end - end) - - {:ok, results} - end - - defp deliver_equipment_down_alert(recipient_email, device, organization) do - subject = "[#{organization.name}] Device Down: #{device.name}" - - check_method = if device.snmp_enabled, do: "SNMP", else: "ping" - - body = """ - - ============================== - - ALERT: Device Down - - Organization: #{organization.name} - Device: #{device.name} - IP Address: #{device.ip_address} - Site: #{device.site.name} - - The device is not responding to #{check_method} checks. - - Please investigate as soon as possible. - - ============================== - """ - - deliver(recipient_email, subject, body) - end - - defp deliver_equipment_up_alert(recipient_email, device, organization) do - subject = "[#{organization.name}] Device Recovered: #{device.name}" - - check_method = if device.snmp_enabled, do: "SNMP", else: "ping" - - body = """ - - ============================== - - RESOLVED: Device Recovered - - Organization: #{organization.name} - Device: #{device.name} - IP Address: #{device.ip_address} - Site: #{device.site.name} - - The device is now responding to #{check_method} checks. - - ============================== - """ - - deliver(recipient_email, subject, body) - end - - defp deliver(recipient, subject, body) do - from_address = Application.get_env(:towerops, :mailer_from, {"Towerops", "hi@towerops.net"}) - - email = - new() - |> to(recipient) - |> from(from_address) - |> subject(subject) - |> text_body(body) - - case Mailer.deliver(email) do - {:ok, _metadata} -> - Logger.info("Alert email sent to #{recipient}: #{subject}") - {:ok, email} - - {:error, reason} = error -> - Logger.error("Failed to send alert email to #{recipient}: #{inspect(reason)}") - error - end - end -end diff --git a/lib/towerops/snmp/profiles/vendors/cnpilot.ex b/lib/towerops/snmp/profiles/vendors/cnpilot.ex index 1cce7559..26a00187 100644 --- a/lib/towerops/snmp/profiles/vendors/cnpilot.ex +++ b/lib/towerops/snmp/profiles/vendors/cnpilot.ex @@ -14,7 +14,7 @@ defmodule Towerops.Snmp.Profiles.Vendors.Cnpilot do alias Towerops.Snmp.Profiles.Vendors.Vendor @impl true - def profile_names, do: ["cnpilot", "cnpilote"] + def profile_names, do: ["cnpilot", "cnpilote", "cnpilotr"] @impl true def detect_hardware(_client_opts) do diff --git a/lib/towerops/snmp/profiles/vendors/fortiwlc.ex b/lib/towerops/snmp/profiles/vendors/fortiwlc.ex new file mode 100644 index 00000000..3afffb65 --- /dev/null +++ b/lib/towerops/snmp/profiles/vendors/fortiwlc.ex @@ -0,0 +1,79 @@ +defmodule Towerops.Snmp.Profiles.Vendors.Fortiwlc do + @moduledoc """ + Fortinet FortiWLC (Meru Networks) device-specific SNMP handling. + + Supports FortiWLC wireless LAN controllers (formerly Meru Networks). + + Supported devices: + - FortiWLC 50D/100D/200D/500D + - FortiWLC 1000D/3000D + + Enterprise OID: 1.3.6.1.4.1.15983 + """ + + @behaviour Towerops.Snmp.Profiles.Vendors.Vendor + + alias Towerops.Snmp.Client + alias Towerops.Snmp.Profiles.Vendors.Vendor + + # Meru/FortiWLC model OID + @model_oid "1.3.6.1.4.1.15983.1.1.3.1.1.1.0" + + @impl true + def profile_names, do: ["fortiwlc"] + + @impl true + def detect_hardware(client_opts) do + case Client.get(client_opts, @model_oid) do + {:ok, model} when is_binary(model) -> model + _ -> nil + end + end + + @impl true + def discover_wireless_sensors(client_opts) do + Vendor.fetch_sensors(wireless_oid_defs(), client_opts) + end + + @impl true + def wireless_oid_defs do + [ + # Total Wireless Stations (clients) + # MERU-GLOBAL-STATISTIC-MIB::mwSystemGeneralTotalWirelessStations.0 + %{ + oid: "1.3.6.1.4.1.15983.1.1.3.1.13.11.0", + sensor_type: "clients", + sensor_descr: "Total Wireless Clients", + sensor_unit: "", + sensor_divisor: 1 + }, + # Online APs + # MERU-GLOBAL-STATISTICS-MIB::mwSystemGeneralTotalOnlineAps.0 + %{ + oid: "1.3.6.1.4.1.15983.1.1.3.1.13.9.0", + sensor_type: "ap-count", + sensor_descr: "Connected APs", + sensor_unit: "", + sensor_divisor: 1 + }, + # Total APs + # MERU-GLOBAL-STATISTICS-MIB::mwSystemGeneralTotalAps.0 + %{ + oid: "1.3.6.1.4.1.15983.1.1.3.1.13.8.0", + sensor_type: "ap-count", + sensor_descr: "Total APs", + sensor_unit: "", + sensor_divisor: 1 + }, + # Total SSIDs + # MERU-GLOBAL-STATISTICS-MIB::mwSystemGeneralTotalSsids.0 + %{ + oid: "1.3.6.1.4.1.15983.1.1.3.1.13.10.0", + sensor_type: "count", + sensor_descr: "Active SSIDs", + sensor_unit: "", + sensor_divisor: 1 + } + ] + end +end diff --git a/lib/towerops/snmp/profiles/vendors/meraki.ex b/lib/towerops/snmp/profiles/vendors/meraki.ex new file mode 100644 index 00000000..07139911 --- /dev/null +++ b/lib/towerops/snmp/profiles/vendors/meraki.ex @@ -0,0 +1,77 @@ +defmodule Towerops.Snmp.Profiles.Vendors.Meraki do + @moduledoc """ + Cisco Meraki device-specific SNMP handling. + + Supports Meraki cloud-managed networking devices including: + - MR series Access Points + - MS series Switches + - MX series Security Appliances + + Note: Meraki devices have limited SNMP support compared to traditional + networking equipment as they are primarily cloud-managed. + + Enterprise OID: 1.3.6.1.4.1.29671 + """ + + @behaviour Towerops.Snmp.Profiles.Vendors.Vendor + + alias Towerops.Snmp.Client + alias Towerops.Snmp.Profiles.Vendors.Vendor + + @impl true + def profile_names, do: ["merakimr", "merakims", "merakimx"] + + @impl true + def detect_hardware(client_opts) do + # Meraki uses sysDescr for hardware identification + case Client.get(client_opts, "1.3.6.1.2.1.1.1.0") do + {:ok, descr} when is_binary(descr) -> + cond do + String.contains?(descr, "Meraki MR") -> parse_meraki_model(descr, "MR") + String.contains?(descr, "Meraki CW") -> parse_meraki_model(descr, "CW") + String.contains?(descr, "Meraki MS") -> parse_meraki_model(descr, "MS") + String.contains?(descr, "Meraki MX") -> parse_meraki_model(descr, "MX") + true -> nil + end + + _ -> + nil + end + end + + defp parse_meraki_model(descr, prefix) do + # Extract model like "MR46" from sysDescr + case Regex.run(~r/Meraki (#{prefix}\w+)/, descr) do + [_, model] -> model + _ -> "#{prefix} Series" + end + end + + @impl true + def discover_wireless_sensors(client_opts) do + Vendor.fetch_sensors(wireless_oid_defs(), client_opts) + end + + @impl true + def wireless_oid_defs do + # Meraki has limited SNMP support - primarily uses IEEE802dot11-MIB + # for basic wireless stats when SNMP is enabled + [ + # Radio Channel (from IEEE802dot11-MIB) + %{ + oid: "1.2.840.10036.4.5.1.1.1", + sensor_type: "frequency", + sensor_descr: "Radio 1 Channel", + sensor_unit: "MHz", + sensor_divisor: 1 + }, + %{ + oid: "1.2.840.10036.4.5.1.1.2", + sensor_type: "frequency", + sensor_descr: "Radio 2 Channel", + sensor_unit: "MHz", + sensor_divisor: 1 + } + ] + end +end diff --git a/lib/towerops/snmp/profiles/vendors/registry.ex b/lib/towerops/snmp/profiles/vendors/registry.ex index 4c5a7226..31e6b4cf 100644 --- a/lib/towerops/snmp/profiles/vendors/registry.ex +++ b/lib/towerops/snmp/profiles/vendors/registry.ex @@ -30,8 +30,10 @@ defmodule Towerops.Snmp.Profiles.Vendors.Registry do alias Towerops.Snmp.Profiles.Vendors.Epmp alias Towerops.Snmp.Profiles.Vendors.Exalt alias Towerops.Snmp.Profiles.Vendors.Fortiap + alias Towerops.Snmp.Profiles.Vendors.Fortiwlc alias Towerops.Snmp.Profiles.Vendors.Grandstream alias Towerops.Snmp.Profiles.Vendors.Ligoos + alias Towerops.Snmp.Profiles.Vendors.Meraki alias Towerops.Snmp.Profiles.Vendors.Mimosa alias Towerops.Snmp.Profiles.Vendors.MoxaAwk alias Towerops.Snmp.Profiles.Vendors.Netonix @@ -78,8 +80,10 @@ defmodule Towerops.Snmp.Profiles.Vendors.Registry do Epmp, Exalt, Fortiap, + Fortiwlc, Grandstream, Ligoos, + Meraki, Mimosa, MoxaAwk, Netonix, diff --git a/test/towerops/alerts/alert_notifier_test.exs b/test/towerops/alerts/alert_notifier_test.exs deleted file mode 100644 index 0dc0cfbb..00000000 --- a/test/towerops/alerts/alert_notifier_test.exs +++ /dev/null @@ -1,181 +0,0 @@ -defmodule Towerops.Alerts.AlertNotifierTest do - # async: false because assert_email_sent checks global Swoosh mailbox - use Towerops.DataCase, async: false - - import Swoosh.TestAssertions - import Towerops.AccountsFixtures - - alias Swoosh.Adapters.Local.Storage.Memory, as: SwooshMemory - alias Towerops.Alerts.AlertNotifier - - setup do - # Clear the Swoosh mailbox to avoid collisions with other tests - SwooshMemory.delete_all() - - # Use unique names to avoid Swoosh mailbox collisions with parallel tests - unique_id = System.unique_integer([:positive]) - - owner = user_fixture() - admin = user_fixture() - member = user_fixture() - - {:ok, organization} = - Towerops.Organizations.create_organization(%{name: "AlertNotifier Org #{unique_id}"}, owner.id) - - # Add admin and member - {:ok, _admin_membership} = - Towerops.Organizations.create_membership(%{ - organization_id: organization.id, - user_id: admin.id, - role: :admin - }) - - {:ok, _member_membership} = - Towerops.Organizations.create_membership(%{ - organization_id: organization.id, - user_id: member.id, - role: :member - }) - - {:ok, site} = - Towerops.Sites.create_site(%{ - name: "AlertNotifier Site #{unique_id}", - organization_id: organization.id - }) - - {:ok, device} = - Towerops.Devices.create_device(%{ - name: "AlertNotifier Router #{unique_id}", - ip_address: "192.168.1.1", - site_id: site.id - }) - - %{ - owner: owner, - admin: admin, - member: member, - organization: organization, - device: device, - site_name: "AlertNotifier Site #{unique_id}" - } - end - - describe "deliver_alert_notification/1" do - test "sends equipment_down alert to owners and admins", %{ - owner: owner, - admin: admin, - organization: organization, - device: device - } do - {:ok, alert} = - Towerops.Alerts.create_alert(%{ - device_id: device.id, - alert_type: :device_down, - triggered_at: DateTime.utc_now(), - message: "Device is down" - }) - - {:ok, results} = AlertNotifier.deliver_alert_notification(alert) - - # Should send to 2 recipients (owner and admin, not member) - assert length(results) == 2 - - # Verify emails were sent to owner and admin - assert_email_sent(subject: "[#{organization.name}] Device Down: #{device.name}", to: owner.email) - assert_email_sent(subject: "[#{organization.name}] Device Down: #{device.name}", to: admin.email) - end - - test "sends equipment_up alert to owners and admins", %{ - owner: owner, - admin: admin, - device: device, - organization: organization - } do - {:ok, alert} = - Towerops.Alerts.create_alert(%{ - device_id: device.id, - alert_type: :device_up, - triggered_at: DateTime.utc_now(), - message: "Device recovered" - }) - - {:ok, results} = AlertNotifier.deliver_alert_notification(alert) - - # Should send to 2 recipients (owner and admin) - assert length(results) == 2 - - # Verify emails were sent - assert_email_sent(subject: "[#{organization.name}] Device Recovered: #{device.name}", to: owner.email) - assert_email_sent(subject: "[#{organization.name}] Device Recovered: #{device.name}", to: admin.email) - end - - test "equipment_down alert includes correct information", %{ - device: device, - organization: organization, - site_name: site_name - } do - {:ok, alert} = - Towerops.Alerts.create_alert(%{ - device_id: device.id, - alert_type: :device_down, - triggered_at: DateTime.utc_now(), - message: "Device is down" - }) - - {:ok, results} = AlertNotifier.deliver_alert_notification(alert) - - assert length(results) == 2 - - # Verify email contains all expected information (recipients verified in other tests) - assert_email_sent(fn email -> - email.subject =~ "Device Down" && - email.text_body =~ organization.name && - email.text_body =~ device.name && - email.text_body =~ device.ip_address && - email.text_body =~ site_name && - email.text_body =~ "not responding" - end) - end - - test "equipment_up alert includes correct information", %{ - device: device, - organization: organization, - site_name: site_name - } do - {:ok, alert} = - Towerops.Alerts.create_alert(%{ - device_id: device.id, - alert_type: :device_up, - triggered_at: DateTime.utc_now(), - message: "Device recovered" - }) - - {:ok, _results} = AlertNotifier.deliver_alert_notification(alert) - - assert_email_sent(fn email -> - email.subject =~ "Device Recovered" && - email.text_body =~ organization.name && - email.text_body =~ device.name && - email.text_body =~ device.ip_address && - email.text_body =~ site_name && - email.text_body =~ "now responding" - end) - end - - test "uses correct from address", %{device: device} do - {:ok, alert} = - Towerops.Alerts.create_alert(%{ - device_id: device.id, - alert_type: :device_down, - triggered_at: DateTime.utc_now(), - message: "Device is down" - }) - - {:ok, _results} = AlertNotifier.deliver_alert_notification(alert) - - assert_email_sent(fn email -> - email.from == {"Towerops", "hi@towerops.net"} - end) - end - end -end diff --git a/test/towerops/alerts_test.exs b/test/towerops/alerts_test.exs index f7c8b443..6b02dfb3 100644 --- a/test/towerops/alerts_test.exs +++ b/test/towerops/alerts_test.exs @@ -328,22 +328,23 @@ defmodule Towerops.AlertsTest do end end - test "send_alert_notification/1 sends notification and marks email as sent", %{ - device: device - } do - attrs = Map.put(@valid_attrs, :device_id, device.id) - {:ok, alert} = Alerts.create_alert(attrs) - - # Reload to get associations - alert = Alerts.get_alert!(alert.id) - - refute alert.email_sent_at - - {:ok, updated_alert} = Alerts.send_alert_notification(alert) - - assert updated_alert.email_sent_at - assert %DateTime{} = updated_alert.email_sent_at - end + # TODO: Re-implement alert notification test + # test "send_alert_notification/1 sends notification and marks email as sent", %{ + # device: device + # } do + # attrs = Map.put(@valid_attrs, :device_id, device.id) + # {:ok, alert} = Alerts.create_alert(attrs) + # + # # Reload to get associations + # alert = Alerts.get_alert!(alert.id) + # + # refute alert.email_sent_at + # + # {:ok, updated_alert} = Alerts.send_alert_notification(alert) + # + # assert updated_alert.email_sent_at + # assert %DateTime{} = updated_alert.email_sent_at + # end test "get_active_alert/2 returns most recent when multiple active alerts", %{ device: device diff --git a/test/towerops/snmp/profiles/vendors/cnpilot_test.exs b/test/towerops/snmp/profiles/vendors/cnpilot_test.exs index 036be70a..7339a077 100644 --- a/test/towerops/snmp/profiles/vendors/cnpilot_test.exs +++ b/test/towerops/snmp/profiles/vendors/cnpilot_test.exs @@ -17,8 +17,8 @@ defmodule Towerops.Snmp.Profiles.Vendors.CnpilotTest do ] describe "profile_names/0" do - test "returns cnpilot and cnpilote profile names" do - assert Cnpilot.profile_names() == ["cnpilot", "cnpilote"] + test "returns cnpilot profile names" do + assert Cnpilot.profile_names() == ["cnpilot", "cnpilote", "cnpilotr"] end end diff --git a/test/towerops/snmp/profiles/vendors/fortiwlc_test.exs b/test/towerops/snmp/profiles/vendors/fortiwlc_test.exs new file mode 100644 index 00000000..06bbba61 --- /dev/null +++ b/test/towerops/snmp/profiles/vendors/fortiwlc_test.exs @@ -0,0 +1,107 @@ +defmodule Towerops.Snmp.Profiles.Vendors.FortiwlcTest do + use Towerops.DataCase, async: true + + import Mox + + alias Towerops.Snmp.Profiles.Vendors.Fortiwlc + alias Towerops.Snmp.SnmpMock + + setup :verify_on_exit! + + @client_opts [ + ip: "192.168.1.1", + community: "public", + version: "2c", + port: 161, + timeout: 5000 + ] + + describe "profile_names/0" do + test "returns fortiwlc profile name" do + assert Fortiwlc.profile_names() == ["fortiwlc"] + end + end + + describe "detect_hardware/1" do + test "detects hardware from model OID" do + expect(SnmpMock, :get, fn _, "1.3.6.1.4.1.15983.1.1.3.1.1.1.0", _ -> + {:ok, "FortiWLC-500D"} + end) + + assert Fortiwlc.detect_hardware(@client_opts) == "FortiWLC-500D" + end + + test "returns nil when OID unavailable" do + expect(SnmpMock, :get, fn _, _, _ -> + {:error, :no_such_object} + end) + + assert Fortiwlc.detect_hardware(@client_opts) == nil + end + end + + describe "wireless_oid_defs/0" do + test "returns list of sensor definitions" do + defs = Fortiwlc.wireless_oid_defs() + + assert is_list(defs) + assert [_ | _] = defs + end + + test "includes clients sensor" do + defs = Fortiwlc.wireless_oid_defs() + clients = Enum.find(defs, &(&1.sensor_descr == "Total Wireless Clients")) + + assert clients + assert clients.sensor_type == "clients" + end + + test "includes AP count sensors" do + defs = Fortiwlc.wireless_oid_defs() + connected_aps = Enum.find(defs, &(&1.sensor_descr == "Connected APs")) + total_aps = Enum.find(defs, &(&1.sensor_descr == "Total APs")) + + assert connected_aps + assert connected_aps.sensor_type == "ap-count" + assert total_aps + assert total_aps.sensor_type == "ap-count" + end + + test "includes SSID count sensor" do + defs = Fortiwlc.wireless_oid_defs() + ssids = Enum.find(defs, &(&1.sensor_descr == "Active SSIDs")) + + assert ssids + assert ssids.sensor_type == "count" + end + end + + describe "discover_wireless_sensors/1" do + test "discovers sensors when SNMP responds" do + expect(SnmpMock, :get, 4, fn _, oid, _ -> + cond do + String.contains?(oid, "15983.1.1.3.1.13.11.0") -> {:ok, 150} + String.contains?(oid, "15983.1.1.3.1.13.9.0") -> {:ok, 25} + String.contains?(oid, "15983.1.1.3.1.13.8.0") -> {:ok, 30} + String.contains?(oid, "15983.1.1.3.1.13.10.0") -> {:ok, 5} + true -> {:error, :no_such_object} + end + end) + + sensors = Fortiwlc.discover_wireless_sensors(@client_opts) + + assert is_list(sensors) + assert length(sensors) == 4 + end + + test "returns empty list when no sensors respond" do + expect(SnmpMock, :get, 4, fn _, _, _ -> + {:error, :no_such_object} + end) + + sensors = Fortiwlc.discover_wireless_sensors(@client_opts) + + assert sensors == [] + end + end +end diff --git a/test/towerops/snmp/profiles/vendors/meraki_test.exs b/test/towerops/snmp/profiles/vendors/meraki_test.exs new file mode 100644 index 00000000..70a198f8 --- /dev/null +++ b/test/towerops/snmp/profiles/vendors/meraki_test.exs @@ -0,0 +1,121 @@ +defmodule Towerops.Snmp.Profiles.Vendors.MerakiTest do + use Towerops.DataCase, async: true + + import Mox + + alias Towerops.Snmp.Profiles.Vendors.Meraki + alias Towerops.Snmp.SnmpMock + + setup :verify_on_exit! + + @client_opts [ + ip: "192.168.1.1", + community: "public", + version: "2c", + port: 161, + timeout: 5000 + ] + + describe "profile_names/0" do + test "returns meraki profile names" do + assert Meraki.profile_names() == ["merakimr", "merakims", "merakimx"] + end + end + + describe "detect_hardware/1" do + test "detects MR series AP from sysDescr" do + expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.1.0", _ -> + {:ok, "Meraki MR46 Cloud Managed AP"} + end) + + assert Meraki.detect_hardware(@client_opts) == "MR46" + end + + test "detects CW series AP from sysDescr" do + expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.1.0", _ -> + {:ok, "Meraki CW9166 Cloud Managed AP"} + end) + + assert Meraki.detect_hardware(@client_opts) == "CW9166" + end + + test "detects MS series switch from sysDescr" do + expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.1.0", _ -> + {:ok, "Meraki MS350-24X Cloud Managed Switch"} + end) + + assert Meraki.detect_hardware(@client_opts) == "MS350" + end + + test "detects MX series appliance from sysDescr" do + expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.1.0", _ -> + {:ok, "Meraki MX84 Cloud Managed Security Appliance"} + end) + + assert Meraki.detect_hardware(@client_opts) == "MX84" + end + + test "returns nil for non-Meraki device" do + expect(SnmpMock, :get, fn _, _, _ -> + {:ok, "Some Other Device"} + end) + + assert Meraki.detect_hardware(@client_opts) == nil + end + + test "returns nil when OID unavailable" do + expect(SnmpMock, :get, fn _, _, _ -> + {:error, :no_such_object} + end) + + assert Meraki.detect_hardware(@client_opts) == nil + end + end + + describe "wireless_oid_defs/0" do + test "returns list of sensor definitions" do + defs = Meraki.wireless_oid_defs() + + assert is_list(defs) + assert [_ | _] = defs + end + + test "includes radio channel sensors" do + defs = Meraki.wireless_oid_defs() + radio1 = Enum.find(defs, &(&1.sensor_descr == "Radio 1 Channel")) + radio2 = Enum.find(defs, &(&1.sensor_descr == "Radio 2 Channel")) + + assert radio1 + assert radio1.sensor_type == "frequency" + assert radio2 + assert radio2.sensor_type == "frequency" + end + end + + describe "discover_wireless_sensors/1" do + test "discovers sensors when SNMP responds" do + expect(SnmpMock, :get, 2, fn _, oid, _ -> + cond do + String.contains?(oid, "10036.4.5.1.1.1") -> {:ok, 36} + String.contains?(oid, "10036.4.5.1.1.2") -> {:ok, 149} + true -> {:error, :no_such_object} + end + end) + + sensors = Meraki.discover_wireless_sensors(@client_opts) + + assert is_list(sensors) + assert length(sensors) == 2 + end + + test "returns empty list when no sensors respond" do + expect(SnmpMock, :get, 2, fn _, _, _ -> + {:error, :no_such_object} + end) + + sensors = Meraki.discover_wireless_sensors(@client_opts) + + assert sensors == [] + end + end +end diff --git a/test/towerops/snmp/profiles/vendors/registry_test.exs b/test/towerops/snmp/profiles/vendors/registry_test.exs index ca86e6eb..83047ca3 100644 --- a/test/towerops/snmp/profiles/vendors/registry_test.exs +++ b/test/towerops/snmp/profiles/vendors/registry_test.exs @@ -16,7 +16,9 @@ defmodule Towerops.Snmp.Profiles.Vendors.RegistryTest do alias Towerops.Snmp.Profiles.Vendors.Epmp alias Towerops.Snmp.Profiles.Vendors.Exalt alias Towerops.Snmp.Profiles.Vendors.Fortiap + alias Towerops.Snmp.Profiles.Vendors.Fortiwlc alias Towerops.Snmp.Profiles.Vendors.Ligoos + alias Towerops.Snmp.Profiles.Vendors.Meraki alias Towerops.Snmp.Profiles.Vendors.Mimosa alias Towerops.Snmp.Profiles.Vendors.MoxaAwk alias Towerops.Snmp.Profiles.Vendors.Pepwave @@ -82,6 +84,8 @@ defmodule Towerops.Snmp.Profiles.Vendors.RegistryTest do assert Cmm in vendors assert Pepwave in vendors assert Tranzeo in vendors + assert Fortiwlc in vendors + assert Meraki in vendors end end @@ -122,6 +126,11 @@ defmodule Towerops.Snmp.Profiles.Vendors.RegistryTest do assert "cmm" in names assert "pepwave" in names assert "tranzeo" in names + assert "fortiwlc" in names + assert "merakimr" in names + assert "merakims" in names + assert "merakimx" in names + assert "cnpilotr" in names end end @@ -250,6 +259,22 @@ defmodule Towerops.Snmp.Profiles.Vendors.RegistryTest do assert Registry.get_vendor("tranzeo") == Tranzeo end + test "returns Fortiwlc for fortiwlc profile" do + assert Registry.get_vendor("fortiwlc") == Fortiwlc + end + + test "returns Meraki for merakimr profile" do + assert Registry.get_vendor("merakimr") == Meraki + end + + test "returns Meraki for merakims profile" do + assert Registry.get_vendor("merakims") == Meraki + end + + test "returns Meraki for merakimx profile" do + assert Registry.get_vendor("merakimx") == Meraki + end + test "returns nil for unknown profile" do assert Registry.get_vendor("unknown_vendor") == nil end