towerops/test/towerops/snmp_test.exs

2301 lines
65 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.Neighbor
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_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_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_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()
}
]
count = Snmp.upsert_arp_entries(device.id, arp_data, [interface])
assert count == 2
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()
}
]
count = Snmp.upsert_arp_entries(device.id, arp_data, [])
assert count == 1
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 "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
end