towerops/test/towerops/snmp_test.exs
Graham McIntire d7741cdc0e
feat: implement unified checks system (Phase 1-4)
This commit implements the unified checks architecture that consolidates
SNMP monitoring with HTTP/TCP/DNS checks under a single "check" abstraction,
enabling consistent graphing, alerting, and management across all check types.

Database Changes:
- Add source_type and source_id to checks table for tracking auto-discovered
  vs manually created checks
- Add value field to check_results for storing numeric sensor readings
- Maintain backward compatibility with existing check_results data

New SNMP Executors:
- SnmpSensorExecutor: Poll sensor OIDs and return formatted values with
  status determination (OK/WARNING/CRITICAL based on limits)
- SnmpInterfaceExecutor: Poll interface stats (bandwidth, packets, errors)
- SnmpProcessorExecutor: Poll CPU/processor usage
- SnmpStorageExecutor: Poll disk/memory usage with percentage calculations

Check Execution Worker:
- CheckExecutorWorker: Unified Oban worker that dispatches to appropriate
  executor based on check_type (snmp_sensor, snmp_interface, http, tcp, etc.)
- Self-schedules next execution with distributed polling offsets
- Records results in check_results TimescaleDB hypertable
- Updates check state (OK/WARNING/CRITICAL/UNKNOWN)

Discovery Integration:
- Auto-creates checks during SNMP discovery for sensors, interfaces,
  processors, and storage
- Links checks to source entities via source_id for data lookup
- Enables/disables checks based on discovery results

UI Enhancements:
- Checks tab on device detail page with grouped display
- FormComponent for adding manual HTTP/TCP/DNS checks
- Empty state with "Run Discovery" prompt
- Check status badges and last checked times

Graphing:
- Update GraphLive to accept check_id parameter
- Query check_results table for time-series data
- Support all check types (SNMP, HTTP response times, etc.)

Testing:
- Comprehensive test suite for SnmpSensorExecutor (5 tests)
- Test suite for CheckExecutorWorker (7 tests)
- Test coverage for discovery check creation (6 tests)
- Remove deprecated monitoring_test.exs testing old API

Bug Fixes:
- Fix SNMP executors reading credentials from correct Device schema fields
  (device.snmp_version instead of device.snmp_device.version)
- Update agent channel test to query MonitoringCheck table directly

Code Quality:
- Extract add_snmp_credentials helper to reduce cyclomatic complexity
- Use map-based lookups for sensor formatting and check type grouping
- Apply pattern matching in dispatcher to reduce complexity
- All credo checks passing with no issues

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-12 16:58:40 -06:00

2885 lines
83 KiB
Elixir

