diff --git a/test/towerops/workers/discovery_worker_test.exs b/test/towerops/workers/discovery_worker_test.exs new file mode 100644 index 00000000..5dcaf071 --- /dev/null +++ b/test/towerops/workers/discovery_worker_test.exs @@ -0,0 +1,130 @@ +defmodule Towerops.Workers.DiscoveryWorkerTest do + use Towerops.DataCase, async: true + + import Mox + import Towerops.AccountsFixtures + + alias Towerops.Snmp + alias Towerops.Snmp.SnmpMock + alias Towerops.Workers.DiscoveryWorker + + setup :verify_on_exit! + + describe "perform/1" do + 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 + }) + + {:ok, device} = + Towerops.Devices.create_device(%{ + name: "Test Device", + ip_address: "192.168.1.1", + snmp_enabled: true, + snmp_version: "2c", + snmp_community: "public", + snmp_port: 161, + site_id: site.id + }) + + {:ok, device: device, org: organization} + end + + test "successfully discovers a device", %{device: device} do + # Mock SNMP responses for discovery + expect(SnmpMock, :get, 6, fn _target, oid, _opts -> + case oid do + "1.3.6.1.2.1.1.1.0" -> {:ok, "Test Device"} + "1.3.6.1.2.1.1.2.0" -> {:ok, [1, 3, 6, 1, 4, 1, 9]} + "1.3.6.1.2.1.1.3.0" -> {:ok, 12_345} + "1.3.6.1.2.1.1.4.0" -> {:ok, "admin@test.com"} + "1.3.6.1.2.1.1.5.0" -> {:ok, "test-device"} + "1.3.6.1.2.1.1.6.0" -> {:ok, "Test Location"} + end + end) + + # Mock interface discovery + expect(SnmpMock, :walk, fn _target, "1., _opts ->3.6.1.2.1.2.2.1.2" -> + {:ok, [%{oid: "1.3.6.1.2.1.2.2.1.2.1", value: "eth0"}]} + end) + + # Mock neighbor discovery (LLDP) - empty + expect(SnmpMock, :walk, fn _target, "1., _opts ->0.8802.1.1.2.1.4.1.1.4" -> + {:ok, []} + end) + + # Mock neighbor discovery (CDP) - empty + expect(SnmpMock, :walk, fn _target, "1., _opts ->3.6.1.4.1.9.9.23.1.2.1.1.4" -> + {:ok, []} + end) + + assert :ok = DiscoveryWorker.perform(device.id) + + # Verify SNMP device was created + snmp_device = Snmp.get_device(device.id) + assert snmp_device + assert snmp_device.sys_descr == "Test Device" + assert snmp_device.sys_name == "test-device" + end + + test "returns error when device not found" do + non_existent_id = Ecto.UUID.generate() + + assert {:error, :device_not_found} = DiscoveryWorker.perform(non_existent_id) + end + + test "returns error when discovery fails", %{device: device} do + # Mock SNMP failure + expect(SnmpMock, :get, 6, fn _target, _oid, _opts -> + {:error, :timeout} + end) + + assert {:error, _reason} = DiscoveryWorker.perform(device.id) + end + + test "logs appropriate messages", %{device: device} do + import ExUnit.CaptureLog + # Mock SNMP responses + expect(SnmpMock, :get, 6, fn _target, oid, _opts -> + case oid do + "1.3.6.1.2.1.1.1.0" -> {:ok, "Test"} + "1.3.6.1.2.1.1.2.0" -> {:ok, [1, 3, 6, 1]} + "1.3.6.1.2.1.1.3.0" -> {:ok, 1} + "1.3.6.1.2.1.1.4.0" -> {:ok, ""} + "1.3.6.1.2.1.1.5.0" -> {:ok, "test"} + "1.3.6.1.2.1.1.6.0" -> {:ok, ""} + end + end) + + expect(SnmpMock, :walk, 3, fn _target, _oid, _opts -> {:ok, []} end) + + log = + capture_log(fn -> + DiscoveryWorker.perform(device.id) + end) + + assert log =~ "Starting SNMP discovery for device #{device.id}" + assert log =~ "SNMP discovery completed successfully for device #{device.id}" + end + + test "logs error when discovery fails", %{device: device} do + import ExUnit.CaptureLog + + expect(SnmpMock, :get, 6, fn _target, _oid, _opts -> + {:error, :network_unreachable} + end) + + log = + capture_log(fn -> + DiscoveryWorker.perform(device.id) + end) + + assert log =~ "SNMP discovery failed for device #{device.id}" + end + end +end diff --git a/test/towerops/workers/monitor_worker_test.exs b/test/towerops/workers/monitor_worker_test.exs new file mode 100644 index 00000000..3eea6334 --- /dev/null +++ b/test/towerops/workers/monitor_worker_test.exs @@ -0,0 +1,141 @@ +defmodule Towerops.Workers.MonitorWorkerTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + + alias Towerops.Monitoring + alias Towerops.Workers.MonitorWorker + + 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 + }) + + {:ok, device} = + Towerops.Devices.create_device(%{ + name: "Test Device", + # Use localhost for reliable testing + ip_address: "127.0.0.1", + monitoring_enabled: true, + site_id: site.id + }) + + {:ok, device: device, org: organization} + end + + describe "perform/1" do + test "creates a check when monitoring is enabled", %{device: device} do + # Call perform - it will ping localhost + result = MonitorWorker.perform(device.id) + + # Should return :ok regardless of ping success/failure + assert result == :ok + + # Verify a check was created + checks = Monitoring.list_checks_for_device(device.id) + assert length(checks) == 1 + + check = hd(checks) + assert check.device_id == device.id + assert check.status in [:success, :failure] + assert check.checked_at + + # If ping succeeded, should have response time + if check.status == :success do + assert is_float(check.response_time_ms) + assert check.response_time_ms >= 0 + else + assert check.response_time_ms == nil + end + end + + test "skips check when monitoring is disabled", %{device: device} do + # Disable monitoring + device = Towerops.Devices.update_device!(device, %{monitoring_enabled: false}) + + assert :ok = MonitorWorker.perform(device.id) + + # Verify no check was created + checks = Monitoring.list_checks_for_device(device.id) + assert length(checks) == 0 + end + + test "returns error when device not found" do + non_existent_id = Ecto.UUID.generate() + + assert {:error, :device_not_found} = MonitorWorker.perform(non_existent_id) + end + + test "broadcasts update after check", %{device: device} do + # Subscribe to device topic + Phoenix.PubSub.subscribe(Towerops.PubSub, "device:#{device.id}") + + MonitorWorker.perform(device.id) + + # Verify broadcast was sent + assert_receive {:monitoring_check_updated, device_id}, 1000 + assert device_id == device.id + end + + test "creates failed check for unreachable host" 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 + }) + + # Use a non-routable IP (documentation range) + {:ok, device} = + Towerops.Devices.create_device(%{ + name: "Unreachable Device", + ip_address: "192.0.2.1", + monitoring_enabled: true, + site_id: site.id + }) + + assert :ok = MonitorWorker.perform(device.id) + + # Verify check was created as failure + checks = Monitoring.list_checks_for_device(device.id) + assert length(checks) == 1 + + check = hd(checks) + assert check.status == :failure + assert check.response_time_ms == nil + end + + test "logs appropriate messages", %{device: device} do + import ExUnit.CaptureLog + + log = + capture_log([level: :debug], fn -> + MonitorWorker.perform(device.id) + end) + + assert log =~ "Starting monitoring check for device #{device.id}" + # Will either log "is up" or "is down" depending on ping result + assert log =~ device.id + end + + test "logs debug when monitoring is disabled", %{device: device} do + import ExUnit.CaptureLog + + device = Towerops.Devices.update_device!(device, %{monitoring_enabled: false}) + + log = + capture_log([level: :debug], fn -> + MonitorWorker.perform(device.id) + end) + + assert log =~ "Device #{device.id} does not have monitoring enabled" + end + end +end diff --git a/test/towerops/workers/poll_worker_test.exs b/test/towerops/workers/poll_worker_test.exs new file mode 100644 index 00000000..e94a1ee2 --- /dev/null +++ b/test/towerops/workers/poll_worker_test.exs @@ -0,0 +1,276 @@ +defmodule Towerops.Workers.PollWorkerTest do + use Towerops.DataCase, async: true + + import Mox + import Towerops.AccountsFixtures + + alias Towerops.Snmp + alias Towerops.Snmp.SnmpMock + alias Towerops.Workers.PollWorker + + setup :verify_on_exit! + + describe "perform/1" do + 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 + }) + + {:ok, device} = + Towerops.Devices.create_device(%{ + name: "Test Device", + ip_address: "192.168.1.1", + snmp_enabled: true, + snmp_version: "2c", + snmp_community: "public", + snmp_port: 161, + site_id: site.id + }) + + snmp_device = + %Snmp.Device{} + |> Snmp.Device.changeset(%{ + device_id: device.id, + sys_name: "test-device", + sys_descr: "Test Device" + }) + |> Repo.insert!() + + sensor = + %Snmp.Sensor{} + |> Snmp.Sensor.changeset(%{ + snmp_device_id: snmp_device.id, + sensor_oid: "1.3.6.1.4.1.14988.1.1.3.10.0", + sensor_divisor: 10.0, + sensor_type: "temperature", + sensor_descr: "Temperature" + }) + |> Repo.insert!() + + interface = + %Snmp.Interface{} + |> Snmp.Interface.changeset(%{ + snmp_device_id: snmp_device.id, + if_index: 1, + if_descr: "eth0" + }) + |> Repo.insert!() + + {:ok, device: device, snmp_device: snmp_device, sensor: sensor, interface: interface} + end + + test "successfully polls a device with sensors and interfaces", %{ + device: device, + sensor: sensor, + interface: interface + } do + # Mock sensor poll + expect(SnmpMock, :get, fn _target, oid, _opts -> + case oid do + "1.3.6.1.4.1.14988.1.1.3.10.0" -> {:ok, 350} + "1.3.6.1.2.1.2.2.1.10.1" -> {:ok, 1000} + "1.3.6.1.2.1.2.2.1.16.1" -> {:ok, 2000} + "1.3.6.1.2.1.2.2.1.14.1" -> {:ok, 0} + "1.3.6.1.2.1.2.2.1.20.1" -> {:ok, 0} + "1.3.6.1.2.1.2.2.1.13.1" -> {:ok, 0} + "1.3.6.1.2.1.2.2.1.19.1" -> {:ok, 0} + _ -> {:error, :no_such_object} + end + end) + + # Mock neighbor discovery (LLDP) - empty + expect(SnmpMock, :walk, fn _target, "1., _opts ->0.8802.1.1.2.1.4.1.1.4" -> + {:ok, []} + end) + + # Mock neighbor discovery (CDP) - empty + expect(SnmpMock, :walk, fn _target, "1., _opts ->3.6.1.4.1.9.9.23.1.2.1.1.4" -> + {:ok, []} + end) + + assert :ok = PollWorker.perform(device.id) + + # Verify sensor reading was created + sensor = Towerops.Repo.reload(sensor) + # 350 / 10.0 divisor + assert sensor.last_value == 35.0 + assert sensor.last_checked_at + + # Verify interface stat was created + stats = Snmp.list_interface_stats(interface.id) + assert length(stats) == 1 + stat = hd(stats) + assert stat.if_in_octets == 1000 + assert stat.if_out_octets == 2000 + end + + test "skips poll when SNMP is disabled", %{device: device} do + device = Towerops.Devices.update_device!(device, %{snmp_enabled: false}) + + assert :ok = PollWorker.perform(device.id) + + # No SNMP calls should have been made (no expectations set) + end + + test "returns error when device not found" do + non_existent_id = Ecto.UUID.generate() + + assert {:error, :device_not_found} = PollWorker.perform(non_existent_id) + end + + test "handles missing SNMP device gracefully", %{device: device, snmp_device: snmp_device} do + # Delete SNMP device + Towerops.Repo.delete!(snmp_device) + + # Should not crash + assert :ok = PollWorker.perform(device.id) + end + + test "creates sensor reading with error status on SNMP failure", %{ + device: device, + sensor: sensor + } do + # Mock SNMP timeout + expect(SnmpMock, :get, fn _target, _oid, _opts -> + {:error, :timeout} + end) + + # Mock neighbor discovery - empty + expect(SnmpMock, :walk, 2, fn _target, _oid, _opts -> {:ok, []} end) + + PollWorker.perform(device.id) + + # Verify error reading was created + readings = Snmp.list_sensor_readings(sensor.id) + assert length(readings) == 1 + reading = hd(readings) + assert reading.status == "error" + assert reading.value == nil + end + + test "updates sensor last_value and last_checked_at", %{device: device, sensor: sensor} do + expect(SnmpMock, :get, fn _target, _oid, _opts -> {:ok, 250} end) + expect(SnmpMock, :walk, 2, fn _target, _oid, _opts -> {:ok, []} end) + + PollWorker.perform(device.id) + + sensor = Towerops.Repo.reload(sensor) + # 250 / 10.0 + assert sensor.last_value == 25.0 + assert sensor.last_checked_at + end + + test "creates interface stats for all interfaces", %{device: device, interface: interface} do + # Mock interface stats + expect(SnmpMock, :get, 6, fn _target, oid, _opts -> + case oid do + "1.3.6.1.2.1.2.2.1.10.1" -> {:ok, 1234} + "1.3.6.1.2.1.2.2.1.16.1" -> {:ok, 5678} + "1.3.6.1.2.1.2.2.1.14.1" -> {:ok, 10} + "1.3.6.1.2.1.2.2.1.20.1" -> {:ok, 20} + "1.3.6.1.2.1.2.2.1.13.1" -> {:ok, 5} + "1.3.6.1.2.1.2.2.1.19.1" -> {:ok, 3} + _ -> {:error, :no_such_object} + end + end) + + expect(SnmpMock, :walk, 2, fn _target, _oid, _opts -> {:ok, []} end) + + PollWorker.perform(device.id) + + stats = Snmp.list_interface_stats(interface.id) + assert length(stats) == 1 + stat = hd(stats) + assert stat.if_in_octets == 1234 + assert stat.if_out_octets == 5678 + assert stat.if_in_errors == 10 + assert stat.if_out_errors == 20 + assert stat.if_in_discards == 5 + assert stat.if_out_discards == 3 + end + + test "discovers and upserts neighbors", %{device: device, snmp_device: _snmp_device} do + # Mock neighbor discovery with LLDP neighbor + expect(SnmpMock, :walk, fn _target, "1., _opts ->0.8802.1.1.2.1.4.1.1.4" -> + {:ok, + [ + %{oid: "1.0.8802.1.1.2.1.4.1.1.4.0.1.1", value: <<1, 2, 3, 4, 5, 6>>}, + # Chassis ID (MAC) + %{oid: "1.0.8802.1.1.2.1.4.1.1.5.0.1.1", value: 4} + # Chassis ID subtype (MAC) + ]} + end) + + # Mock other LLDP OIDs + expect(SnmpMock, :walk, 4, fn _target, _oid, _opts -> {:ok, []} end) + + # Mock CDP (empty) + expect(SnmpMock, :walk, fn _target, "1., _opts ->3.6.1.4.1.9.9.23.1.2.1.1.4" -> + {:ok, []} + end) + + PollWorker.perform(device.id) + + # Verify neighbor was created/updated + neighbors = Snmp.list_neighbors(device.id) + # May or may not have neighbors depending on what was actually discovered + assert is_list(neighbors) + end + + test "deletes stale neighbors older than 5 minutes", %{device: device, snmp_device: snmp_device, interface: interface} do + # Create a stale neighbor (10 minutes old) + stale_time = DateTime.add(DateTime.utc_now(), -10, :minute) + + stale_neighbor = + %Snmp.Neighbor{} + |> Snmp.Neighbor.changeset(%{ + device_id: device.id, + interface_id: interface.id, + chassis_id: "old:neighbor", + remote_port_id: "port1", + last_seen_at: stale_time + }) + |> Repo.insert!() + + # Mock neighbor discovery - no neighbors found + expect(SnmpMock, :walk, 6, fn _target, _oid, _opts -> {:ok, []} end) + + PollWorker.perform(device.id) + + # Verify stale neighbor was deleted + assert Towerops.Repo.get(Towerops.Snmp.Neighbor, stale_neighbor.id) == nil + end + + test "handles non-numeric sensor values", %{device: device} do + # Mock sensor returning string instead of number + expect(SnmpMock, :get, fn _target, _oid, _opts -> + {:ok, "not a number"} + end) + + expect(SnmpMock, :walk, 2, fn _target, _oid, _opts -> {:ok, []} end) + + # Should not crash + PollWorker.perform(device.id) + end + + test "logs appropriate messages", %{device: device} do + import ExUnit.CaptureLog + + expect(SnmpMock, :get, fn _target, _oid, _opts -> {:ok, 100} end) + expect(SnmpMock, :walk, 2, fn _target, _oid, _opts -> {:ok, []} end) + + log = + capture_log(fn -> + PollWorker.perform(device.id) + end) + + assert log =~ "Starting SNMP poll for device #{device.id}" + assert log =~ "SNMP poll completed for device #{device.id}" + end + end +end