defmodule Towerops.SnmpTest do
use Towerops.DataCase
import Towerops.AccountsFixtures
alias Towerops.Snmp
alias Towerops.Snmp.ArpEntry
alias Towerops.Snmp.Device
alias Towerops.Snmp.Interface
alias Towerops.Snmp.InterfaceStat
alias Towerops.Snmp.MacAddress
alias Towerops.Snmp.Neighbor
alias Towerops.Snmp.Processor
alias Towerops.Snmp.Sensor
alias Towerops.Snmp.SensorReading
alias Towerops.Snmp.SnmpMock
alias Towerops.Snmp.Storage
alias Towerops.Snmp.StorageReading
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 Router",
ip_address: "192.168.1.1",
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public",
snmp_port: 161,
site_id: site.id,
organization_id: organization.id
})
snmp_device =
%Device{}
|> Device.changeset(%{
device_id: device.id,
sys_name: "test-router",
sys_descr: "Test Device"
})
|> Repo.insert!()
%{device: device, snmp_device: snmp_device, organization: organization}
end
describe "test_connection/4" do
import Mox
setup :verify_on_exit!
test "delegates to Client.test_connection" do
expect(SnmpMock, :get, fn _, _, _ ->
{:ok, 12_345}
end)
result = Snmp.test_connection("192.168.1.1", "public", "2c")
case result do
{:ok, _} -> assert true
{:error, _} -> assert true
end
end
test "accepts custom port" do
expect(SnmpMock, :get, fn _, _, _ ->
{:ok, 12_345}
end)
result = Snmp.test_connection("192.168.1.1", "public", "2c", 1161)
case result do
{:ok, _} -> assert true
{:error, _} -> assert true
end
end
end
describe "get_device/1" do
test "returns device for device_id", %{device: device, snmp_device: snmp_device} do
found_device = Snmp.get_device(device.id)
assert found_device.id == snmp_device.id
assert found_device.sys_name == "test-router"
end
test "returns nil for non-existent device_id" do
assert Snmp.get_device(Ecto.UUID.generate()) == nil
end
end
describe "get_device_with_associations/1" do
test "returns device with preloaded associations", %{device: device, snmp_device: snmp_device} do
found_device = Snmp.get_device_with_associations(device.id)
assert found_device.id == snmp_device.id
assert Ecto.assoc_loaded?(found_device.interfaces)
assert Ecto.assoc_loaded?(found_device.sensors)
end
test "returns nil for non-existent device_id" do
assert Snmp.get_device_with_associations(Ecto.UUID.generate()) == nil
end
end
describe "list_interfaces/1" do
test "returns all interfaces for a device ordered by if_index", %{device: _device, snmp_device: snmp_device} do
interface1 =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 2,
if_name: "eth1"
})
|> Repo.insert!()
interface2 =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
interfaces = Snmp.list_interfaces(snmp_device.id)
assert length(interfaces) == 2
assert hd(interfaces).id == interface2.id
assert List.last(interfaces).id == interface1.id
end
test "returns empty list for device with no interfaces", %{device: _device, snmp_device: snmp_device} do
assert Snmp.list_interfaces(snmp_device.id) == []
end
end
describe "list_monitored_interfaces/1" do
test "returns only monitored interfaces", %{device: _device, snmp_device: snmp_device} do
monitored =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0",
monitored: true
})
|> Repo.insert!()
_unmonitored =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 2,
if_name: "eth1",
monitored: false
})
|> Repo.insert!()
interfaces = Snmp.list_monitored_interfaces(snmp_device.id)
assert length(interfaces) == 1
assert hd(interfaces).id == monitored.id
end
end
describe "get_interface/1" do
test "returns interface by id", %{device: _device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
found = Snmp.get_interface(interface.id)
assert found.id == interface.id
assert found.if_name == "eth0"
end
test "returns nil for non-existent id" do
assert Snmp.get_interface(Ecto.UUID.generate()) == nil
end
end
describe "update_interface/2" do
test "updates interface attributes", %{device: _device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0",
monitored: false
})
|> Repo.insert!()
{:ok, updated} = Snmp.update_interface(interface, %{monitored: true})
assert updated.monitored == true
end
test "returns error for invalid attributes", %{device: _device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
{:error, changeset} = Snmp.update_interface(interface, %{if_index: nil})
assert changeset.errors[:if_index]
end
end
describe "list_sensors/1" do
test "returns all sensors for a device ordered by type and index", %{device: _device, snmp_device: snmp_device} do
sensor1 =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4"
})
|> Repo.insert!()
sensor2 =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "power",
sensor_index: "2",
sensor_oid: "1.2.3.5"
})
|> Repo.insert!()
sensors = Snmp.list_sensors(snmp_device.id)
assert length(sensors) == 2
assert sensor1.id in Enum.map(sensors, & &1.id)
assert sensor2.id in Enum.map(sensors, & &1.id)
end
test "returns empty list for device with no sensors", %{device: _device, snmp_device: snmp_device} do
assert Snmp.list_sensors(snmp_device.id) == []
end
end
describe "list_monitored_sensors/1" do
test "returns only monitored sensors", %{device: _device, snmp_device: snmp_device} do
monitored =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4",
monitored: true
})
|> Repo.insert!()
_unmonitored =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "power",
sensor_index: "2",
sensor_oid: "1.2.3.5",
monitored: false
})
|> Repo.insert!()
sensors = Snmp.list_monitored_sensors(snmp_device.id)
assert length(sensors) == 1
assert hd(sensors).id == monitored.id
end
end
describe "list_sensors_by_type/1" do
test "groups sensors by type", %{device: _device, snmp_device: snmp_device} do
_temp1 =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4"
})
|> Repo.insert!()
_temp2 =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "2",
sensor_oid: "1.2.3.5"
})
|> Repo.insert!()
_power =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "power",
sensor_index: "3",
sensor_oid: "1.2.3.6"
})
|> Repo.insert!()
grouped = Snmp.list_sensors_by_type(snmp_device.id)
assert Map.has_key?(grouped, "temperature")
assert Map.has_key?(grouped, "power")
assert length(grouped["temperature"]) == 2
assert length(grouped["power"]) == 1
end
test "returns empty map for device with no sensors", %{device: _device, snmp_device: snmp_device} do
assert Snmp.list_sensors_by_type(snmp_device.id) == %{}
end
end
describe "get_sensor/1" do
test "returns sensor by id", %{device: _device, snmp_device: snmp_device} do
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4"
})
|> Repo.insert!()
found = Snmp.get_sensor(sensor.id)
assert found.id == sensor.id
assert found.sensor_type == "temperature"
end
test "returns nil for non-existent id" do
assert Snmp.get_sensor(Ecto.UUID.generate()) == nil
end
end
describe "update_sensor/2" do
test "updates sensor attributes", %{device: _device, snmp_device: snmp_device} do
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4",
monitored: false
})
|> Repo.insert!()
{:ok, updated} = Snmp.update_sensor(sensor, %{monitored: true})
assert updated.monitored == true
end
test "returns error for invalid attributes", %{device: _device, snmp_device: snmp_device} do
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4"
})
|> Repo.insert!()
{:error, changeset} = Snmp.update_sensor(sensor, %{sensor_oid: nil})
assert changeset.errors[:sensor_oid]
end
end
describe "get_sensor_readings/2" do
test "returns recent readings for a sensor", %{device: _device, snmp_device: snmp_device} do
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4"
})
|> Repo.insert!()
for i <- 1..5 do
%SensorReading{}
|> SensorReading.changeset(%{
sensor_id: sensor.id,
value: 20.0 + i,
status: "ok",
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
end
readings = Snmp.get_sensor_readings(sensor.id)
assert length(readings) == 5
end
test "respects limit option", %{device: _device, snmp_device: snmp_device} do
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4"
})
|> Repo.insert!()
for i <- 1..10 do
%SensorReading{}
|> SensorReading.changeset(%{
sensor_id: sensor.id,
value: 20.0 + i,
status: "ok",
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
end
readings = Snmp.get_sensor_readings(sensor.id, limit: 3)
assert length(readings) == 3
end
test "respects since option", %{device: _device, snmp_device: snmp_device} do
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4"
})
|> Repo.insert!()
past = DateTime.add(DateTime.utc_now(), -3600, :second)
recent = DateTime.add(DateTime.utc_now(), -60, :second)
%SensorReading{}
|> SensorReading.changeset(%{
sensor_id: sensor.id,
value: 20.0,
status: "ok",
checked_at: past
})
|> Repo.insert!()
%SensorReading{}
|> SensorReading.changeset(%{
sensor_id: sensor.id,
value: 25.0,
status: "ok",
checked_at: recent
})
|> Repo.insert!()
since_time = DateTime.add(DateTime.utc_now(), -120, :second)
readings = Snmp.get_sensor_readings(sensor.id, since: since_time)
assert length(readings) == 1
assert hd(readings).value == 25.0
end
test "returns empty list for sensor with no readings", %{device: _device, snmp_device: snmp_device} do
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4"
})
|> Repo.insert!()
assert Snmp.get_sensor_readings(sensor.id) == []
end
end
describe "get_latest_sensor_reading/1" do
test "returns most recent reading", %{device: _device, snmp_device: snmp_device} do
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4"
})
|> Repo.insert!()
old_reading =
%SensorReading{}
|> SensorReading.changeset(%{
sensor_id: sensor.id,
value: 20.0,
status: "ok",
checked_at: DateTime.add(DateTime.utc_now(), -3600, :second)
})
|> Repo.insert!()
new_reading =
%SensorReading{}
|> SensorReading.changeset(%{
sensor_id: sensor.id,
value: 25.0,
status: "ok",
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
latest = Snmp.get_latest_sensor_reading(sensor.id)
assert latest.id == new_reading.id
refute latest.id == old_reading.id
end
test "returns nil for sensor with no readings", %{device: _device, snmp_device: snmp_device} do
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4"
})
|> Repo.insert!()
assert Snmp.get_latest_sensor_reading(sensor.id) == nil
end
end
describe "get_interface_stats/2" do
test "returns recent stats for an interface", %{device: _device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
for i <- 1..5 do
%InterfaceStat{}
|> InterfaceStat.changeset(%{
interface_id: interface.id,
if_in_octets: i * 1000,
if_out_octets: i * 2000,
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
end
stats = Snmp.get_interface_stats(interface.id)
assert length(stats) == 5
end
test "respects limit option", %{device: _device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
for i <- 1..10 do
%InterfaceStat{}
|> InterfaceStat.changeset(%{
interface_id: interface.id,
if_in_octets: i * 1000,
if_out_octets: i * 2000,
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
end
stats = Snmp.get_interface_stats(interface.id, limit: 3)
assert length(stats) == 3
end
test "respects since option", %{device: _device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
past = DateTime.add(DateTime.utc_now(), -3600, :second)
recent = DateTime.add(DateTime.utc_now(), -60, :second)
%InterfaceStat{}
|> InterfaceStat.changeset(%{
interface_id: interface.id,
if_in_octets: 1000,
if_out_octets: 2000,
checked_at: past
})
|> Repo.insert!()
%InterfaceStat{}
|> InterfaceStat.changeset(%{
interface_id: interface.id,
if_in_octets: 5000,
if_out_octets: 6000,
checked_at: recent
})
|> Repo.insert!()
since_time = DateTime.add(DateTime.utc_now(), -120, :second)
stats = Snmp.get_interface_stats(interface.id, since: since_time)
assert length(stats) == 1
assert hd(stats).if_in_octets == 5000
end
test "returns empty list for interface with no stats", %{device: _device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
assert Snmp.get_interface_stats(interface.id) == []
end
end
describe "get_latest_interface_stat/1" do
test "returns most recent stat", %{device: _device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
old_stat =
%InterfaceStat{}
|> InterfaceStat.changeset(%{
interface_id: interface.id,
if_in_octets: 1000,
if_out_octets: 2000,
checked_at: DateTime.add(DateTime.utc_now(), -3600, :second)
})
|> Repo.insert!()
new_stat =
%InterfaceStat{}
|> InterfaceStat.changeset(%{
interface_id: interface.id,
if_in_octets: 5000,
if_out_octets: 6000,
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
latest = Snmp.get_latest_interface_stat(interface.id)
assert latest.id == new_stat.id
refute latest.id == old_stat.id
end
test "returns nil for interface with no stats", %{device: _device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
assert Snmp.get_latest_interface_stat(interface.id) == nil
end
end
describe "create_sensor_reading/1" do
test "creates a sensor reading with valid attributes", %{device: _device, snmp_device: snmp_device} do
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4"
})
|> Repo.insert!()
{:ok, reading} =
Snmp.create_sensor_reading(%{
sensor_id: sensor.id,
value: 25.5,
status: "ok",
checked_at: DateTime.utc_now()
})
assert reading.sensor_id == sensor.id
assert reading.value == 25.5
assert reading.status == "ok"
end
test "returns error with invalid attributes" do
{:error, changeset} = Snmp.create_sensor_reading(%{value: "not a number"})
assert changeset.errors[:sensor_id]
end
end
describe "create_interface_stat/1" do
test "creates an interface stat with valid attributes", %{device: _device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
{:ok, stat} =
Snmp.create_interface_stat(%{
interface_id: interface.id,
if_in_octets: 1000,
if_out_octets: 2000,
if_in_errors: 0,
if_out_errors: 0,
if_in_discards: 0,
if_out_discards: 0,
checked_at: DateTime.utc_now()
})
assert stat.interface_id == interface.id
assert stat.if_in_octets == 1000
assert stat.if_out_octets == 2000
end
test "returns error with invalid attributes" do
{:error, changeset} = Snmp.create_interface_stat(%{if_in_octets: "not a number"})
assert changeset.errors[:interface_id]
end
end
describe "get_latest_sensor_readings_batch/1" do
test "returns empty map for empty sensor list", %{device: _device, snmp_device: _snmp_device} do
assert Snmp.get_latest_sensor_readings_batch([]) == %{}
end
test "returns readings for multiple sensors", %{device: _device, snmp_device: snmp_device} do
sensor1 =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4"
})
|> Repo.insert!()
sensor2 =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "power",
sensor_index: "2",
sensor_oid: "1.2.3.5"
})
|> Repo.insert!()
reading1 =
%SensorReading{}
|> SensorReading.changeset(%{
sensor_id: sensor1.id,
value: 25.0,
status: "ok",
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
reading2 =
%SensorReading{}
|> SensorReading.changeset(%{
sensor_id: sensor2.id,
value: 100.0,
status: "ok",
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
batch = Snmp.get_latest_sensor_readings_batch([sensor1.id, sensor2.id])
assert map_size(batch) == 2
assert batch[sensor1.id].id == reading1.id
assert batch[sensor2.id].id == reading2.id
end
test "returns only latest reading per sensor", %{device: _device, snmp_device: snmp_device} do
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4"
})
|> Repo.insert!()
_old_reading =
%SensorReading{}
|> SensorReading.changeset(%{
sensor_id: sensor.id,
value: 20.0,
status: "ok",
checked_at: DateTime.add(DateTime.utc_now(), -3600, :second)
})
|> Repo.insert!()
new_reading =
%SensorReading{}
|> SensorReading.changeset(%{
sensor_id: sensor.id,
value: 25.0,
status: "ok",
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
batch = Snmp.get_latest_sensor_readings_batch([sensor.id])
assert map_size(batch) == 1
assert batch[sensor.id].id == new_reading.id
assert batch[sensor.id].value == 25.0
end
test "excludes sensors without readings", %{device: _device, snmp_device: snmp_device} do
sensor_with_reading =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4"
})
|> Repo.insert!()
sensor_without_reading =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "power",
sensor_index: "2",
sensor_oid: "1.2.3.5"
})
|> Repo.insert!()
%SensorReading{}
|> SensorReading.changeset(%{
sensor_id: sensor_with_reading.id,
value: 25.0,
status: "ok",
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
batch = Snmp.get_latest_sensor_readings_batch([sensor_with_reading.id, sensor_without_reading.id])
assert map_size(batch) == 1
assert Map.has_key?(batch, sensor_with_reading.id)
refute Map.has_key?(batch, sensor_without_reading.id)
end
end
describe "get_latest_interface_stats_batch/1" do
test "returns empty map for empty interface list", %{device: _device, snmp_device: _snmp_device} do
assert Snmp.get_latest_interface_stats_batch([]) == %{}
end
test "returns stats for multiple interfaces", %{device: _device, snmp_device: snmp_device} do
interface1 =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
interface2 =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 2,
if_name: "eth1"
})
|> Repo.insert!()
stat1 =
%InterfaceStat{}
|> InterfaceStat.changeset(%{
interface_id: interface1.id,
if_in_octets: 1000,
if_out_octets: 2000,
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
stat2 =
%InterfaceStat{}
|> InterfaceStat.changeset(%{
interface_id: interface2.id,
if_in_octets: 5000,
if_out_octets: 6000,
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
batch = Snmp.get_latest_interface_stats_batch([interface1.id, interface2.id])
assert map_size(batch) == 2
assert batch[interface1.id].id == stat1.id
assert batch[interface2.id].id == stat2.id
end
test "returns only latest stat per interface", %{device: _device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
_old_stat =
%InterfaceStat{}
|> InterfaceStat.changeset(%{
interface_id: interface.id,
if_in_octets: 1000,
if_out_octets: 2000,
checked_at: DateTime.add(DateTime.utc_now(), -3600, :second)
})
|> Repo.insert!()
new_stat =
%InterfaceStat{}
|> InterfaceStat.changeset(%{
interface_id: interface.id,
if_in_octets: 5000,
if_out_octets: 6000,
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
batch = Snmp.get_latest_interface_stats_batch([interface.id])
assert map_size(batch) == 1
assert batch[interface.id].id == new_stat.id
assert batch[interface.id].if_in_octets == 5000
end
test "excludes interfaces without stats", %{device: _device, snmp_device: snmp_device} do
interface_with_stat =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
interface_without_stat =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 2,
if_name: "eth1"
})
|> Repo.insert!()
%InterfaceStat{}
|> InterfaceStat.changeset(%{
interface_id: interface_with_stat.id,
if_in_octets: 1000,
if_out_octets: 2000,
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
batch = Snmp.get_latest_interface_stats_batch([interface_with_stat.id, interface_without_stat.id])
assert map_size(batch) == 1
assert Map.has_key?(batch, interface_with_stat.id)
refute Map.has_key?(batch, interface_without_stat.id)
end
end
describe "list_neighbors/1" do
test "returns all neighbors for a device", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
neighbor =
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device.id,
interface_id: interface.id,
protocol: "lldp",
remote_chassis_id: "00:11:22:33:44:55",
remote_system_name: "neighbor-switch",
last_discovered_at: DateTime.utc_now()
})
|> Repo.insert!()
neighbors = Snmp.list_neighbors(device.id)
assert length(neighbors) == 1
assert hd(neighbors).id == neighbor.id
assert Ecto.assoc_loaded?(hd(neighbors).interface)
end
test "returns empty list for device with no neighbors", %{device: device, snmp_device: _snmp_device} do
assert Snmp.list_neighbors(device.id) == []
end
test "orders neighbors by protocol and system name", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
neighbor_lldp_b =
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device.id,
interface_id: interface.id,
protocol: "lldp",
remote_chassis_id: "00:11:22:33:44:55",
remote_system_name: "switch-b",
last_discovered_at: DateTime.utc_now()
})
|> Repo.insert!()
neighbor_cdp_a =
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device.id,
interface_id: interface.id,
protocol: "cdp",
remote_chassis_id: "aa:bb:cc:dd:ee:ff",
remote_system_name: "router-a",
last_discovered_at: DateTime.utc_now()
})
|> Repo.insert!()
neighbor_lldp_a =
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device.id,
interface_id: interface.id,
protocol: "lldp",
remote_chassis_id: "11:22:33:44:55:66",
remote_system_name: "switch-a",
last_discovered_at: DateTime.utc_now()
})
|> Repo.insert!()
neighbors = Snmp.list_neighbors(device.id)
assert length(neighbors) == 3
# Should be ordered: cdp first, then lldp alphabetically
assert Enum.at(neighbors, 0).id == neighbor_cdp_a.id
assert Enum.at(neighbors, 1).id == neighbor_lldp_a.id
assert Enum.at(neighbors, 2).id == neighbor_lldp_b.id
end
end
describe "get_neighbor/1" do
test "returns neighbor by id", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
neighbor =
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device.id,
interface_id: interface.id,
protocol: "lldp",
remote_chassis_id: "00:11:22:33:44:55",
last_discovered_at: DateTime.utc_now()
})
|> Repo.insert!()
found = Snmp.get_neighbor(neighbor.id)
assert found.id == neighbor.id
assert found.remote_chassis_id == "00:11:22:33:44:55"
end
test "returns nil for non-existent id" do
assert Snmp.get_neighbor(Ecto.UUID.generate()) == nil
end
end
describe "upsert_neighbor/1" do
test "creates new neighbor when none exists", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
attrs = %{
device_id: device.id,
interface_id: interface.id,
protocol: "lldp",
remote_chassis_id: "00:11:22:33:44:55",
remote_system_name: "new-neighbor",
last_discovered_at: DateTime.utc_now()
}
{:ok, neighbor} = Snmp.upsert_neighbor(attrs)
assert neighbor.remote_system_name == "new-neighbor"
assert neighbor.remote_chassis_id == "00:11:22:33:44:55"
end
test "updates existing neighbor when match found", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
existing =
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device.id,
interface_id: interface.id,
protocol: "lldp",
remote_chassis_id: "00:11:22:33:44:55",
remote_system_name: "old-name",
last_discovered_at: DateTime.add(DateTime.utc_now(), -3600, :second)
})
|> Repo.insert!()
new_time = DateTime.truncate(DateTime.utc_now(), :second)
attrs = %{
device_id: device.id,
interface_id: interface.id,
protocol: "lldp",
remote_chassis_id: "00:11:22:33:44:55",
remote_system_name: "updated-name",
last_discovered_at: new_time
}
{:ok, updated} = Snmp.upsert_neighbor(attrs)
assert updated.id == existing.id
assert updated.remote_system_name == "updated-name"
# Compare with truncation since database stores :utc_datetime (no microseconds)
assert DateTime.compare(updated.last_discovered_at, new_time) in [:eq, :gt]
end
end
describe "delete_stale_neighbors/2" do
test "deletes neighbors older than cutoff", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
old_time = DateTime.add(DateTime.utc_now(), -7200, :second)
recent_time = DateTime.add(DateTime.utc_now(), -60, :second)
old_neighbor =
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device.id,
interface_id: interface.id,
protocol: "lldp",
remote_chassis_id: "00:11:22:33:44:55",
last_discovered_at: old_time
})
|> Repo.insert!()
recent_neighbor =
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device.id,
interface_id: interface.id,
protocol: "cdp",
remote_chassis_id: "aa:bb:cc:dd:ee:ff",
last_discovered_at: recent_time
})
|> Repo.insert!()
cutoff = DateTime.add(DateTime.utc_now(), -3600, :second)
{deleted_count, _} = Snmp.delete_stale_neighbors(device.id, cutoff)
assert deleted_count == 1
assert Snmp.get_neighbor(old_neighbor.id) == nil
assert Snmp.get_neighbor(recent_neighbor.id)
end
test "does not delete neighbors from other devices", %{organization: organization} do
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Another Site",
organization_id: organization.id
})
{:ok, device1} =
Towerops.Devices.create_device(%{
name: "Router 1",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, device2} =
Towerops.Devices.create_device(%{
name: "Router 2",
ip_address: "192.168.1.2",
site_id: site.id,
organization_id: organization.id
})
snmp_device1 =
%Device{}
|> Device.changeset(%{
device_id: device1.id,
sys_name: "router1"
})
|> Repo.insert!()
snmp_device2 =
%Device{}
|> Device.changeset(%{
device_id: device2.id,
sys_name: "router2"
})
|> Repo.insert!()
interface1 =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device1.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
interface2 =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device2.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
old_time = DateTime.add(DateTime.utc_now(), -7200, :second)
neighbor1 =
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device1.id,
interface_id: interface1.id,
protocol: "lldp",
last_discovered_at: old_time
})
|> Repo.insert!()
neighbor2 =
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device2.id,
interface_id: interface2.id,
protocol: "lldp",
last_discovered_at: old_time
})
|> Repo.insert!()
cutoff = DateTime.add(DateTime.utc_now(), -3600, :second)
{deleted_count, _} = Snmp.delete_stale_neighbors(device1.id, cutoff)
assert deleted_count == 1
assert Snmp.get_neighbor(neighbor1.id) == nil
assert Snmp.get_neighbor(neighbor2.id)
end
end
describe "get_storage_readings/2" do
test "returns recent readings for a storage entry", %{snmp_device: snmp_device} do
storage =
%Storage{}
|> Storage.changeset(%{
snmp_device_id: snmp_device.id,
storage_index: 1,
storage_type: "fixed_disk",
description: "/dev/sda1"
})
|> Repo.insert!()
for i <- 1..5 do
%StorageReading{}
|> StorageReading.changeset(%{
storage_id: storage.id,
used_bytes: 1000 * i,
total_bytes: 10_000,
usage_percent: 10.0 * i,
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
end
readings = Snmp.get_storage_readings(storage.id)
assert length(readings) == 5
end
test "respects limit option", %{snmp_device: snmp_device} do
storage =
%Storage{}
|> Storage.changeset(%{
snmp_device_id: snmp_device.id,
storage_index: 1,
storage_type: "fixed_disk",
description: "/dev/sda1"
})
|> Repo.insert!()
for i <- 1..10 do
%StorageReading{}
|> StorageReading.changeset(%{
storage_id: storage.id,
used_bytes: 1000 * i,
total_bytes: 10_000,
usage_percent: 10.0 * i,
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
end
readings = Snmp.get_storage_readings(storage.id, limit: 3)
assert length(readings) == 3
end
test "respects since option", %{snmp_device: snmp_device} do
storage =
%Storage{}
|> Storage.changeset(%{
snmp_device_id: snmp_device.id,
storage_index: 1,
storage_type: "fixed_disk",
description: "/dev/sda1"
})
|> Repo.insert!()
past = DateTime.add(DateTime.utc_now(), -3600, :second)
recent = DateTime.add(DateTime.utc_now(), -60, :second)
%StorageReading{}
|> StorageReading.changeset(%{
storage_id: storage.id,
used_bytes: 1000,
total_bytes: 10_000,
usage_percent: 10.0,
checked_at: past
})
|> Repo.insert!()
%StorageReading{}
|> StorageReading.changeset(%{
storage_id: storage.id,
used_bytes: 5000,
total_bytes: 10_000,
usage_percent: 50.0,
checked_at: recent
})
|> Repo.insert!()
since_time = DateTime.add(DateTime.utc_now(), -120, :second)
readings = Snmp.get_storage_readings(storage.id, since: since_time)
assert length(readings) == 1
assert hd(readings).usage_percent == 50.0
end
test "returns empty list for storage with no readings", %{snmp_device: snmp_device} do
storage =
%Storage{}
|> Storage.changeset(%{
snmp_device_id: snmp_device.id,
storage_index: 1,
storage_type: "fixed_disk",
description: "/dev/sda1"
})
|> Repo.insert!()
assert Snmp.get_storage_readings(storage.id) == []
end
end
describe "get_latest_storage_reading/1" do
test "returns most recent reading", %{snmp_device: snmp_device} do
storage =
%Storage{}
|> Storage.changeset(%{
snmp_device_id: snmp_device.id,
storage_index: 1,
storage_type: "fixed_disk",
description: "/dev/sda1"
})
|> Repo.insert!()
old_reading =
%StorageReading{}
|> StorageReading.changeset(%{
storage_id: storage.id,
used_bytes: 1000,
total_bytes: 10_000,
usage_percent: 10.0,
checked_at: DateTime.add(DateTime.utc_now(), -3600, :second)
})
|> Repo.insert!()
new_reading =
%StorageReading{}
|> StorageReading.changeset(%{
storage_id: storage.id,
used_bytes: 5000,
total_bytes: 10_000,
usage_percent: 50.0,
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
latest = Snmp.get_latest_storage_reading(storage.id)
assert latest.id == new_reading.id
refute latest.id == old_reading.id
end
test "returns nil for storage with no readings", %{snmp_device: snmp_device} do
storage =
%Storage{}
|> Storage.changeset(%{
snmp_device_id: snmp_device.id,
storage_index: 1,
storage_type: "fixed_disk",
description: "/dev/sda1"
})
|> Repo.insert!()
assert Snmp.get_latest_storage_reading(storage.id) == nil
end
end
describe "get_latest_storage_readings_batch/1" do
test "returns empty map for empty storage list" do
assert Snmp.get_latest_storage_readings_batch([]) == %{}
end
test "returns readings for multiple storage entries", %{snmp_device: snmp_device} do
storage1 =
%Storage{}
|> Storage.changeset(%{
snmp_device_id: snmp_device.id,
storage_index: 1,
storage_type: "fixed_disk",
description: "/dev/sda1"
})
|> Repo.insert!()
storage2 =
%Storage{}
|> Storage.changeset(%{
snmp_device_id: snmp_device.id,
storage_index: 2,
storage_type: "ram",
description: "Physical Memory"
})
|> Repo.insert!()
reading1 =
%StorageReading{}
|> StorageReading.changeset(%{
storage_id: storage1.id,
used_bytes: 5000,
total_bytes: 10_000,
usage_percent: 50.0,
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
reading2 =
%StorageReading{}
|> StorageReading.changeset(%{
storage_id: storage2.id,
used_bytes: 2000,
total_bytes: 8000,
usage_percent: 25.0,
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
batch = Snmp.get_latest_storage_readings_batch([storage1.id, storage2.id])
assert map_size(batch) == 2
assert batch[storage1.id].id == reading1.id
assert batch[storage2.id].id == reading2.id
end
test "returns only latest reading per storage", %{snmp_device: snmp_device} do
storage =
%Storage{}
|> Storage.changeset(%{
snmp_device_id: snmp_device.id,
storage_index: 1,
storage_type: "fixed_disk",
description: "/dev/sda1"
})
|> Repo.insert!()
_old_reading =
%StorageReading{}
|> StorageReading.changeset(%{
storage_id: storage.id,
used_bytes: 1000,
total_bytes: 10_000,
usage_percent: 10.0,
checked_at: DateTime.add(DateTime.utc_now(), -3600, :second)
})
|> Repo.insert!()
new_reading =
%StorageReading{}
|> StorageReading.changeset(%{
storage_id: storage.id,
used_bytes: 5000,
total_bytes: 10_000,
usage_percent: 50.0,
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
batch = Snmp.get_latest_storage_readings_batch([storage.id])
assert map_size(batch) == 1
assert batch[storage.id].id == new_reading.id
assert batch[storage.id].usage_percent == 50.0
end
test "excludes storage without readings", %{snmp_device: snmp_device} do
storage_with_reading =
%Storage{}
|> Storage.changeset(%{
snmp_device_id: snmp_device.id,
storage_index: 1,
storage_type: "fixed_disk",
description: "/dev/sda1"
})
|> Repo.insert!()
storage_without_reading =
%Storage{}
|> Storage.changeset(%{
snmp_device_id: snmp_device.id,
storage_index: 2,
storage_type: "ram",
description: "Physical Memory"
})
|> Repo.insert!()
%StorageReading{}
|> StorageReading.changeset(%{
storage_id: storage_with_reading.id,
used_bytes: 5000,
total_bytes: 10_000,
usage_percent: 50.0,
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
batch = Snmp.get_latest_storage_readings_batch([storage_with_reading.id, storage_without_reading.id])
assert map_size(batch) == 1
assert Map.has_key?(batch, storage_with_reading.id)
refute Map.has_key?(batch, storage_without_reading.id)
end
end
describe "create_storage_reading/1" do
test "creates a storage reading with valid attributes", %{snmp_device: snmp_device} do
storage =
%Storage{}
|> Storage.changeset(%{
snmp_device_id: snmp_device.id,
storage_index: 1,
storage_type: "fixed_disk",
description: "/dev/sda1"
})
|> Repo.insert!()
{:ok, reading} =
Snmp.create_storage_reading(%{
storage_id: storage.id,
used_bytes: 5000,
total_bytes: 10_000,
usage_percent: 50.0,
checked_at: DateTime.utc_now()
})
assert reading.storage_id == storage.id
assert reading.used_bytes == 5000
assert reading.total_bytes == 10_000
assert reading.usage_percent == 50.0
end
test "returns error with invalid attributes" do
{:error, changeset} = Snmp.create_storage_reading(%{used_bytes: 1000})
assert changeset.errors[:storage_id]
end
end
describe "list_arp_entries/1" do
test "returns all ARP entries for a device", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
arp =
%ArpEntry{}
|> ArpEntry.changeset(%{
device_id: device.id,
interface_id: interface.id,
ip_address: "192.168.1.100",
mac_address: "aa:bb:cc:dd:ee:ff",
entry_type: "dynamic",
if_index: 1,
last_seen_at: DateTime.utc_now()
})
|> Repo.insert!()
entries = Snmp.list_arp_entries(device.id)
assert length(entries) == 1
assert hd(entries).id == arp.id
assert Ecto.assoc_loaded?(hd(entries).interface)
end
test "returns empty list for device with no ARP entries", %{device: device} do
assert Snmp.list_arp_entries(device.id) == []
end
test "orders ARP entries by IP address", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
arp2 =
%ArpEntry{}
|> ArpEntry.changeset(%{
device_id: device.id,
interface_id: interface.id,
ip_address: "192.168.1.200",
mac_address: "aa:bb:cc:dd:ee:02",
last_seen_at: DateTime.utc_now()
})
|> Repo.insert!()
arp1 =
%ArpEntry{}
|> ArpEntry.changeset(%{
device_id: device.id,
interface_id: interface.id,
ip_address: "192.168.1.100",
mac_address: "aa:bb:cc:dd:ee:01",
last_seen_at: DateTime.utc_now()
})
|> Repo.insert!()
entries = Snmp.list_arp_entries(device.id)
assert length(entries) == 2
assert Enum.at(entries, 0).id == arp1.id
assert Enum.at(entries, 1).id == arp2.id
end
end
describe "get_arp_entry/1" do
test "returns ARP entry by id", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
arp =
%ArpEntry{}
|> ArpEntry.changeset(%{
device_id: device.id,
interface_id: interface.id,
ip_address: "192.168.1.100",
mac_address: "aa:bb:cc:dd:ee:ff",
last_seen_at: DateTime.utc_now()
})
|> Repo.insert!()
found = Snmp.get_arp_entry(arp.id)
assert found.id == arp.id
assert found.ip_address == "192.168.1.100"
end
test "returns nil for non-existent id" do
assert Snmp.get_arp_entry(Ecto.UUID.generate()) == nil
end
end
describe "upsert_arp_entry/1" do
test "creates new ARP entry when none exists", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
attrs = %{
device_id: device.id,
interface_id: interface.id,
ip_address: "192.168.1.100",
mac_address: "aa:bb:cc:dd:ee:ff",
entry_type: "dynamic",
if_index: 1,
last_seen_at: DateTime.utc_now()
}
{:ok, arp} = Snmp.upsert_arp_entry(attrs)
assert arp.ip_address == "192.168.1.100"
assert arp.mac_address == "aa:bb:cc:dd:ee:ff"
end
test "updates existing ARP entry when match found", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
existing =
%ArpEntry{}
|> ArpEntry.changeset(%{
device_id: device.id,
interface_id: interface.id,
ip_address: "192.168.1.100",
mac_address: "aa:bb:cc:dd:ee:ff",
entry_type: "dynamic",
if_index: 1,
last_seen_at: DateTime.add(DateTime.utc_now(), -3600, :second)
})
|> Repo.insert!()
new_time = DateTime.truncate(DateTime.utc_now(), :second)
attrs = %{
device_id: device.id,
interface_id: interface.id,
ip_address: "192.168.1.100",
mac_address: "aa:bb:cc:dd:ee:ff",
entry_type: "static",
if_index: 1,
last_seen_at: new_time
}
{:ok, updated} = Snmp.upsert_arp_entry(attrs)
assert updated.id == existing.id
assert updated.entry_type == "static"
end
end
describe "delete_stale_arp_entries/2" do
test "deletes ARP entries older than cutoff", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
old_time = DateTime.add(DateTime.utc_now(), -7200, :second)
recent_time = DateTime.add(DateTime.utc_now(), -60, :second)
old_arp =
%ArpEntry{}
|> ArpEntry.changeset(%{
device_id: device.id,
interface_id: interface.id,
ip_address: "192.168.1.100",
mac_address: "aa:bb:cc:dd:ee:ff",
last_seen_at: old_time
})
|> Repo.insert!()
recent_arp =
%ArpEntry{}
|> ArpEntry.changeset(%{
device_id: device.id,
interface_id: interface.id,
ip_address: "192.168.1.200",
mac_address: "11:22:33:44:55:66",
last_seen_at: recent_time
})
|> Repo.insert!()
cutoff = DateTime.add(DateTime.utc_now(), -3600, :second)
{deleted_count, _} = Snmp.delete_stale_arp_entries(device.id, cutoff)
assert deleted_count == 1
assert Snmp.get_arp_entry(old_arp.id) == nil
assert Snmp.get_arp_entry(recent_arp.id)
end
test "does not delete ARP entries from other devices", %{organization: organization} do
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Another Site",
organization_id: organization.id
})
{:ok, device1} =
Towerops.Devices.create_device(%{
name: "Router 1",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, device2} =
Towerops.Devices.create_device(%{
name: "Router 2",
ip_address: "192.168.1.2",
site_id: site.id,
organization_id: organization.id
})
old_time = DateTime.add(DateTime.utc_now(), -7200, :second)
arp1 =
%ArpEntry{}
|> ArpEntry.changeset(%{
device_id: device1.id,
ip_address: "192.168.1.100",
mac_address: "aa:bb:cc:dd:ee:ff",
last_seen_at: old_time
})
|> Repo.insert!()
arp2 =
%ArpEntry{}
|> ArpEntry.changeset(%{
device_id: device2.id,
ip_address: "192.168.1.100",
mac_address: "11:22:33:44:55:66",
last_seen_at: old_time
})
|> Repo.insert!()
cutoff = DateTime.add(DateTime.utc_now(), -3600, :second)
{deleted_count, _} = Snmp.delete_stale_arp_entries(device1.id, cutoff)
assert deleted_count == 1
assert Snmp.get_arp_entry(arp1.id) == nil
assert Snmp.get_arp_entry(arp2.id)
end
end
describe "upsert_arp_entries/3" do
test "creates multiple ARP entries with interface mapping", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
arp_data = [
%{
ip_address: "192.168.1.100",
mac_address: "aa:bb:cc:dd:ee:ff",
if_index: 1,
entry_type: "dynamic",
last_seen_at: DateTime.utc_now()
},
%{
ip_address: "192.168.1.200",
mac_address: "11:22:33:44:55:66",
if_index: 1,
entry_type: "static",
last_seen_at: DateTime.utc_now()
}
]
{success, errors} = Snmp.upsert_arp_entries(device.id, arp_data, [interface])
assert success == 2
assert errors == 0
entries = Snmp.list_arp_entries(device.id)
assert length(entries) == 2
ips = entries |> Enum.map(& &1.ip_address) |> Enum.sort()
assert ips == ["192.168.1.100", "192.168.1.200"]
# Both should have the interface linked
assert Enum.all?(entries, &(&1.interface_id == interface.id))
end
test "handles entries without matching interface", %{device: device} do
arp_data = [
%{
ip_address: "192.168.1.100",
mac_address: "aa:bb:cc:dd:ee:ff",
if_index: 999,
entry_type: "dynamic",
last_seen_at: DateTime.utc_now()
}
]
{success, errors} = Snmp.upsert_arp_entries(device.id, arp_data, [])
assert success == 1
assert errors == 0
entries = Snmp.list_arp_entries(device.id)
assert length(entries) == 1
assert hd(entries).interface_id == nil
assert hd(entries).if_index == 999
end
end
describe "upsert_arp_entries/3 error tracking" do
test "returns success and error counts", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
arp_data = [
%{
ip_address: "192.168.1.100",
mac_address: "aa:bb:cc:dd:ee:ff",
if_index: 1,
entry_type: "dynamic",
last_seen_at: DateTime.utc_now()
},
%{
ip_address: "192.168.1.200",
mac_address: "11:22:33:44:55:66",
if_index: 1,
entry_type: "static",
last_seen_at: DateTime.utc_now()
}
]
{success, errors} = Snmp.upsert_arp_entries(device.id, arp_data, [interface])
assert success == 2
assert errors == 0
end
end
describe "upsert_mac_addresses/3 error tracking" do
test "returns success and error counts", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
mac_data = [
%{
mac_address: "aa:bb:cc:dd:ee:ff",
port_index: 1,
vlan_id: 1,
last_seen_at: DateTime.utc_now()
},
%{
mac_address: "11:22:33:44:55:66",
port_index: 1,
vlan_id: 1,
last_seen_at: DateTime.utc_now()
}
]
{success, errors} = Snmp.upsert_mac_addresses(device.id, mac_data, [interface])
assert success == 2
assert errors == 0
end
end
describe "list_discovered_devices_for_organization/1" do
test "merges data from neighbors, ARP, and MAC tables", %{
organization: organization,
device: device,
snmp_device: snmp_device
} do
# Create an interface
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
# Create a neighbor entry
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device.id,
snmp_device_id: snmp_device.id,
interface_id: interface.id,
remote_chassis_id: "aa:bb:cc:dd:ee:ff",
remote_port_id: "Gi1/0/1",
remote_system_name: "discovered-switch",
remote_platform: "Cisco IOS Software",
remote_capabilities: ["bridge", "router"],
protocol: "lldp",
last_discovered_at: DateTime.utc_now()
})
|> Repo.insert!()
# Create an ARP entry with same MAC
%ArpEntry{}
|> ArpEntry.changeset(%{
device_id: device.id,
ip_address: "192.168.1.100",
mac_address: "aa:bb:cc:dd:ee:ff",
if_index: 1,
entry_type: "dynamic",
last_seen_at: DateTime.utc_now()
})
|> Repo.insert!()
discovered = Snmp.list_discovered_devices_for_organization(organization.id)
assert length(discovered) == 1
entry = hd(discovered)
# Should merge data from both sources
assert entry.identifier.type == :mac
assert entry.identifier.value == "aa:bb:cc:dd:ee:ff"
assert entry.hostname == "discovered-switch"
assert "192.168.1.100" in entry.ip_addresses
assert "aa:bb:cc:dd:ee:ff" in entry.mac_addresses
assert entry.source_count == 2
assert :lldp in entry.protocols_used
assert :arp in entry.protocols_used
end
test "filters out existing devices by IP address", %{
organization: organization,
device: device,
snmp_device: snmp_device
} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
# Create neighbor with same IP as existing device
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device.id,
snmp_device_id: snmp_device.id,
interface_id: interface.id,
remote_chassis_id: "aa:bb:cc:dd:ee:ff",
remote_port_id: "Gi1/0/1",
remote_system_name: "test-router",
remote_address: device.ip_address,
protocol: "lldp",
last_discovered_at: DateTime.utc_now()
})
|> Repo.insert!()
discovered = Snmp.list_discovered_devices_for_organization(organization.id)
# Should be filtered out
assert discovered == []
end
test "filters out existing devices by MAC address", %{
organization: organization,
device: device,
snmp_device: snmp_device
} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0",
if_phys_address: "aa:bb:cc:dd:ee:ff"
})
|> Repo.insert!()
# Create neighbor with same MAC as existing interface
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device.id,
snmp_device_id: snmp_device.id,
interface_id: interface.id,
remote_chassis_id: "aa:bb:cc:dd:ee:ff",
remote_port_id: "Gi1/0/1",
remote_system_name: "discovered-device",
protocol: "lldp",
last_discovered_at: DateTime.utc_now()
})
|> Repo.insert!()
discovered = Snmp.list_discovered_devices_for_organization(organization.id)
# Should be filtered out
assert discovered == []
end
test "infers device type from LLDP capabilities", %{
organization: organization,
device: device,
snmp_device: snmp_device
} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
# Create neighbor with bridge capability
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device.id,
snmp_device_id: snmp_device.id,
interface_id: interface.id,
remote_chassis_id: "aa:bb:cc:dd:ee:ff",
remote_port_id: "Gi1/0/1",
remote_system_name: "switch-device",
remote_capabilities: ["bridge"],
protocol: "lldp",
last_discovered_at: DateTime.utc_now()
})
|> Repo.insert!()
discovered = Snmp.list_discovered_devices_for_organization(organization.id)
assert length(discovered) == 1
assert hd(discovered).device_type == :switch
end
test "infers device type from platform description", %{
organization: organization,
device: device,
snmp_device: snmp_device
} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
# Create neighbor with Cisco IOS description
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device.id,
snmp_device_id: snmp_device.id,
interface_id: interface.id,
remote_chassis_id: "aa:bb:cc:dd:ee:ff",
remote_port_id: "Gi1/0/1",
remote_system_name: "router-device",
remote_platform: "Cisco IOS Software, Version 15.0",
protocol: "lldp",
last_discovered_at: DateTime.utc_now()
})
|> Repo.insert!()
discovered = Snmp.list_discovered_devices_for_organization(organization.id)
assert length(discovered) == 1
assert hd(discovered).device_type == :router
end
test "prefers MAC address as primary identifier", %{
organization: organization,
device: device,
snmp_device: snmp_device
} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
# Create neighbor with both MAC and IP
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device.id,
snmp_device_id: snmp_device.id,
interface_id: interface.id,
remote_chassis_id: "aa:bb:cc:dd:ee:ff",
remote_port_id: "Gi1/0/1",
remote_system_name: "test-device",
remote_address: "192.168.1.100",
protocol: "lldp",
last_discovered_at: DateTime.utc_now()
})
|> Repo.insert!()
discovered = Snmp.list_discovered_devices_for_organization(organization.id)
assert length(discovered) == 1
entry = hd(discovered)
# Should prefer MAC as identifier
assert entry.identifier.type == :mac
assert entry.identifier.value == "aa:bb:cc:dd:ee:ff"
end
test "returns empty list when no discovery data exists", %{organization: organization} do
discovered = Snmp.list_discovered_devices_for_organization(organization.id)
assert discovered == []
end
test "sorts by source count and last seen", %{organization: organization, device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
# Create one neighbor with single source (older)
old_time = DateTime.add(DateTime.utc_now(), -3600, :second)
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device.id,
snmp_device_id: snmp_device.id,
interface_id: interface.id,
remote_chassis_id: "11:11:11:11:11:11",
remote_port_id: "Gi1/0/1",
remote_system_name: "old-device",
protocol: "lldp",
last_discovered_at: old_time
})
|> Repo.insert!()
# Create another neighbor with single source (newer)
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device.id,
snmp_device_id: snmp_device.id,
interface_id: interface.id,
remote_chassis_id: "22:22:22:22:22:22",
remote_port_id: "Gi1/0/2",
remote_system_name: "new-device",
protocol: "lldp",
last_discovered_at: DateTime.utc_now()
})
|> Repo.insert!()
# Create a device with multiple sources
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device.id,
snmp_device_id: snmp_device.id,
interface_id: interface.id,
remote_chassis_id: "33:33:33:33:33:33",
remote_port_id: "Gi1/0/3",
remote_system_name: "multi-source",
protocol: "lldp",
last_discovered_at: old_time
})
|> Repo.insert!()
%ArpEntry{}
|> ArpEntry.changeset(%{
device_id: device.id,
ip_address: "192.168.1.100",
mac_address: "33:33:33:33:33:33",
if_index: 1,
entry_type: "dynamic",
last_seen_at: old_time
})
|> Repo.insert!()
discovered = Snmp.list_discovered_devices_for_organization(organization.id)
assert length(discovered) == 3
# First should be multi-source (source_count = 2)
assert hd(discovered).hostname == "multi-source"
assert hd(discovered).source_count == 2
# Next two should be sorted by last_seen (newer first)
assert Enum.at(discovered, 1).hostname == "new-device"
assert Enum.at(discovered, 2).hostname == "old-device"
end
end
describe "concurrent upsert operations (race condition tests)" do
test "concurrent neighbor upserts don't create duplicates", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
# Spawn 10 concurrent workers all upserting the same neighbor
tasks =
for i <- 1..10 do
Task.async(fn ->
Snmp.upsert_neighbor(%{
device_id: device.id,
interface_id: interface.id,
remote_chassis_id: "00:11:22:33:44:55",
protocol: "lldp",
remote_system_name: "neighbor-#{i}",
last_discovered_at: DateTime.utc_now()
})
end)
end
# Wait for all tasks to complete
results = Task.await_many(tasks, 5_000)
# All should succeed
assert Enum.all?(results, fn
{:ok, _} -> true
_ -> false
end)
# Should have exactly 1 neighbor, not 10
neighbors =
Repo.all(
from n in Neighbor,
where: n.interface_id == ^interface.id and n.remote_chassis_id == "00:11:22:33:44:55"
)
assert length(neighbors) == 1
# The final name should be one of the concurrent writes (non-deterministic but valid)
neighbor = hd(neighbors)
assert String.starts_with?(neighbor.remote_system_name, "neighbor-")
end
test "concurrent ARP entry upserts don't create duplicates", %{device: device} do
# Spawn 10 concurrent workers all upserting the same ARP entry
tasks =
for _i <- 1..10 do
Task.async(fn ->
Snmp.upsert_arp_entry(%{
device_id: device.id,
ip_address: "192.168.1.100",
mac_address: "aa:bb:cc:dd:ee:ff",
entry_type: "dynamic",
last_seen_at: DateTime.utc_now()
})
end)
end
# Wait for all tasks to complete
results = Task.await_many(tasks, 5_000)
# All should succeed
assert Enum.all?(results, fn
{:ok, _} -> true
_ -> false
end)
# Should have exactly 1 ARP entry, not 10
arp_entries =
Repo.all(
from a in ArpEntry,
where:
a.device_id == ^device.id and a.ip_address == "192.168.1.100" and
a.mac_address == "aa:bb:cc:dd:ee:ff"
)
assert length(arp_entries) == 1
end
test "concurrent MAC address upserts don't create duplicates", %{device: device} do
# Spawn 10 concurrent workers all upserting the same MAC address
tasks =
for i <- 1..10 do
Task.async(fn ->
Snmp.upsert_mac_address(%{
device_id: device.id,
mac_address: "11:22:33:44:55:66",
vlan_id: 100,
port_index: i,
last_seen_at: DateTime.utc_now()
})
end)
end
# Wait for all tasks to complete
results = Task.await_many(tasks, 5_000)
# All should succeed
assert Enum.all?(results, fn
{:ok, _} -> true
_ -> false
end)
# Should have exactly 1 MAC address, not 10
mac_addresses =
Repo.all(
from m in MacAddress,
where: m.device_id == ^device.id and m.mac_address == "11:22:33:44:55:66" and m.vlan_id == 100
)
assert length(mac_addresses) == 1
# The final port_index should be one of the concurrent writes
mac = hd(mac_addresses)
assert mac.port_index in 1..10
end
test "concurrent MAC address upserts with nil vlan_id don't create duplicates", %{device: device} do
# Spawn 10 concurrent workers all upserting the same MAC address with nil vlan_id
tasks =
for i <- 1..10 do
Task.async(fn ->
Snmp.upsert_mac_address(%{
device_id: device.id,
mac_address: "aa:bb:cc:dd:ee:ff",
vlan_id: nil,
port_index: i,
last_seen_at: DateTime.utc_now()
})
end)
end
# Wait for all tasks to complete
results = Task.await_many(tasks, 5_000)
# All should succeed
assert Enum.all?(results, fn
{:ok, _} -> true
_ -> false
end)
# Should have exactly 1 MAC address, not 10
mac_addresses =
Repo.all(
from m in MacAddress,
where: m.device_id == ^device.id and m.mac_address == "aa:bb:cc:dd:ee:ff" and is_nil(m.vlan_id)
)
assert length(mac_addresses) == 1
end
test "concurrent neighbor upserts with nil chassis_id handled correctly", %{
device: device,
snmp_device: snmp_device
} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
# Spawn 10 concurrent workers all upserting neighbors with nil chassis_id
tasks =
for i <- 1..10 do
Task.async(fn ->
Snmp.upsert_neighbor(%{
device_id: device.id,
interface_id: interface.id,
remote_chassis_id: nil,
protocol: "cdp",
remote_system_name: "neighbor-#{i}",
last_discovered_at: DateTime.utc_now()
})
end)
end
# Wait for all tasks to complete
results = Task.await_many(tasks, 5_000)
# All should succeed
assert Enum.all?(results, fn
{:ok, _} -> true
_ -> false
end)
# Should have exactly 1 neighbor with nil chassis_id
neighbors =
Repo.all(
from n in Neighbor,
where: n.interface_id == ^interface.id and is_nil(n.remote_chassis_id) and n.protocol == "cdp"
)
assert length(neighbors) == 1
end
end
describe "delete_stale_and_upsert_neighbors/3" do
test "atomically deletes stale and upserts new neighbors", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{snmp_device_id: snmp_device.id, if_index: 1, if_name: "eth0"})
|> Repo.insert!()
cutoff = DateTime.add(DateTime.utc_now(), -5, :minute)
# Create a stale neighbor (old timestamp)
{:ok, _stale} =
Snmp.upsert_neighbor(%{
device_id: device.id,
interface_id: interface.id,
remote_chassis_id: "stale-chassis",
remote_port_id: "ge0/0/1",
remote_system_name: "stale-switch",
protocol: "lldp",
last_discovered_at: DateTime.add(DateTime.utc_now(), -10, :minute)
})
# New neighbors to upsert
new_neighbors = [
%{
device_id: device.id,
interface_id: interface.id,
remote_chassis_id: "new-chassis",
remote_port_id: "ge0/0/2",
remote_system_name: "new-switch",
protocol: "lldp",
last_discovered_at: DateTime.utc_now()
}
]
assert {:ok, _} = Snmp.delete_stale_and_upsert_neighbors(device.id, new_neighbors, cutoff)
remaining = Repo.all(from n in Neighbor, where: n.device_id == ^device.id)
assert length(remaining) == 1
assert hd(remaining).remote_chassis_id == "new-chassis"
end
end
describe "delete_stale_and_upsert_arp_entries/4" do
test "atomically deletes stale and upserts new ARP entries", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{snmp_device_id: snmp_device.id, if_index: 1, if_name: "eth0"})
|> Repo.insert!()
now = DateTime.truncate(DateTime.utc_now(), :second)
cutoff = DateTime.add(now, -5, :minute)
# Create a stale ARP entry
Repo.insert!(%ArpEntry{
device_id: device.id,
interface_id: interface.id,
ip_address: "10.0.0.99",
mac_address: "aa:bb:cc:dd:ee:ff",
last_seen_at: DateTime.add(now, -10, :minute)
})
# New entries to upsert
new_entries = [
%{
ip_address: "10.0.0.100",
mac_address: "11:22:33:44:55:66",
if_index: interface.if_index,
last_seen_at: now
}
]
assert {:ok, _} =
Snmp.delete_stale_and_upsert_arp_entries(
device.id,
new_entries,
[interface],
cutoff
)
remaining = Repo.all(from a in ArpEntry, where: a.device_id == ^device.id)
assert length(remaining) == 1
assert hd(remaining).ip_address == "10.0.0.100"
end
end
describe "delete_stale_and_upsert_mac_addresses/4" do
test "atomically deletes stale and upserts new MAC addresses", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{snmp_device_id: snmp_device.id, if_index: 1, if_name: "eth0"})
|> Repo.insert!()
now = DateTime.truncate(DateTime.utc_now(), :second)
cutoff = DateTime.add(now, -5, :minute)
# Create a stale MAC entry
Repo.insert!(%MacAddress{
device_id: device.id,
interface_id: interface.id,
mac_address: "aa:bb:cc:dd:ee:ff",
vlan_id: 1,
last_seen_at: DateTime.add(now, -10, :minute)
})
# New entries to upsert
new_entries = [
%{
mac_address: "11:22:33:44:55:66",
port_index: interface.if_index,
vlan_id: 1,
last_seen_at: now
}
]
assert {:ok, _} =
Snmp.delete_stale_and_upsert_mac_addresses(
device.id,
new_entries,
[interface],
cutoff
)
remaining = Repo.all(from m in MacAddress, where: m.device_id == ^device.id)
assert length(remaining) == 1
assert hd(remaining).mac_address == "11:22:33:44:55:66"
end
end
describe "create_checks_from_discovery/2" do
test "creates checks for sensors", %{device: device, snmp_device: snmp_device} do
# Create a sensor to discover
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_descr: "CPU Temperature",
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: ".1.3.6.1.4.1.9.9.13.1.3.1.3.1",
sensor_unit: "°C",
sensor_divisor: 1
})
|> Repo.insert!()
# Run check creation
assert {:ok, counts} = Snmp.create_checks_from_discovery(device, snmp_device)
assert counts.sensors == 1
# Verify check was created
checks = Towerops.Monitoring.list_checks(device.organization_id, device_id: device.id)
assert length(checks) == 1
check = hd(checks)
assert check.name == "CPU Temperature"
assert check.check_type == "snmp_sensor"
assert check.source_type == "auto_discovery"
assert check.source_id == sensor.id
assert check.device_id == device.id
assert check.enabled == true
assert check.interval_seconds == 60
# Verify config contains sensor metadata
assert check.config["sensor_type"] == "temperature"
assert check.config["sensor_oid"] == ".1.3.6.1.4.1.9.9.13.1.3.1.3.1"
end
test "creates checks for interfaces", %{device: device, snmp_device: snmp_device} do
# Create an interface
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_descr: "eth0",
if_name: "Ethernet0",
if_type: 6,
if_speed: 1_000_000_000,
if_oper_status: "up"
})
|> Repo.insert!()
assert {:ok, counts} = Snmp.create_checks_from_discovery(device, snmp_device)
assert counts.interfaces == 1
checks = Towerops.Monitoring.list_checks(device.organization_id, device_id: device.id)
assert length(checks) == 1
check = hd(checks)
assert check.name == "Ethernet0 Status"
assert check.check_type == "snmp_interface"
assert check.source_type == "auto_discovery"
assert check.source_id == interface.id
assert check.config["if_index"] == 1
assert check.config["if_descr"] == "eth0"
end
test "creates checks for processors", %{device: device, snmp_device: snmp_device} do
# Create a processor
processor =
%Processor{}
|> Processor.changeset(%{
snmp_device_id: snmp_device.id,
processor_index: "1",
description: "CPU 1",
processor_type: "hr_processor"
})
|> Repo.insert!()
assert {:ok, counts} = Snmp.create_checks_from_discovery(device, snmp_device)
assert counts.processors == 1
checks = Towerops.Monitoring.list_checks(device.organization_id, device_id: device.id)
check = hd(checks)
assert check.name == "CPU 1"
assert check.check_type == "snmp_processor"
assert check.source_id == processor.id
end
test "creates checks for storage", %{device: device, snmp_device: snmp_device} do
# Create storage
storage =
%Storage{}
|> Storage.changeset(%{
snmp_device_id: snmp_device.id,
storage_index: 1,
description: "/ (root)",
storage_type: "fixed_disk",
total_bytes: 100_000_000,
used_bytes: 50_000_000
})
|> Repo.insert!()
assert {:ok, counts} = Snmp.create_checks_from_discovery(device, snmp_device)
assert counts.storage == 1
checks = Towerops.Monitoring.list_checks(device.organization_id, device_id: device.id)
check = hd(checks)
assert check.name == "/ (root) Usage"
assert check.check_type == "snmp_storage"
assert check.source_id == storage.id
end
test "creates multiple checks for different types", %{device: device, snmp_device: snmp_device} do
# Create multiple items
_sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_descr: "Temperature 1",
sensor_index: "1",
sensor_oid: ".1.2.3.4",
sensor_unit: "°C",
sensor_divisor: 1
})
|> Repo.insert!()
_interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_descr: "eth0",
if_oper_status: "up"
})
|> Repo.insert!()
assert {:ok, counts} = Snmp.create_checks_from_discovery(device, snmp_device)
assert counts.sensors == 1
assert counts.interfaces == 1
assert counts.processors == 0
assert counts.storage == 0
checks = Towerops.Monitoring.list_checks(device.organization_id, device_id: device.id)
assert length(checks) == 2
check_types = checks |> Enum.map(& &1.check_type) |> Enum.sort()
assert check_types == ["snmp_interface", "snmp_sensor"]
end
test "schedules checks for execution", %{device: device, snmp_device: snmp_device} do
_sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_descr: "Test Sensor",
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: ".1.2.3.4",
sensor_unit: "°C",
sensor_divisor: 1
})
|> Repo.insert!()
assert {:ok, _counts} = Snmp.create_checks_from_discovery(device, snmp_device)
# Verify Oban job was scheduled
jobs = Repo.all(from j in Oban.Job, where: j.worker == "Towerops.Workers.CheckExecutorWorker")
assert length(jobs) == 1
job = hd(jobs)
assert job.worker == "Towerops.Workers.CheckExecutorWorker"
assert job.queue == "check_executors"
end
end
end